@aglyn/plugins-marketing 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.
Files changed (183) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +56 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
  8. package/src/lib/components/announcement-bar-card.component.js +292 -0
  9. package/src/lib/components/announcement-bar-card.component.js.map +1 -0
  10. package/src/lib/components/campaign-composer.d.ts +84 -0
  11. package/src/lib/components/campaign-composer.js +1643 -0
  12. package/src/lib/components/campaign-composer.js.map +1 -0
  13. package/src/lib/components/campaign-conversions-card.d.ts +22 -0
  14. package/src/lib/components/campaign-conversions-card.js +642 -0
  15. package/src/lib/components/campaign-conversions-card.js.map +1 -0
  16. package/src/lib/components/campaign-detail-card.d.ts +55 -0
  17. package/src/lib/components/campaign-detail-card.js +771 -0
  18. package/src/lib/components/campaign-detail-card.js.map +1 -0
  19. package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
  20. package/src/lib/components/campaign-edit-drawer.js +252 -0
  21. package/src/lib/components/campaign-edit-drawer.js.map +1 -0
  22. package/src/lib/components/campaign-email-zones.d.ts +111 -0
  23. package/src/lib/components/campaign-email-zones.js +54 -0
  24. package/src/lib/components/campaign-email-zones.js.map +1 -0
  25. package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
  26. package/src/lib/components/campaign-glance-card.component.js +151 -0
  27. package/src/lib/components/campaign-glance-card.component.js.map +1 -0
  28. package/src/lib/components/campaign-members-section.d.ts +87 -0
  29. package/src/lib/components/campaign-members-section.js +499 -0
  30. package/src/lib/components/campaign-members-section.js.map +1 -0
  31. package/src/lib/components/campaign-reach-sections.d.ts +137 -0
  32. package/src/lib/components/campaign-reach-sections.js +694 -0
  33. package/src/lib/components/campaign-reach-sections.js.map +1 -0
  34. package/src/lib/components/campaign-report-card.d.ts +91 -0
  35. package/src/lib/components/campaign-report-card.js +667 -0
  36. package/src/lib/components/campaign-report-card.js.map +1 -0
  37. package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
  38. package/src/lib/components/campaign-test-send-drawer.js +238 -0
  39. package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
  40. package/src/lib/components/campaigns-card.d.ts +31 -0
  41. package/src/lib/components/campaigns-card.js +667 -0
  42. package/src/lib/components/campaigns-card.js.map +1 -0
  43. package/src/lib/components/conversion-attribution.component.d.ts +62 -0
  44. package/src/lib/components/conversion-attribution.component.js +127 -0
  45. package/src/lib/components/conversion-attribution.component.js.map +1 -0
  46. package/src/lib/components/email-compose-card.d.ts +12 -0
  47. package/src/lib/components/email-compose-card.js +157 -0
  48. package/src/lib/components/email-compose-card.js.map +1 -0
  49. package/src/lib/components/email-detail.d.ts +40 -0
  50. package/src/lib/components/email-detail.js +1180 -0
  51. package/src/lib/components/email-detail.js.map +1 -0
  52. package/src/lib/components/email-edit-drawer.d.ts +54 -0
  53. package/src/lib/components/email-edit-drawer.js +172 -0
  54. package/src/lib/components/email-edit-drawer.js.map +1 -0
  55. package/src/lib/components/email-messages-widget.d.ts +22 -0
  56. package/src/lib/components/email-messages-widget.js +49 -0
  57. package/src/lib/components/email-messages-widget.js.map +1 -0
  58. package/src/lib/components/email-recipients-card.d.ts +50 -0
  59. package/src/lib/components/email-recipients-card.js +338 -0
  60. package/src/lib/components/email-recipients-card.js.map +1 -0
  61. package/src/lib/components/emails-list-card.d.ts +36 -0
  62. package/src/lib/components/emails-list-card.js +544 -0
  63. package/src/lib/components/emails-list-card.js.map +1 -0
  64. package/src/lib/components/experiment-zones.d.ts +78 -0
  65. package/src/lib/components/experiment-zones.js +65 -0
  66. package/src/lib/components/experiment-zones.js.map +1 -0
  67. package/src/lib/components/host-experiments-card.component.d.ts +17 -0
  68. package/src/lib/components/host-experiments-card.component.js +887 -0
  69. package/src/lib/components/host-experiments-card.component.js.map +1 -0
  70. package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
  71. package/src/lib/components/host-marketing-summary-card.component.js +161 -0
  72. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
  73. package/src/lib/components/host-overlays-card.component.d.ts +19 -0
  74. package/src/lib/components/host-overlays-card.component.js +783 -0
  75. package/src/lib/components/host-overlays-card.component.js.map +1 -0
  76. package/src/lib/components/marketing-console-page.d.ts +28 -0
  77. package/src/lib/components/marketing-console-page.js +166 -0
  78. package/src/lib/components/marketing-console-page.js.map +1 -0
  79. package/src/lib/components/marketing-console-sections.d.ts +34 -0
  80. package/src/lib/components/marketing-console-sections.js +77 -0
  81. package/src/lib/components/marketing-console-sections.js.map +1 -0
  82. package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
  83. package/src/lib/components/overlay-stats-row.component.js +78 -0
  84. package/src/lib/components/overlay-stats-row.component.js.map +1 -0
  85. package/src/lib/components/popup-card.component.d.ts +18 -0
  86. package/src/lib/components/popup-card.component.js +462 -0
  87. package/src/lib/components/popup-card.component.js.map +1 -0
  88. package/src/lib/components/record-attribution-widget.d.ts +35 -0
  89. package/src/lib/components/record-attribution-widget.js +33 -0
  90. package/src/lib/components/record-attribution-widget.js.map +1 -0
  91. package/src/lib/components/site-runtime.d.ts +9 -0
  92. package/src/lib/components/site-runtime.js +1081 -0
  93. package/src/lib/components/site-runtime.js.map +1 -0
  94. package/src/lib/components/use-campaign-send-api.d.ts +35 -0
  95. package/src/lib/components/use-campaign-send-api.js +99 -0
  96. package/src/lib/components/use-campaign-send-api.js.map +1 -0
  97. package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
  98. package/src/lib/components/use-campaign-topic-options.js +44 -0
  99. package/src/lib/components/use-campaign-topic-options.js.map +1 -0
  100. package/src/lib/components/use-emails-hub-path.d.ts +39 -0
  101. package/src/lib/components/use-emails-hub-path.js +70 -0
  102. package/src/lib/components/use-emails-hub-path.js.map +1 -0
  103. package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
  104. package/src/lib/components/use-marketing-hub-path.js +50 -0
  105. package/src/lib/components/use-marketing-hub-path.js.map +1 -0
  106. package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
  107. package/src/lib/components/use-overlay-copy-editor.js +110 -0
  108. package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
  109. package/src/lib/constants/bundle-common.d.ts +8 -0
  110. package/src/lib/constants/bundle-common.js +9 -0
  111. package/src/lib/constants/bundle-common.js.map +1 -0
  112. package/src/lib/model/campaign-membership-figures.d.ts +109 -0
  113. package/src/lib/model/campaign-membership-figures.js +113 -0
  114. package/src/lib/model/campaign-membership-figures.js.map +1 -0
  115. package/src/lib/model/compile-client-automations.d.ts +65 -0
  116. package/src/lib/model/compile-client-automations.js +95 -0
  117. package/src/lib/model/compile-client-automations.js.map +1 -0
  118. package/src/lib/model/experiments.d.ts +130 -0
  119. package/src/lib/model/experiments.js +186 -0
  120. package/src/lib/model/experiments.js.map +1 -0
  121. package/src/lib/model/index.d.ts +26 -0
  122. package/src/lib/model/index.js +26 -0
  123. package/src/lib/model/index.js.map +1 -0
  124. package/src/lib/model/marketing-record-routes.d.ts +6 -0
  125. package/src/lib/model/marketing-record-routes.js +46 -0
  126. package/src/lib/model/marketing-record-routes.js.map +1 -0
  127. package/src/lib/model/overlay-copy.d.ts +62 -0
  128. package/src/lib/model/overlay-copy.js +78 -0
  129. package/src/lib/model/overlay-copy.js.map +1 -0
  130. package/src/lib/model/overlays.d.ts +113 -0
  131. package/src/lib/model/overlays.js +93 -0
  132. package/src/lib/model/overlays.js.map +1 -0
  133. package/src/lib/model/site-contract.d.ts +100 -0
  134. package/src/lib/model/site-contract.js +23 -0
  135. package/src/lib/model/site-contract.js.map +1 -0
  136. package/src/lib/plugin.d.ts +27 -0
  137. package/src/lib/plugin.js +168 -0
  138. package/src/lib/plugin.js.map +1 -0
  139. package/src/lib/preview-props.d.ts +31 -0
  140. package/src/lib/preview-props.js +54 -0
  141. package/src/lib/preview-props.js.map +1 -0
  142. package/src/lib/server/campaign-manage.d.ts +62 -0
  143. package/src/lib/server/campaign-manage.js +829 -0
  144. package/src/lib/server/campaign-manage.js.map +1 -0
  145. package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
  146. package/src/lib/server/campaign-process-scheduled.js +198 -0
  147. package/src/lib/server/campaign-process-scheduled.js.map +1 -0
  148. package/src/lib/server/campaign-recipients.d.ts +55 -0
  149. package/src/lib/server/campaign-recipients.js +219 -0
  150. package/src/lib/server/campaign-recipients.js.map +1 -0
  151. package/src/lib/server/campaign-send.d.ts +487 -0
  152. package/src/lib/server/campaign-send.js +3157 -0
  153. package/src/lib/server/campaign-send.js.map +1 -0
  154. package/src/lib/server/email-campaign-reach.d.ts +174 -0
  155. package/src/lib/server/email-campaign-reach.js +254 -0
  156. package/src/lib/server/email-campaign-reach.js.map +1 -0
  157. package/src/lib/server/email-events.d.ts +27 -0
  158. package/src/lib/server/email-events.js +714 -0
  159. package/src/lib/server/email-events.js.map +1 -0
  160. package/src/lib/server/get-client-automations.d.ts +60 -0
  161. package/src/lib/server/get-client-automations.js +87 -0
  162. package/src/lib/server/get-client-automations.js.map +1 -0
  163. package/src/lib/server/get-overlays.d.ts +28 -0
  164. package/src/lib/server/get-overlays.js +65 -0
  165. package/src/lib/server/get-overlays.js.map +1 -0
  166. package/src/lib/server/get-screen-experiments.d.ts +42 -0
  167. package/src/lib/server/get-screen-experiments.js +82 -0
  168. package/src/lib/server/get-screen-experiments.js.map +1 -0
  169. package/src/lib/server/lists-materialize.d.ts +18 -0
  170. package/src/lib/server/lists-materialize.js +130 -0
  171. package/src/lib/server/lists-materialize.js.map +1 -0
  172. package/src/lib/server/marketing-figures.d.ts +37 -0
  173. package/src/lib/server/marketing-figures.js +248 -0
  174. package/src/lib/server/marketing-figures.js.map +1 -0
  175. package/src/lib/server/site-page-enricher.d.ts +26 -0
  176. package/src/lib/server/site-page-enricher.js +210 -0
  177. package/src/lib/server/site-page-enricher.js.map +1 -0
  178. package/src/lib/server.d.ts +20 -0
  179. package/src/lib/server.js +152 -0
  180. package/src/lib/server.js.map +1 -0
  181. package/src/lib/site.d.ts +22 -0
  182. package/src/lib/site.js +48 -0
  183. package/src/lib/site.js.map +1 -0
@@ -0,0 +1,1643 @@
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
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
+ import { buildRoute, checkQuota, Route } from "@aglyn/aglyn";
20
+ import { EMAIL_MERGE_TAGS, emailMessageMode, emailPlainTextState } from "@aglyn/aglyn/app-utils/recipient-email-render";
21
+ import { useConfirmationContext } from "@aglyn/shared-ui-jsx";
22
+ import QuotaReadoutComponent from "@aglyn/shared-ui-jsx/components/quota-readout.component";
23
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
24
+ import { Alert, Box, Button, Chip, Divider, MenuItem, Stack, TextField, Typography } from "@mui/material";
25
+ import { collection, doc, limit, query } from "firebase/firestore";
26
+ import { CampaignDesignCreate, CampaignSenderEditor, CampaignTopicSelect } from "./campaign-email-zones.js";
27
+ import { useCampaignSendApi } from "./use-campaign-send-api.js";
28
+ import { useSendingApi } from "@aglyn/tenant-feature-instance/hooks/use-sending-identity-api";
29
+ import { describeCallFailure } from "@aglyn/shared-util-http/authorized-token";
30
+ import CampaignTestSendDrawer from "./campaign-test-send-drawer.js";
31
+ import { useRouter } from "next/navigation";
32
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
+ import { useConsoleHostRoute, useFirestore, useFirestoreCollection, useFirestoreDoc, useOrgDataScope, useHostOrgId, useOrgPlan } from "@aglyn/tenant-feature-instance";
34
+ // The besigner route is `/[orgSlug]/hosts/[host]/screens/[screenId]/
35
+ // versions/[versionId]/besigner`. Takes the resolved org slug + subdomain,
36
+ // not a host doc id.
37
+ const besignerHref = (orgSlug, host, screenId, versionId)=>buildRoute(Route.SCREEN_BESIGNER, {
38
+ orgSlug,
39
+ host,
40
+ screenId,
41
+ versionId
42
+ });
43
+ /**
44
+ * How long the composer waits after a keystroke before asking the server to
45
+ * re-render the message.
46
+ *
47
+ * Longer than the audience debounce below because it fires on typing rather
48
+ * than on a picker: a merchant writing a paragraph would otherwise issue a
49
+ * render for every pause in it.
50
+ */ const RENDER_DEBOUNCE_MS = 600;
51
+ /** How long the audience picker settles before the count is re-resolved. */ const COUNT_DEBOUNCE_MS = 400;
52
+ const plural = (count, noun)=>`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
53
+ /**
54
+ * COMPOSE AND SEND ONE EMAIL.
55
+ *
56
+ * Everything a merchant decides about a single message: who it goes to, what
57
+ * it says, who it appears to come from, what it will look like, and — before
58
+ * the button that cannot be taken back — how many people it reaches and how
59
+ * many it does not.
60
+ */ /**
61
+ * The picker entry that OPENS THE SENDER EDITOR rather than selecting a row.
62
+ *
63
+ * A one-off address is deliberately not offered, and this is what replaces it.
64
+ * An address typed into one campaign and stored nowhere is a mailbox nobody
65
+ * serves — its bounces come back to nothing and a client ignoring `Reply-To:`
66
+ * answers into a void — which does not stop being true because the merchant
67
+ * meant it. What they actually want is a sender they had not set up yet, and
68
+ * from here that costs one drawer: validated once, served, and reusable.
69
+ *
70
+ * A one-off NAME and a one-off reply address are a different matter and have
71
+ * always worked, as the two per-send fields below.
72
+ */ const ADD_SENDER_OPTION = 'add-a-sender';
73
+ /**
74
+ * What one of the two per-send sender fields is DOING, in a sentence.
75
+ *
76
+ * Three states, and the middle one is why this exists. The composer seeds both
77
+ * fields from the chosen sender, so a merchant opening a new email finds them
78
+ * already filled with values that are defined on another screen — and a field
79
+ * that silently matches a setting elsewhere reads as a second definition of
80
+ * it, which is exactly the "two pages define overlapping things" confusion the
81
+ * From line above was missing.
82
+ *
83
+ * Naming the inheritance costs a sentence and settles it: the value came from
84
+ * the sender, editing it is per-email, and the sender is unchanged either way.
85
+ * The send agrees — `campaign-send.ts` resolves `options.fromName ||
86
+ * branding.fromName`, so what is submitted here is what goes out and what is
87
+ * recorded on the report.
88
+ */ function senderFieldHelp(field, saved, current) {
89
+ const base = field === 'name' ? 'Shown in front of the address.' : 'Where replies go — any mailbox, including a personal one.';
90
+ if (!saved) {
91
+ return field === 'name' ? `${base} Your brand name is used when this is empty.` : base;
92
+ }
93
+ return current.trim() === saved ? `${base} This is the sender’s saved ${field}; editing it changes this email only.` : `${base} Overrides the sender’s saved ${field}, ${saved}, for this email only.`;
94
+ }
95
+ export function CampaignComposer(props) {
96
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8, _ref9, _ref10, _ref11, _ref12, _ref13, _ref14, _ref15, _ref16, _ref17, _find, _ref18, _ref19, _ref20, _ref21, _ref22;
97
+ var _identityOptions_find;
98
+ const { hostId, emailCampaignId, campaignListIds, campaignTopicId, displayName, campaignId, initial, onSent } = props;
99
+ const { orgSlug, subdomain } = useConsoleHostRoute(hostId);
100
+ // Org-shared data root. Null until the org lookup settles, and for a host
101
+ // with no owning org — so the audience picker offers the built-ins alone
102
+ // rather than segments and lists from a dead path.
103
+ const { scope: dataScope } = useOrgDataScope({
104
+ hostId
105
+ });
106
+ const firestore = useFirestore();
107
+ const { enqueueSnackbar } = useSnackbar();
108
+ const { confirm } = useConfirmationContext();
109
+ const router = useRouter();
110
+ // Contact segments join the built-in audiences.
111
+ const { data: segmentDocs } = useFirestoreCollection(()=>dataScope ? query(collection(firestore, dataScope[0], dataScope[1], 'contactSegments'), limit(50)) : null, [
112
+ firestore,
113
+ dataScope
114
+ ], {
115
+ idField: '$id'
116
+ });
117
+ const segments = [
118
+ ...segmentDocs != null ? segmentDocs : []
119
+ ].sort((a, b)=>{
120
+ var _a_name, _b_name;
121
+ return String((_a_name = a.name) != null ? _a_name : '').localeCompare(String((_b_name = b.name) != null ? _b_name : ''));
122
+ });
123
+ // Org email lists join the audiences.
124
+ const { data: listDocs } = useFirestoreCollection(()=>dataScope ? query(collection(firestore, dataScope[0], dataScope[1], 'lists'), limit(50)) : null, [
125
+ firestore,
126
+ dataScope
127
+ ], {
128
+ idField: '$id'
129
+ });
130
+ const lists = [
131
+ ...listDocs != null ? listDocs : []
132
+ ].sort((a, b)=>{
133
+ var _a_name, _b_name;
134
+ return String((_a_name = a.name) != null ? _a_name : '').localeCompare(String((_b_name = b.name) != null ? _b_name : ''));
135
+ });
136
+ /**
137
+ * The monthly campaign allowance, standing rather than only on refusal.
138
+ *
139
+ * READ THE ENFORCEABLE METER, NOT THE COST ONE. `emailSends` beside it
140
+ * counts every receipt, booking reminder and password reset the site sent;
141
+ * showing that total against this cap would tell a merchant they had spent
142
+ * their campaign allowance on order confirmations.
143
+ *
144
+ * PER ORG, because that is where the cap is enforced.
145
+ * `emailSendsPerMonth` is an ORG entitlement and `reserveCampaignEmailSends`
146
+ * claims against `orgs/{orgId}/counters/campaignEmailSends[YYYY-MM]`. A
147
+ * readout and its gate must read the same counter — the number a merchant
148
+ * checks before pressing Send is the worst possible place for a second
149
+ * opinion.
150
+ */ const { org, ready: orgReady } = useOrgPlan(hostId);
151
+ const campaignOrgId = useHostOrgId(hostId);
152
+ const campaignMonthKey = new Date().toISOString().slice(0, 7);
153
+ const { data: campaignSendCounter } = useFirestoreDoc(()=>campaignOrgId ? doc(firestore, 'orgs', campaignOrgId, 'counters', 'campaignEmailSends') : null, [
154
+ firestore,
155
+ campaignOrgId
156
+ ]);
157
+ // An org that has never sent a campaign has no counter document at all;
158
+ // that is a settled zero, and the same zero `orgCampaignEmailSendsForMonth`
159
+ // resolves it to on the server.
160
+ const campaignSendsUsed = Number((_ref = campaignSendCounter == null ? void 0 : campaignSendCounter[campaignMonthKey]) != null ? _ref : 0);
161
+ // Email A/B experiments: running (or winner-decided) email experiments the
162
+ // composer can attach.
163
+ const { data: experimentDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'experiments'), limit(50)), [
164
+ firestore,
165
+ hostId
166
+ ], {
167
+ idField: '$id'
168
+ });
169
+ const emailExperiments = [
170
+ ...experimentDocs != null ? experimentDocs : []
171
+ ].filter((experiment)=>!experiment.deletedAt && experiment.target === 'email' && (experiment.status === 'running' || experiment.winnerVariantId)).sort((a, b)=>{
172
+ var _a_name, _b_name;
173
+ return String((_a_name = a.name) != null ? _a_name : '').localeCompare(String((_b_name = b.name) != null ? _b_name : ''));
174
+ });
175
+ // Designed emails: besigner email documents are screens with kind 'email';
176
+ // campaigns reference them by screen id.
177
+ const { data: screenDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'screens'), limit(200)), [
178
+ firestore,
179
+ hostId
180
+ ], {
181
+ idField: '$id'
182
+ });
183
+ const emailScreens = [
184
+ ...screenDocs != null ? screenDocs : []
185
+ ].filter((screen)=>!screen.deletedAt && screen.kind === 'email').sort((a, b)=>{
186
+ var _a_displayName, _b_displayName;
187
+ return String((_a_displayName = a.displayName) != null ? _a_displayName : '').localeCompare(String((_b_displayName = b.displayName) != null ? _b_displayName : ''));
188
+ });
189
+ /*
190
+ * The stored audience, back in the picker's own `kind:id` form.
191
+ *
192
+ * The record keeps the kind and the id in two fields because that is what
193
+ * the send path reads; the picker is one control, so the two are rejoined
194
+ * here. A composer opened on an existing email that defaulted to Leads
195
+ * would be one save away from silently re-aiming it.
196
+ */ const storedAudience = (initial == null ? void 0 : initial.audience) ? initial.audience === 'list' && initial.listId ? `list:${initial.listId}` : initial.audience === 'segment' && initial.segmentId ? `segment:${initial.segmentId}` : initial.audience : '';
197
+ const [subject, setSubject] = useState((_ref1 = initial == null ? void 0 : initial.subject) != null ? _ref1 : '');
198
+ const [body, setBody] = useState((_ref2 = initial == null ? void 0 : initial.body) != null ? _ref2 : '');
199
+ const [fromName, setFromName] = useState((_ref3 = initial == null ? void 0 : initial.fromName) != null ? _ref3 : '');
200
+ const [replyTo, setReplyTo] = useState((_ref4 = initial == null ? void 0 : initial.replyTo) != null ? _ref4 : '');
201
+ const [preheader, setPreheader] = useState((_ref5 = initial == null ? void 0 : initial.preheader) != null ? _ref5 : '');
202
+ /**
203
+ * WHICH OF THIS SITE'S IDENTITIES THIS EMAIL LEAVES ON.
204
+ *
205
+ * Two values and no more — empty for the site's standing selection, and
206
+ * `platform` for the domain the platform issued the site, which is what
207
+ * clearing the selection resolves to. It is deliberately not a domain name:
208
+ * which custom domain a site may use is an org-admin decision stored on the
209
+ * host, and a composer that could name one would be a way for a site editor
210
+ * to send as a domain their site was never given.
211
+ */ const [sendingIdentity, setSendingIdentity] = useState((_ref6 = initial == null ? void 0 : initial.sendingIdentity) != null ? _ref6 : '');
212
+ /**
213
+ * WHICH OF THE SITE'S SENDERS THIS EMAIL GOES OUT AS.
214
+ *
215
+ * An id into `hosts/{hostId}/senders`, empty for the site's default. It is
216
+ * an id and not an address, which is the difference between this and the
217
+ * field above it: the mailbox behind it was configured once under the
218
+ * organization-admin gate, so a composer choosing here can only reach an
219
+ * address the site was already set up to send as.
220
+ */ const [senderId, setSenderId] = useState((_ref7 = initial == null ? void 0 : initial.senderId) != null ? _ref7 : '');
221
+ /*
222
+ * The sender this composer OPENED on, which is a stable prop rather than the
223
+ * state above. The identity read below seeds two fields from it, and
224
+ * depending on the state would re-issue that request every time somebody
225
+ * changed the picker.
226
+ */ const openedAsSenderId = (_ref8 = initial == null ? void 0 : initial.senderId) != null ? _ref8 : '';
227
+ /*
228
+ * The campaign's own list is where the composer opens, because a send
229
+ * composed inside a campaign aimed at a list is overwhelmingly a send to
230
+ * that list — and an audience picker that defaults to Leads inside such a
231
+ * campaign is one wrong keystroke away from mailing the wrong people.
232
+ */ const [audience, setAudience] = useState(storedAudience || ((campaignListIds == null ? void 0 : campaignListIds[0]) ? `list:${campaignListIds[0]}` : 'leads'));
233
+ const [experimentId, setExperimentId] = useState('');
234
+ /*
235
+ * WHICH STREAM this email belongs to.
236
+ *
237
+ * Empty until the picker settles it against the org's catalog, and sent on
238
+ * every request that resolves an audience — the count as much as the send.
239
+ * `filterTopicSendable` removes the people who have left this stream, so a
240
+ * preview taken without the topic reports a reach the send will not deliver,
241
+ * and a send taken without it records `marketing` for a sales campaign,
242
+ * which is the opt-out a recipient already exercised being ignored.
243
+ */ const [topicId, setTopicId] = useState((_ref9 = (_ref10 = initial == null ? void 0 : initial.topicId) != null ? _ref10 : campaignTopicId) != null ? _ref9 : '');
244
+ const [templateScreenId, setTemplateScreenId] = useState((_ref11 = initial == null ? void 0 : initial.templateScreenId) != null ? _ref11 : '');
245
+ /**
246
+ * HOW THIS EMAIL IS WRITTEN — a choice, not an inference.
247
+ *
248
+ * The form used to show a design picker and a message box at once and say
249
+ * nothing about which one the send would use. It used the design and dropped
250
+ * the message, silently, so a merchant who did both mailed something they
251
+ * had not written.
252
+ *
253
+ * TWO modes, because the data model has two things and not three: a saved
254
+ * TEMPLATE is a besigner email screen — the same `kind: 'email'` document
255
+ * the picker below lists and "Design this email" creates — so "designed"
256
+ * and "from a template" are one mode under two names. There is no third
257
+ * thing to offer.
258
+ *
259
+ * Seeded from the record through the same resolver the send path reads, so
260
+ * a draft reopens in the mode it was saved in rather than in whichever the
261
+ * form happens to default to.
262
+ */ const [mode, setMode] = useState(()=>emailMessageMode({
263
+ templateScreenId: initial == null ? void 0 : initial.templateScreenId
264
+ }));
265
+ /**
266
+ * WHERE A DESIGNED EMAIL'S PLAIN-TEXT HALF COMES FROM.
267
+ *
268
+ * Generated by default — the design already produces one, and it is what
269
+ * every designed campaign has always mailed. `authored` is the deliberate
270
+ * override, held as its own state rather than inferred from the text being
271
+ * non-empty: choosing to write one and then clearing the box would
272
+ * otherwise snap the choice back under the author's hands.
273
+ *
274
+ * The RECORD still says it with presence alone — a stored `plainText` is an
275
+ * override and an absent one is generated — so there is no flag on the
276
+ * document to fall out of step with the value beside it.
277
+ */ const [plainTextSource, setPlainTextSource] = useState(()=>emailPlainTextState({
278
+ plainText: initial == null ? void 0 : initial.plainText
279
+ }).source);
280
+ const [plainText, setPlainText] = useState((_ref12 = initial == null ? void 0 : initial.plainText) != null ? _ref12 : '');
281
+ /**
282
+ * Which design version the text part above was written against.
283
+ *
284
+ * Stamped on every edit, so the record carries the answer to "does this
285
+ * still describe the design" rather than leaving a reader to guess. The
286
+ * composer compares it with the design's current version and says when the
287
+ * two have parted; nothing overwrites the author's words on their behalf.
288
+ */ const [plainTextVersionId, setPlainTextVersionId] = useState((_ref13 = initial == null ? void 0 : initial.plainTextVersionId) != null ? _ref13 : '');
289
+ // Scheduling: a future timestamp turns Send into Schedule.
290
+ const [sendAt, setSendAt] = useState('');
291
+ /** Whether the sender editor is open, opened from the From picker. */ const [addingSender, setAddingSender] = useState(false);
292
+ const [busy, setBusy] = useState(false);
293
+ const [showPreview, setShowPreview] = useState(false);
294
+ const designed = mode === 'design';
295
+ /*==========================================
296
+ * WHAT THIS COMPOSER SUBMITS: the field its mode owns, and nothing else.
297
+ *
298
+ * Both halves are kept in component state while the form is open, so
299
+ * switching modes to look at the other one and switching back loses no
300
+ * typing. Only one of them is ever SENT — which is what makes the discard
301
+ * impossible from here rather than merely warned about, and what keeps the
302
+ * stored record in one mode instead of carrying two sources for the route
303
+ * to choose between later.
304
+ *=========================================*/ const sentTemplateScreenId = designed ? templateScreenId : '';
305
+ const sentBody = designed ? '' : body.trim();
306
+ /*
307
+ * The text half rides only where it has a job. On a plain-text email the
308
+ * body IS the text part, so an override there would be a second source for
309
+ * the same half — which the route refuses, and which this never submits.
310
+ */ const sentPlainText = designed && plainTextSource === 'authored' ? plainText.trim() : '';
311
+ /** Whether the mode has what it needs — a design, or something written. */ const messageReady = designed ? Boolean(templateScreenId) : Boolean(body.trim());
312
+ const selectedTemplate = emailScreens.find((screen)=>screen.$id === templateScreenId);
313
+ /*
314
+ * The draft save, reachable from above its declaration. `handleDesignCreated`
315
+ * persists the created design onto the record through the same save the
316
+ * Save draft button uses, and that callback closes over most of the form —
317
+ * a ref keeps this one from having to depend on all of it.
318
+ */ const handleSaveDraftRef = useRef(null);
319
+ /**
320
+ * A DESIGN FOR THIS EMAIL, without making the author think in templates.
321
+ *
322
+ * A one-off campaign does not want a reusable artifact — it wants its own
323
+ * design document. The data model has only one shape for that (a `kind:
324
+ * 'email'` screen, the same document the picker lists), so this mints one,
325
+ * but named after the email it belongs to rather than "Untitled email":
326
+ * the name is the only thing that tells one design from another in the
327
+ * picker, and a list of identical "Untitled email" rows tells nobody
328
+ * anything. Saved templates stay what they are — picking one from the
329
+ * select above is the reuse path.
330
+ *
331
+ * The selection is written onto the record before the editor navigation
332
+ * unmounts this form; without that, the design would exist while the
333
+ * campaign still pointed at nothing, and the author would have to come
334
+ * back and find their own screen in the picker.
335
+ */ const handleDesignFailed = useCallback((error)=>{
336
+ var _ref;
337
+ console.error(error);
338
+ enqueueSnackbar((_ref = error == null ? void 0 : error.message) != null ? _ref : 'Creating the email design failed', {
339
+ variant: 'error'
340
+ });
341
+ }, [
342
+ enqueueSnackbar
343
+ ]);
344
+ /*
345
+ * The design itself is minted by the widget in the create zone — a design
346
+ * document is the email plugin's — and named here. What is this composer's
347
+ * is the campaign: recording the choice on it, and opening the editor.
348
+ */ const handleDesignCreated = useCallback(async (design)=>{
349
+ const { screenId, versionId } = design;
350
+ try {
351
+ setTemplateScreenId(screenId);
352
+ if (campaignId) {
353
+ await (handleSaveDraftRef.current == null ? void 0 : handleSaveDraftRef.current.call(handleSaveDraftRef, {
354
+ templateScreenId: screenId
355
+ }));
356
+ }
357
+ if (orgSlug && subdomain) {
358
+ void router.push(besignerHref(orgSlug, subdomain, screenId, versionId));
359
+ }
360
+ } catch (error) {
361
+ handleDesignFailed(error);
362
+ }
363
+ }, [
364
+ campaignId,
365
+ orgSlug,
366
+ subdomain,
367
+ router,
368
+ handleDesignFailed
369
+ ]);
370
+ /*
371
+ * The one authorized caller of the campaign API, shared with the email
372
+ * detail page's "send to more people". It holds the user in a ref for a
373
+ * reason the preview effects below depend on — see the hook.
374
+ */ const authorizedPost = useCampaignSendApi(hostId);
375
+ /*
376
+ * Proofing is a drawer now, not a button that mails you.
377
+ *
378
+ * "Send test to me" could only ever answer one of the three questions a
379
+ * proof is asked: what does this look like, to somebody with real data, at
380
+ * an address I can actually open. It mailed the caller, rendered against
381
+ * the caller, from whatever identity the site happened to hold — so a
382
+ * merchant checking their merge tags saw the fallbacks every time.
383
+ */ const [testOpen, setTestOpen] = useState(false);
384
+ /*
385
+ * The audience select's value packs the kind and the id into one string
386
+ * (`list:abc`). Decomposed ONCE here so the preview and the send cannot
387
+ * disagree about what they are asking for — they had two copies of this
388
+ * split, which is how a preview counts a segment while the send resolves
389
+ * a list.
390
+ */ const audienceKind = audience.startsWith('segment:') ? 'segment' : audience.startsWith('list:') ? 'list' : audience;
391
+ const segmentId = audience.startsWith('segment:') ? audience.slice('segment:'.length) : '';
392
+ const listId = audience.startsWith('list:') ? audience.slice('list:'.length) : '';
393
+ /**
394
+ * HOW MANY PEOPLE, AND ON WHAT BASIS — a dry run of the real send path.
395
+ *
396
+ * It comes from the send path rather than a count of its own, so it has
397
+ * already been through audience resolution, de-duplication, the consent
398
+ * rule, the per-send cap, the suppression list and the monthly quota.
399
+ * Counting the audience here instead would be a second set of rules to
400
+ * drift from the one that decides what actually goes out — on the one
401
+ * number a merchant checks before pressing Send.
402
+ */ const [preview, setPreview] = useState(null);
403
+ useEffect(()=>{
404
+ let active = true;
405
+ setPreview(null);
406
+ // Debounced: switching audience with the keyboard walks the whole
407
+ // list, and each stop would otherwise be a full audience resolution.
408
+ const timer = setTimeout(async ()=>{
409
+ try {
410
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8;
411
+ /*
412
+ * NO SUBJECT AND NO BODY, and that is the point of this request.
413
+ *
414
+ * The count is a fact about the AUDIENCE. Nothing in resolving it
415
+ * reads the copy, so nothing about the copy may gate it — a merchant
416
+ * asks "how many people is this" before writing the email, which is
417
+ * when the answer is worth having. Sending the draft along would also
418
+ * put the composer's whole audience resolution — up to five thousand
419
+ * documents — behind every keystroke, for a number that had not moved.
420
+ * The message itself is previewed by `rendered` below, which resolves
421
+ * nobody.
422
+ */ const { response, payload } = await authorizedPost(_extends({
423
+ action: 'preview',
424
+ audience: audienceKind
425
+ }, segmentId ? {
426
+ segmentId
427
+ } : {}, listId ? {
428
+ listId
429
+ } : {}, topicId ? {
430
+ topicId
431
+ } : {}, sendingIdentity ? {
432
+ sendingIdentity
433
+ } : {}, senderId ? {
434
+ senderId
435
+ } : {}));
436
+ if (!active) return;
437
+ if (!response.ok) {
438
+ var _ref9;
439
+ // The refusals are useful, not noise: "The audience is empty"
440
+ // and the monthly-cap message are exactly what a merchant needs
441
+ // BEFORE writing the email rather than after.
442
+ //
443
+ // A 409 is the one that must also STOP the send. Every other
444
+ // refusal here is about the audience and can be true while the
445
+ // email is still worth composing; this one says the message has
446
+ // nowhere to leave from, and letting Send stay live would trade a
447
+ // sentence the merchant can act on for the same 409 arriving after
448
+ // the click.
449
+ return setPreview({
450
+ error: String((_ref9 = payload == null ? void 0 : payload.error) != null ? _ref9 : ''),
451
+ blocking: response.status === 409
452
+ });
453
+ }
454
+ setPreview({
455
+ sendable: Number((_ref = payload == null ? void 0 : payload.sendable) != null ? _ref : 0),
456
+ suppressed: Number((_ref1 = payload == null ? void 0 : payload.suppressed) != null ? _ref1 : 0),
457
+ audienceSize: Number((_ref2 = payload == null ? void 0 : payload.audienceSize) != null ? _ref2 : 0),
458
+ audienceTruncated: Boolean(payload == null ? void 0 : payload.audienceTruncated),
459
+ consented: Number((_ref3 = payload == null ? void 0 : payload.consented) != null ? _ref3 : 0),
460
+ grandfathered: Number((_ref4 = payload == null ? void 0 : payload.grandfathered) != null ? _ref4 : 0),
461
+ consentWithheld: Number((_ref5 = payload == null ? void 0 : payload.consentWithheld) != null ? _ref5 : 0),
462
+ cadenceHeld: Number((_ref6 = payload == null ? void 0 : payload.cadenceHeld) != null ? _ref6 : 0),
463
+ identity: String((_ref7 = payload == null ? void 0 : payload.identity) != null ? _ref7 : ''),
464
+ identitySource: (_ref8 = payload == null ? void 0 : payload.identitySource) != null ? _ref8 : null
465
+ });
466
+ } catch (error) {
467
+ /*
468
+ * A REFUSAL IS NOT A LOADING STATE.
469
+ *
470
+ * `null` is the value this holds before the first answer, and the
471
+ * readout renders it as "Counting recipients…". A call that failed
472
+ * before it ever reached the route — authorization that could not be
473
+ * obtained — has to read as the refusal it is, or the composer sits
474
+ * on a sentence about work it has stopped doing and the confirm
475
+ * dialog reports an unreadable count without saying why.
476
+ */ if (active) setPreview({
477
+ error: describeCallFailure(error, 'Could not count this audience')
478
+ });
479
+ }
480
+ }, COUNT_DEBOUNCE_MS);
481
+ return ()=>{
482
+ active = false;
483
+ clearTimeout(timer);
484
+ };
485
+ }, [
486
+ authorizedPost,
487
+ audienceKind,
488
+ segmentId,
489
+ listId,
490
+ topicId,
491
+ sendingIdentity,
492
+ senderId
493
+ ]);
494
+ /**
495
+ * THE IDENTITIES THIS SITE MAY SEND AS, for the picker.
496
+ *
497
+ * One cheap read — the host document and at most one org subcollection
498
+ * document — issued once, and NOT part of the debounced count above: it
499
+ * answers a question about the site rather than about the message, so it
500
+ * cannot move while somebody types.
501
+ *
502
+ * The picker is only offered when there is a choice to make. A site with no
503
+ * verified domain has exactly one identity, and a select with one option is
504
+ * a control that reads as a decision the person failed to take.
505
+ */ const [identityOptions, setIdentityOptions] = useState(null);
506
+ /**
507
+ * The value that means "the site's standing selection" in the picker.
508
+ *
509
+ * The picker's options are keyed by domain (and `platform`), while the SEND
510
+ * carries the two-valued thing — empty or `platform`. This is the join
511
+ * between them, and it is derived from the options rather than stored: the
512
+ * site's own domain is whichever offered option is not the platform one.
513
+ */ const siteDefaultIdentity = (_ref14 = identityOptions == null ? void 0 : (_identityOptions_find = identityOptions.find((one)=>one.value !== 'platform')) == null ? void 0 : _identityOptions_find.value) != null ? _ref14 : 'platform';
514
+ /**
515
+ * The identity as the route reports it, whole.
516
+ *
517
+ * Kept rather than picked apart into the two fields this file reads,
518
+ * because the sender drawer opens from here and takes the same view the
519
+ * Sending card hands it — so the editor and the picker agree about what a
520
+ * sender currently is instead of holding two answers.
521
+ *
522
+ * Null until the read lands, so the controls below render nothing rather
523
+ * than an empty picker — the same reason `identityOptions` starts null.
524
+ */ const [identityView, setIdentityView] = useState(null);
525
+ const senders = (_ref15 = identityView == null ? void 0 : identityView.senders) != null ? _ref15 : null;
526
+ /**
527
+ * Bumped to re-read the identity, and the pending row to select when it
528
+ * lands.
529
+ *
530
+ * A sender added from the composer exists only after the route answers, so
531
+ * the selection cannot be made at the moment somebody presses Save — the
532
+ * list it would select from has not been read yet. The id is parked here and
533
+ * applied by the read, which is also what puts the new sender's name and
534
+ * reply address into the two fields below.
535
+ *
536
+ * A ref rather than state for the pending id: it is read INSIDE the effect,
537
+ * and as state it would be a dependency, so every selection would re-issue
538
+ * the request that had just answered.
539
+ */ const [identityReads, setIdentityReads] = useState(0);
540
+ const pendingSenderRef = useRef('');
541
+ const sendingApi = useSendingApi();
542
+ useEffect(()=>{
543
+ let active = true;
544
+ void (async ()=>{
545
+ var _ref, _ref1, _ref2, _ref3;
546
+ const { response, payload } = await sendingApi({
547
+ path: 'sending-identity',
548
+ method: 'GET',
549
+ query: {
550
+ hostId
551
+ }
552
+ });
553
+ if (!active || !response.ok) return;
554
+ setIdentityOptions((_ref = payload == null ? void 0 : payload.options) != null ? _ref : []);
555
+ const rows = (_ref1 = payload == null ? void 0 : payload.senders) != null ? _ref1 : [];
556
+ setIdentityView(payload);
557
+ /*
558
+ * A sender just added from this composer is selected here, where the
559
+ * row it names finally exists, and brings its two fields with it —
560
+ * which is the same thing choosing an existing sender does.
561
+ */ const pending = pendingSenderRef.current;
562
+ pendingSenderRef.current = '';
563
+ const added = pending ? rows.find((one)=>one.id === pending) : null;
564
+ if (added) {
565
+ var _added_fromName, _added_replyTo;
566
+ setSenderId(added.id);
567
+ setFromName((_added_fromName = added.fromName) != null ? _added_fromName : '');
568
+ setReplyTo((_added_replyTo = added.replyTo) != null ? _added_replyTo : '');
569
+ return;
570
+ }
571
+ /*
572
+ * THE SENDER'S NAME AND REPLY ADDRESS, AS A STARTING POINT AND NOT AS
573
+ * AN OVERRIDE.
574
+ *
575
+ * A sender carries who its mail comes from — a person's name and where
576
+ * their replies land — and this is where that reaches a campaign. It is
577
+ * applied only to a field that is still empty, so it seeds a new
578
+ * composer and never rewrites a value somebody typed, and never one a
579
+ * saved email already carries.
580
+ *
581
+ * Seeding rather than falling back at the send is the difference
582
+ * between a merchant SEEING who this will come from and finding out
583
+ * afterwards. It also keeps one answer on the record: the send stores
584
+ * the fields the composer submitted, so what the report says went out
585
+ * is what was on screen when somebody pressed the button.
586
+ *
587
+ * From the sender this email ALREADY names where it names one, and from
588
+ * the site's fields otherwise. An email set to go out as Jamie seeded
589
+ * from the site's default would open with the wrong person's name in
590
+ * front of Jamie's address.
591
+ */ const opened = openedAsSenderId ? rows.find((one)=>one.id === openedAsSenderId) : null;
592
+ const siteFromName = String((_ref2 = opened ? opened.fromName : payload == null ? void 0 : payload.fromName) != null ? _ref2 : '');
593
+ const siteReplyTo = String((_ref3 = opened ? opened.replyTo : payload == null ? void 0 : payload.replyTo) != null ? _ref3 : '');
594
+ if (siteFromName) setFromName((current)=>current || siteFromName);
595
+ if (siteReplyTo) setReplyTo((current)=>current || siteReplyTo);
596
+ })().catch(()=>undefined);
597
+ return ()=>{
598
+ active = false;
599
+ };
600
+ }, [
601
+ sendingApi,
602
+ hostId,
603
+ openedAsSenderId,
604
+ identityReads
605
+ ]);
606
+ /**
607
+ * CHOOSING A SENDER BRINGS THE THREE FIELDS IT CARRIES.
608
+ *
609
+ * Picking one is "send this as Jamie", so the display name and reply address
610
+ * configured beside the mailbox come with it — including when the sender has
611
+ * none, because leaving the previous person's name in front of somebody
612
+ * else's address is the one outcome nobody asked for. Both fields stay
613
+ * editable afterwards: they are per-message facts, and the send records what
614
+ * was submitted rather than re-deriving them.
615
+ */ const applySender = useCallback((next)=>{
616
+ var _chosen_fromName, _chosen_replyTo;
617
+ /*
618
+ * A REAL ROW ALWAYS WINS, and the sentinel is only reached when none
619
+ * matched. `ADD_SENDER_OPTION` is a value this product never mints as a
620
+ * document id, and checking the list first means that even if one ever
621
+ * arrived it would be selected as the sender it is rather than opening
622
+ * a drawer — a picker cannot be made to send as the wrong address by a
623
+ * value that happens to collide with a menu entry.
624
+ */ const chosen = (senders != null ? senders : []).find((one)=>one.id === next);
625
+ if (!chosen) {
626
+ if (next === ADD_SENDER_OPTION) setAddingSender(true);
627
+ return;
628
+ }
629
+ setSenderId(chosen.id);
630
+ setFromName((_chosen_fromName = chosen.fromName) != null ? _chosen_fromName : '');
631
+ setReplyTo((_chosen_replyTo = chosen.replyTo) != null ? _chosen_replyTo : '');
632
+ }, [
633
+ senders
634
+ ]);
635
+ /**
636
+ * The sender this email will actually leave as, and the address with it.
637
+ *
638
+ * Resolved from the list rather than from `senderId` alone, because an email
639
+ * that names no sender leaves on the site's DEFAULT — which is a real row
640
+ * with a real address, and the one the merchant needs to see when they have
641
+ * chosen nothing.
642
+ */ const chosenSender = (_ref16 = (_ref17 = (_find = (senders != null ? senders : []).find((one)=>one.id === senderId)) != null ? _find : (senders != null ? senders : []).find((one)=>one.isDefault)) != null ? _ref17 : (senders != null ? senders : [])[0]) != null ? _ref16 : null;
643
+ /**
644
+ * WHAT THE EMAIL LOOKS LIKE, rendered by the code that will mail it.
645
+ *
646
+ * Re-rendered as the copy changes and resolving no audience at all, which
647
+ * is why it is a second request rather than a field on the count above.
648
+ * Only while the pane is open: it reads the designed template and its
649
+ * products, and a merchant who never opens the preview should not pay for
650
+ * one on every campaign.
651
+ */ const [rendered, setRendered] = useState(null);
652
+ useEffect(()=>{
653
+ if (!showPreview) return setRendered(null);
654
+ let active = true;
655
+ setRendered(null);
656
+ const timer = setTimeout(async ()=>{
657
+ try {
658
+ var _ref, _ref1, _ref2;
659
+ const { response, payload } = await authorizedPost(_extends({
660
+ action: 'renderPreview',
661
+ subject: subject.trim(),
662
+ body: sentBody,
663
+ preheader: preheader.trim()
664
+ }, sentTemplateScreenId ? {
665
+ templateScreenId: sentTemplateScreenId
666
+ } : {}, sentPlainText ? {
667
+ plainText: sentPlainText
668
+ } : {}));
669
+ if (!active) return;
670
+ if (!response.ok) {
671
+ var _ref3;
672
+ return setRendered({
673
+ error: String((_ref3 = payload == null ? void 0 : payload.error) != null ? _ref3 : 'Could not render this email')
674
+ });
675
+ }
676
+ setRendered({
677
+ subject: String((_ref = payload == null ? void 0 : payload.subject) != null ? _ref : ''),
678
+ html: String((_ref1 = payload == null ? void 0 : payload.html) != null ? _ref1 : ''),
679
+ text: String((_ref2 = payload == null ? void 0 : payload.text) != null ? _ref2 : '')
680
+ });
681
+ } catch (error) {
682
+ if (active) setRendered({
683
+ error: describeCallFailure(error, 'Could not render this email')
684
+ });
685
+ }
686
+ }, RENDER_DEBOUNCE_MS);
687
+ return ()=>{
688
+ active = false;
689
+ clearTimeout(timer);
690
+ };
691
+ }, [
692
+ authorizedPost,
693
+ showPreview,
694
+ subject,
695
+ sentBody,
696
+ preheader,
697
+ sentTemplateScreenId,
698
+ sentPlainText
699
+ ]);
700
+ /**
701
+ * The design's OWN plain text, fetched once so an override starts from what
702
+ * it is replacing rather than from an empty box.
703
+ *
704
+ * `messageText` and not `text`: the rendered `text` ends in this preview's
705
+ * opt-out line, and folding that into a stored override would put a second
706
+ * footer in every message the send appends its own to.
707
+ *
708
+ * One request, on an explicit act. The preview effect above is deliberately
709
+ * behind a button because it re-renders as the copy changes; this fires when
710
+ * somebody chooses to write the text part, which happens once.
711
+ */ const [adoptingText, setAdoptingText] = useState(false);
712
+ const adoptGeneratedText = useCallback(async ()=>{
713
+ if (!templateScreenId || adoptingText) return;
714
+ setAdoptingText(true);
715
+ try {
716
+ var _ref, _ref1;
717
+ const { response, payload } = await authorizedPost({
718
+ action: 'renderPreview',
719
+ subject: subject.trim(),
720
+ body: '',
721
+ preheader: preheader.trim(),
722
+ templateScreenId
723
+ });
724
+ if (!response.ok) {
725
+ var _ref2;
726
+ return void enqueueSnackbar((_ref2 = payload == null ? void 0 : payload.error) != null ? _ref2 : 'The design’s text could not be read', {
727
+ variant: 'warning',
728
+ allowDuplicate: true
729
+ });
730
+ }
731
+ setPlainText(String((_ref = payload == null ? void 0 : payload.messageText) != null ? _ref : ''));
732
+ setPlainTextVersionId((_ref1 = selectedTemplate == null ? void 0 : selectedTemplate.versionId) != null ? _ref1 : '');
733
+ } catch (error) {
734
+ enqueueSnackbar(describeCallFailure(error, 'The design’s text could not be read'), {
735
+ variant: 'error',
736
+ allowDuplicate: true
737
+ });
738
+ } finally{
739
+ setAdoptingText(false);
740
+ }
741
+ }, [
742
+ templateScreenId,
743
+ adoptingText,
744
+ authorizedPost,
745
+ subject,
746
+ preheader,
747
+ selectedTemplate,
748
+ enqueueSnackbar
749
+ ]);
750
+ /**
751
+ * Whether the text part still describes the design, in the composer's own
752
+ * words.
753
+ *
754
+ * The same pure function the record's shape is defined by, so the notice on
755
+ * screen and the state stored cannot disagree about what is authored and
756
+ * what has gone stale.
757
+ */ const plainTextState = emailPlainTextState({
758
+ plainText: plainTextSource === 'authored' ? plainText : '',
759
+ plainTextVersionId
760
+ }, selectedTemplate == null ? void 0 : selectedTemplate.versionId);
761
+ const audienceLabel = audience === 'leads' ? 'lead' : audience === 'members' ? 'site member' : audience.startsWith('list:') ? 'list subscriber' : 'contact in the segment';
762
+ /**
763
+ * WHAT THE CONFIRM DIALOG SAYS, and why it says a number.
764
+ *
765
+ * A dialog for an irreversible bulk action has one job: state the size of
766
+ * what is about to happen. "goes to every list subscriber who hasn't
767
+ * unsubscribed" states a RULE, and a merchant cannot tell from a rule
768
+ * whether they are about to mail forty people or four thousand.
769
+ *
770
+ * Three cases, and the third is the one worth being careful about:
771
+ *
772
+ * - the audience fits in one send — say how many;
773
+ * - it does not — say how many of how many, because "every subscriber" is
774
+ * then simply false and the remainder is never mailed by this send;
775
+ * - the count could not be read — say THAT, and do not fall back to a
776
+ * sentence that implies completeness. A confirm dialog that asserts a
777
+ * reach it does not know is worse than one that admits it.
778
+ */ const counts = preview && !('error' in preview) ? preview : null;
779
+ const confirmDescription = useCallback((scheduling, sendAtMs)=>{
780
+ const quoted = `"${subject.trim()}"`;
781
+ const when = scheduling ? ` on ${new Date(sendAtMs).toLocaleString()}` : '';
782
+ if (!counts) {
783
+ const reason = preview && 'error' in preview && preview.error ? ` (${preview.error})` : '';
784
+ return `${quoted} goes to every ${audienceLabel} who hasn't ` + `unsubscribed${when}. The recipient count could not be read${reason}, ` + `so this send's size is unknown.`;
785
+ }
786
+ const short = counts.audienceSize > counts.sendable;
787
+ const reach = short ? `${plural(counts.sendable, `${audienceLabel}`)} of ` + `${counts.audienceSize.toLocaleString()}${counts.audienceTruncated ? ' or more' : ''} in this audience` : plural(counts.sendable, audienceLabel);
788
+ const withheld = [];
789
+ if (counts.consentWithheld) {
790
+ withheld.push(`${counts.consentWithheld.toLocaleString()} withheld — no marketing ` + `consent on record`);
791
+ }
792
+ if (counts.suppressed) {
793
+ withheld.push(`${counts.suppressed.toLocaleString()} unsubscribed or suppressed`);
794
+ }
795
+ /*
796
+ * Named separately from `suppressed`, because the two ask different
797
+ * things of a merchant. An address that unsubscribed is gone; one
798
+ * holding for its own cadence arrives at the next campaign outside its
799
+ * interval, and reading that as an unsubscribe would send somebody
800
+ * looking for a broken audience that is not broken.
801
+ */ if (counts.cadenceHeld) {
802
+ withheld.push(`${counts.cadenceHeld.toLocaleString()} asked for mail less often ` + `than this`);
803
+ }
804
+ if (counts.grandfathered) {
805
+ withheld.push(`${counts.grandfathered.toLocaleString()} reachable only because ` + `consent is not enforced retroactively`);
806
+ }
807
+ return `${quoted} goes to ${reach}${when}.` + (withheld.length ? ` Not counted: ${withheld.join('; ')}.` : '');
808
+ }, [
809
+ counts,
810
+ preview,
811
+ subject,
812
+ audienceLabel
813
+ ]);
814
+ const handleSend = useCallback(async ()=>{
815
+ if (!subject.trim() || !messageReady || busy) return;
816
+ const sendAtMs = sendAt ? new Date(sendAt).getTime() : 0;
817
+ const scheduling = Boolean(sendAtMs);
818
+ if (scheduling && sendAtMs <= Date.now()) {
819
+ return void enqueueSnackbar('Pick a future send time', {
820
+ variant: 'warning',
821
+ persist: false
822
+ });
823
+ }
824
+ const confirmed = await confirm({
825
+ title: scheduling ? 'Schedule this campaign?' : 'Send this campaign?',
826
+ description: confirmDescription(scheduling, sendAtMs),
827
+ confirmationText: scheduling ? 'Schedule' : 'Send'
828
+ }).then(()=>true).catch(()=>false);
829
+ if (!confirmed) return;
830
+ setBusy(true);
831
+ try {
832
+ const { response, payload } = await authorizedPost(_extends({}, scheduling ? {
833
+ action: 'schedule',
834
+ sendAtMs
835
+ } : {}, campaignId ? {
836
+ campaignId
837
+ } : {}, {
838
+ subject: subject.trim(),
839
+ body: sentBody,
840
+ audience: audienceKind
841
+ }, segmentId ? {
842
+ segmentId
843
+ } : {}, listId ? {
844
+ listId
845
+ } : {}, experimentId ? {
846
+ experimentId
847
+ } : {}, sentTemplateScreenId ? {
848
+ templateScreenId: sentTemplateScreenId
849
+ } : {}, sentPlainText ? {
850
+ plainText: sentPlainText
851
+ } : {}, emailCampaignId ? {
852
+ emailCampaignId
853
+ } : {}, displayName ? {
854
+ displayName
855
+ } : {}, topicId ? {
856
+ topicId
857
+ } : {}, senderId ? {
858
+ senderId
859
+ } : {}, {
860
+ fromName: fromName.trim(),
861
+ replyTo: replyTo.trim(),
862
+ preheader: preheader.trim()
863
+ }));
864
+ if (response.status === 501) {
865
+ return void enqueueSnackbar('Campaigns are not configured on this deployment', {
866
+ variant: 'info',
867
+ persist: false
868
+ });
869
+ }
870
+ if (!response.ok) {
871
+ var _ref;
872
+ return void enqueueSnackbar((_ref = payload == null ? void 0 : payload.error) != null ? _ref : 'Send failed', {
873
+ variant: 'warning',
874
+ allowDuplicate: true
875
+ });
876
+ }
877
+ enqueueSnackbar(scheduling ? `Scheduled for ${new Date(sendAtMs).toLocaleString()}` : `Sent to ${payload.sent} of ${payload.recipients} recipients`, {
878
+ variant: 'success',
879
+ persist: false
880
+ });
881
+ /*
882
+ * Cleared only when this composer mints its own sends.
883
+ *
884
+ * On an email's own page the fields ARE that email, and the record now
885
+ * holds exactly what was just sent — blanking them would show the
886
+ * merchant an empty composer for a message that went out a second ago.
887
+ */ if (!campaignId) {
888
+ setSubject('');
889
+ setBody('');
890
+ setSendAt('');
891
+ setPreheader('');
892
+ }
893
+ onSent == null ? void 0 : onSent();
894
+ } catch (error) {
895
+ /*
896
+ * A CLICK THAT DID NOTHING HAS TO SAY SO.
897
+ *
898
+ * This covers the failures that never reach the route at all — chiefly
899
+ * an ID token that cannot be obtained, which is awaited in front of the
900
+ * request. `describeCallFailure` lets that one name itself, because
901
+ * "nothing was sent, you are signed out" and "the send failed" call for
902
+ * different things from the person reading it.
903
+ */ console.error(error);
904
+ enqueueSnackbar(describeCallFailure(error, 'An error has occurred'), {
905
+ variant: 'error',
906
+ allowDuplicate: true
907
+ });
908
+ } finally{
909
+ setBusy(false);
910
+ }
911
+ }, [
912
+ subject,
913
+ sentBody,
914
+ sentPlainText,
915
+ messageReady,
916
+ audienceKind,
917
+ segmentId,
918
+ listId,
919
+ experimentId,
920
+ sentTemplateScreenId,
921
+ emailCampaignId,
922
+ topicId,
923
+ senderId,
924
+ fromName,
925
+ replyTo,
926
+ preheader,
927
+ sendAt,
928
+ busy,
929
+ campaignId,
930
+ displayName,
931
+ authorizedPost,
932
+ confirm,
933
+ confirmDescription,
934
+ enqueueSnackbar,
935
+ onSent
936
+ ]);
937
+ /*==========================================
938
+ * SAVING WITHOUT SENDING.
939
+ *
940
+ * The draft is the working document, and it has to be savable on its own —
941
+ * a composer whose only way out is Send is a composer that makes a merchant
942
+ * choose between mailing something half-written and losing it.
943
+ *
944
+ * Only offered when this composer is editing a record. Without a
945
+ * `campaignId` there is nothing to save INTO, and minting a draft from a
946
+ * surface that did not ask for one would leave unsent records behind every
947
+ * time somebody opened a composer and changed their mind.
948
+ *=========================================*/ const [saving, setSaving] = useState(false);
949
+ const handleSaveDraft = useCallback(/*
950
+ * `overrides.templateScreenId` exists for `handleDesignCreated`: it has
951
+ * just minted a design and set it into state, but state set this tick is
952
+ * not yet in `sentTemplateScreenId`, so the save it fires immediately
953
+ * after would record the record's previous design — or none.
954
+ */ async (overrides)=>{
955
+ var _ref;
956
+ if (!campaignId || saving) return;
957
+ const savedTemplateScreenId = (_ref = overrides == null ? void 0 : overrides.templateScreenId) != null ? _ref : sentTemplateScreenId;
958
+ setSaving(true);
959
+ try {
960
+ const { response, payload } = await authorizedPost(_extends({
961
+ action: 'draft',
962
+ campaignId,
963
+ subject: subject.trim(),
964
+ body: sentBody,
965
+ audience: audienceKind
966
+ }, segmentId ? {
967
+ segmentId
968
+ } : {}, listId ? {
969
+ listId
970
+ } : {}, experimentId ? {
971
+ experimentId
972
+ } : {}, savedTemplateScreenId ? {
973
+ templateScreenId: savedTemplateScreenId
974
+ } : {}, sentPlainText ? _extends({
975
+ plainText: sentPlainText
976
+ }, plainTextVersionId ? {
977
+ plainTextVersionId
978
+ } : {}) : {}, emailCampaignId ? {
979
+ emailCampaignId
980
+ } : {}, displayName ? {
981
+ displayName
982
+ } : {}, topicId ? {
983
+ topicId
984
+ } : {}, senderId ? {
985
+ senderId
986
+ } : {}, {
987
+ fromName: fromName.trim(),
988
+ replyTo: replyTo.trim(),
989
+ preheader: preheader.trim()
990
+ }));
991
+ if (!response.ok) {
992
+ var _ref1;
993
+ return void enqueueSnackbar((_ref1 = payload == null ? void 0 : payload.error) != null ? _ref1 : 'Draft not saved', {
994
+ variant: 'warning',
995
+ allowDuplicate: true
996
+ });
997
+ }
998
+ enqueueSnackbar('Draft saved', {
999
+ variant: 'success',
1000
+ persist: false
1001
+ });
1002
+ } catch (error) {
1003
+ console.error(error);
1004
+ enqueueSnackbar(describeCallFailure(error, 'Draft not saved'), {
1005
+ variant: 'error',
1006
+ allowDuplicate: true
1007
+ });
1008
+ } finally{
1009
+ setSaving(false);
1010
+ }
1011
+ }, [
1012
+ campaignId,
1013
+ saving,
1014
+ authorizedPost,
1015
+ subject,
1016
+ sentBody,
1017
+ sentPlainText,
1018
+ plainTextVersionId,
1019
+ audienceKind,
1020
+ segmentId,
1021
+ listId,
1022
+ experimentId,
1023
+ sentTemplateScreenId,
1024
+ emailCampaignId,
1025
+ displayName,
1026
+ topicId,
1027
+ senderId,
1028
+ fromName,
1029
+ replyTo,
1030
+ preheader,
1031
+ enqueueSnackbar
1032
+ ]);
1033
+ // Kept current every render; see the ref's declaration above.
1034
+ handleSaveDraftRef.current = handleSaveDraft;
1035
+ const quotaLimit = useMemo(()=>checkQuota(org, 'emailSendsPerMonth', campaignSendsUsed).limit, [
1036
+ org,
1037
+ campaignSendsUsed
1038
+ ]);
1039
+ return /*#__PURE__*/ _jsxs(Stack, {
1040
+ spacing: 1.5,
1041
+ children: [
1042
+ /*#__PURE__*/ _jsx(Typography, {
1043
+ variant: "body2",
1044
+ color: "text.secondary",
1045
+ children: 'Send an update to your leads or site members. Every email ' + 'carries an unsubscribe link; monthly sends are capped by ' + 'your plan.'
1046
+ }),
1047
+ /*#__PURE__*/ _jsxs(Stack, {
1048
+ direction: "row",
1049
+ spacing: 1,
1050
+ children: [
1051
+ /*#__PURE__*/ _jsx(TextField, {
1052
+ label: "Subject",
1053
+ value: subject,
1054
+ onChange: (event)=>setSubject(event.target.value),
1055
+ size: "small",
1056
+ sx: {
1057
+ flex: 1
1058
+ }
1059
+ }),
1060
+ /*#__PURE__*/ _jsxs(TextField, {
1061
+ select: true,
1062
+ label: "Audience",
1063
+ value: audience,
1064
+ onChange: (event)=>setAudience(event.target.value),
1065
+ size: "small",
1066
+ sx: {
1067
+ minWidth: 150
1068
+ },
1069
+ children: [
1070
+ /*#__PURE__*/ _jsx(MenuItem, {
1071
+ value: "leads",
1072
+ children: 'Leads'
1073
+ }),
1074
+ /*#__PURE__*/ _jsx(MenuItem, {
1075
+ value: "members",
1076
+ children: 'Site members'
1077
+ }),
1078
+ segments.map((segment)=>/*#__PURE__*/ _jsx(MenuItem, {
1079
+ value: `segment:${segment.$id}`,
1080
+ children: `Segment: ${segment.name}`
1081
+ }, segment.$id)),
1082
+ lists.map((list)=>/*#__PURE__*/ _jsx(MenuItem, {
1083
+ value: `list:${list.$id}`,
1084
+ children: `List: ${list.name}`
1085
+ }, list.$id))
1086
+ ]
1087
+ })
1088
+ ]
1089
+ }),
1090
+ /*#__PURE__*/ _jsx(Typography, {
1091
+ variant: "caption",
1092
+ color: "text.secondary",
1093
+ children: preview === null ? 'Counting recipients…' : 'error' in preview ? preview.error || 'Could not count this audience' : `Recipients ${preview.sendable.toLocaleString()}` + (preview.audienceSize > preview.sendable ? ` of ${preview.audienceSize.toLocaleString()}${preview.audienceTruncated ? '+' : ''} in this audience` : '') + (preview.suppressed ? ` · ${preview.suppressed.toLocaleString()} unsubscribed or suppressed` : '') + (preview.cadenceHeld ? ` · ${preview.cadenceHeld.toLocaleString()} asked for mail less often than this` : '')
1094
+ }),
1095
+ counts && /*#__PURE__*/ _jsxs(Typography, {
1096
+ variant: "caption",
1097
+ color: "text.secondary",
1098
+ children: [
1099
+ `In this audience: ${counts.consented.toLocaleString()} with a recorded consent basis`,
1100
+ counts.grandfathered ? ` · ${counts.grandfathered.toLocaleString()} grandfathered (captured before consent was required)` : '',
1101
+ counts.consentWithheld ? ` · ${counts.consentWithheld.toLocaleString()} withheld — no consent on record, never mailed` : ''
1102
+ ]
1103
+ }),
1104
+ /*#__PURE__*/ _jsx(QuotaReadoutComponent, {
1105
+ ready: orgReady,
1106
+ used: campaignSendsUsed,
1107
+ limit: quotaLimit,
1108
+ noun: "campaign email",
1109
+ nounPlural: "campaign emails",
1110
+ period: "this month"
1111
+ }),
1112
+ /*#__PURE__*/ _jsx(Divider, {}),
1113
+ /*#__PURE__*/ _jsx(CampaignTopicSelect, {
1114
+ hostId: hostId,
1115
+ value: topicId,
1116
+ onChange: setTopicId,
1117
+ disabled: busy
1118
+ }),
1119
+ ((_ref18 = identityOptions == null ? void 0 : identityOptions.filter((one)=>one.selectable).length) != null ? _ref18 : 0) > 1 ? /*#__PURE__*/ _jsx(TextField, {
1120
+ select: true,
1121
+ label: "From address",
1122
+ value: sendingIdentity || siteDefaultIdentity,
1123
+ onChange: (event)=>setSendingIdentity(event.target.value === siteDefaultIdentity ? '' : event.target.value),
1124
+ size: "small",
1125
+ helperText: "Which verified address this email leaves on",
1126
+ children: (identityOptions != null ? identityOptions : []).filter((one)=>one.selectable).map((one)=>{
1127
+ var _one_from;
1128
+ return /*#__PURE__*/ _jsx(MenuItem, {
1129
+ value: one.value,
1130
+ children: (_one_from = one.from) != null ? _one_from : one.value
1131
+ }, one.value);
1132
+ })
1133
+ }) : null,
1134
+ ((_ref19 = senders == null ? void 0 : senders.length) != null ? _ref19 : 0) > 1 ? /*#__PURE__*/ _jsxs(TextField, {
1135
+ select: true,
1136
+ label: "From",
1137
+ value: (_ref20 = chosenSender == null ? void 0 : chosenSender.id) != null ? _ref20 : '',
1138
+ onChange: (event)=>applySender(event.target.value),
1139
+ size: "small",
1140
+ helperText: "Which of this site’s senders this email goes out as",
1141
+ children: [
1142
+ (senders != null ? senders : []).map((sender)=>{
1143
+ var _sender_from;
1144
+ return /*#__PURE__*/ _jsx(MenuItem, {
1145
+ value: sender.id,
1146
+ children: /*#__PURE__*/ _jsxs(Stack, {
1147
+ spacing: 0,
1148
+ children: [
1149
+ /*#__PURE__*/ _jsx(Typography, {
1150
+ variant: "body2",
1151
+ children: (_sender_from = sender.from) != null ? _sender_from : `${sender.localPart}@`
1152
+ }),
1153
+ sender.fromName || sender.isDefault ? /*#__PURE__*/ _jsx(Typography, {
1154
+ variant: "caption",
1155
+ color: "text.secondary",
1156
+ children: [
1157
+ sender.fromName,
1158
+ sender.isDefault ? 'this site’s default' : ''
1159
+ ].filter(Boolean).join(' · ')
1160
+ }) : null
1161
+ ]
1162
+ })
1163
+ }, sender.id);
1164
+ }),
1165
+ (identityView == null ? void 0 : identityView.canManage) && (identityView == null ? void 0 : identityView.selected) ? /*#__PURE__*/ _jsx(MenuItem, {
1166
+ value: ADD_SENDER_OPTION,
1167
+ children: /*#__PURE__*/ _jsx(Typography, {
1168
+ variant: "body2",
1169
+ color: "primary",
1170
+ children: 'Add a sender…'
1171
+ })
1172
+ }) : null
1173
+ ]
1174
+ }) : null,
1175
+ (chosenSender == null ? void 0 : chosenSender.from) ? /*#__PURE__*/ _jsxs(Typography, {
1176
+ variant: "body2",
1177
+ color: "text.secondary",
1178
+ children: [
1179
+ 'This email goes out as ',
1180
+ /*#__PURE__*/ _jsx(Typography, {
1181
+ component: "span",
1182
+ variant: "body2",
1183
+ sx: {
1184
+ fontWeight: 'bold'
1185
+ },
1186
+ children: fromName.trim() ? `${fromName.trim()} <${chosenSender.from}>` : chosenSender.from
1187
+ }),
1188
+ replyTo.trim() ? `, with replies to ${replyTo.trim()}` : '',
1189
+ '.'
1190
+ ]
1191
+ }) : null,
1192
+ /*#__PURE__*/ _jsxs(Stack, {
1193
+ direction: "row",
1194
+ spacing: 1,
1195
+ children: [
1196
+ /*#__PURE__*/ _jsx(TextField, {
1197
+ label: "From name",
1198
+ value: fromName,
1199
+ onChange: (event)=>setFromName(event.target.value),
1200
+ size: "small",
1201
+ sx: {
1202
+ flex: 1
1203
+ },
1204
+ /*
1205
+ * THE HELPER SAYS WHAT THIS FIELD IS OVERRIDING.
1206
+ *
1207
+ * The composer seeds both fields from the chosen sender, so the
1208
+ * value sitting here on open is the SENDER'S — and a field that
1209
+ * silently matches a setting somewhere else reads as a second
1210
+ * definition of it. Saying which state it is in is what makes
1211
+ * editing here read as "for this email" rather than as changing the
1212
+ * site, which is the confusion two screens carrying the same two
1213
+ * fields produce on their own.
1214
+ */ helperText: senderFieldHelp('name', (_ref21 = chosenSender == null ? void 0 : chosenSender.fromName) != null ? _ref21 : '', fromName)
1215
+ }),
1216
+ /*#__PURE__*/ _jsx(TextField, {
1217
+ label: "Reply-to",
1218
+ value: replyTo,
1219
+ onChange: (event)=>setReplyTo(event.target.value),
1220
+ size: "small",
1221
+ type: "email",
1222
+ sx: {
1223
+ flex: 1
1224
+ },
1225
+ /*
1226
+ * The one field here that may name a mailbox on a domain nobody has
1227
+ * verified, and the helper says so — because the alternative is a
1228
+ * merchant reaching for the From fields to be reachable at their
1229
+ * own address. A `From:` on somebody else's mail provider cannot
1230
+ * align under the strict DMARC policy the sending apex publishes,
1231
+ * so it would be refused by the receiving side rather than
1232
+ * delivered; a reply address carries no such requirement.
1233
+ */ helperText: senderFieldHelp('reply address', (_ref22 = chosenSender == null ? void 0 : chosenSender.replyTo) != null ? _ref22 : '', replyTo)
1234
+ })
1235
+ ]
1236
+ }),
1237
+ /*#__PURE__*/ _jsx(TextField, {
1238
+ label: "Preheader",
1239
+ value: preheader,
1240
+ onChange: (event)=>setPreheader(event.target.value),
1241
+ size: "small",
1242
+ helperText: "The preview line inboxes show after the subject"
1243
+ }),
1244
+ emailExperiments.length ? // Email A/B: variant subject/body overrides apply per recipient; a
1245
+ // decided experiment sends the winner copy.
1246
+ /*#__PURE__*/ _jsxs(TextField, {
1247
+ select: true,
1248
+ label: "A/B test",
1249
+ value: experimentId,
1250
+ onChange: (event)=>setExperimentId(event.target.value),
1251
+ size: "small",
1252
+ sx: {
1253
+ minWidth: 150
1254
+ },
1255
+ children: [
1256
+ /*#__PURE__*/ _jsx(MenuItem, {
1257
+ value: "",
1258
+ children: 'None'
1259
+ }),
1260
+ emailExperiments.map((experiment)=>{
1261
+ var _experiment_name;
1262
+ return /*#__PURE__*/ _jsxs(MenuItem, {
1263
+ value: experiment.$id,
1264
+ children: [
1265
+ (_experiment_name = experiment.name) != null ? _experiment_name : experiment.$id,
1266
+ experiment.winnerVariantId ? ' (winner decided)' : ''
1267
+ ]
1268
+ }, experiment.$id);
1269
+ })
1270
+ ]
1271
+ }) : null,
1272
+ /*#__PURE__*/ _jsx(Divider, {}),
1273
+ /*#__PURE__*/ _jsxs(TextField, {
1274
+ select: true,
1275
+ label: "How this email is written",
1276
+ value: mode,
1277
+ onChange: (event)=>setMode(event.target.value),
1278
+ size: "small",
1279
+ sx: {
1280
+ maxWidth: 360
1281
+ },
1282
+ helperText: designed ? 'The design is the message. Its plain-text half is generated ' + 'from it below, and can be written by hand instead.' : 'What you type is the message. Links in it are made clickable ' + 'for readers whose mail shows styling.',
1283
+ children: [
1284
+ /*#__PURE__*/ _jsx(MenuItem, {
1285
+ value: "design",
1286
+ children: 'Designed in the besigner'
1287
+ }),
1288
+ /*#__PURE__*/ _jsx(MenuItem, {
1289
+ value: "text",
1290
+ children: 'Written here as plain text'
1291
+ })
1292
+ ]
1293
+ }),
1294
+ designed ? /*#__PURE__*/ _jsxs(Stack, {
1295
+ direction: "row",
1296
+ spacing: 1,
1297
+ sx: {
1298
+ alignItems: 'center'
1299
+ },
1300
+ children: [
1301
+ /*#__PURE__*/ _jsxs(TextField, {
1302
+ select: true,
1303
+ label: "Email design",
1304
+ value: templateScreenId,
1305
+ onChange: (event)=>setTemplateScreenId(event.target.value),
1306
+ size: "small",
1307
+ sx: {
1308
+ minWidth: 220
1309
+ },
1310
+ helperText: "This email's own design, or a saved template to reuse",
1311
+ children: [
1312
+ /*#__PURE__*/ _jsx(MenuItem, {
1313
+ value: "",
1314
+ children: 'Choose a design…'
1315
+ }),
1316
+ emailScreens.map((screen)=>{
1317
+ var _screen_displayName;
1318
+ return /*#__PURE__*/ _jsx(MenuItem, {
1319
+ value: screen.$id,
1320
+ children: (_screen_displayName = screen.displayName) != null ? _screen_displayName : screen.$id
1321
+ }, screen.$id);
1322
+ })
1323
+ ]
1324
+ }),
1325
+ selectedTemplate ? /*#__PURE__*/ _jsx(Button, {
1326
+ size: "small",
1327
+ disabled: !orgSlug || !subdomain,
1328
+ onClick: ()=>void router.push(besignerHref(orgSlug, subdomain, selectedTemplate.$id, selectedTemplate.versionId)),
1329
+ children: 'Edit design'
1330
+ }) : null,
1331
+ /*#__PURE__*/ _jsx(CampaignDesignCreate, {
1332
+ hostId: hostId,
1333
+ name: (displayName == null ? void 0 : displayName.trim()) || subject.trim() || undefined,
1334
+ onCreated: (design)=>void handleDesignCreated(design),
1335
+ onError: handleDesignFailed
1336
+ })
1337
+ ]
1338
+ }) : null,
1339
+ designed && templateScreenId ? /*#__PURE__*/ _jsxs(Stack, {
1340
+ spacing: 1,
1341
+ children: [
1342
+ /*#__PURE__*/ _jsxs(TextField, {
1343
+ select: true,
1344
+ label: "Plain-text version",
1345
+ value: plainTextSource,
1346
+ onChange: (event)=>{
1347
+ const next = event.target.value;
1348
+ setPlainTextSource(next);
1349
+ // Start from what it replaces. An empty box would make an
1350
+ // override mean "write this email a second time".
1351
+ if (next === 'authored' && !plainText.trim()) {
1352
+ void adoptGeneratedText();
1353
+ }
1354
+ },
1355
+ size: "small",
1356
+ sx: {
1357
+ maxWidth: 360
1358
+ },
1359
+ helperText: plainTextSource === 'authored' ? 'What readers whose mail shows no styling receive.' : 'Taken from the design, and kept in step with it.',
1360
+ children: [
1361
+ /*#__PURE__*/ _jsx(MenuItem, {
1362
+ value: "generated",
1363
+ children: 'Generated from the design'
1364
+ }),
1365
+ /*#__PURE__*/ _jsx(MenuItem, {
1366
+ value: "authored",
1367
+ children: 'Written here'
1368
+ })
1369
+ ]
1370
+ }),
1371
+ plainTextState.stale ? /*#__PURE__*/ _jsx(Alert, {
1372
+ severity: "warning",
1373
+ action: /*#__PURE__*/ _jsx(Button, {
1374
+ size: "small",
1375
+ color: "inherit",
1376
+ disabled: adoptingText,
1377
+ onClick: ()=>void adoptGeneratedText(),
1378
+ children: adoptingText ? 'Reading…' : 'Take the design’s text'
1379
+ }),
1380
+ children: 'The design has been edited since this plain-text version was ' + 'written, so the two may no longer say the same thing. It is ' + 'left exactly as you wrote it — nothing has overwritten it — ' + 'and this is here because a text half that has fallen behind ' + 'is otherwise impossible to see.'
1381
+ }) : null,
1382
+ plainTextSource === 'authored' ? /*#__PURE__*/ _jsx(TextField, {
1383
+ label: "The plain-text message",
1384
+ value: plainText,
1385
+ onChange: (event)=>{
1386
+ var _ref;
1387
+ setPlainText(event.target.value);
1388
+ /*
1389
+ * Stamped on every edit: this text now describes the design as
1390
+ * it stands, which is what makes the notice above go away for
1391
+ * the right reason rather than because somebody dismissed it.
1392
+ */ setPlainTextVersionId((_ref = selectedTemplate == null ? void 0 : selectedTemplate.versionId) != null ? _ref : '');
1393
+ },
1394
+ size: "small",
1395
+ multiline: true,
1396
+ minRows: 4,
1397
+ helperText: adoptingText ? 'Reading the design’s own text…' : 'Merge tags resolve here too, and the unsubscribe link is ' + 'added as a plain address at the end.'
1398
+ }) : null,
1399
+ body.trim() && plainTextSource === 'generated' ? /*#__PURE__*/ _jsx(Alert, {
1400
+ severity: "info",
1401
+ action: /*#__PURE__*/ _jsx(Button, {
1402
+ size: "small",
1403
+ color: "inherit",
1404
+ onClick: ()=>{
1405
+ var _ref;
1406
+ setPlainTextSource('authored');
1407
+ setPlainText(body);
1408
+ setPlainTextVersionId((_ref = selectedTemplate == null ? void 0 : selectedTemplate.versionId) != null ? _ref : '');
1409
+ },
1410
+ children: 'Use it as the plain-text version'
1411
+ }),
1412
+ children: 'This email also carries a typed message. A designed email is ' + 'not sent from one, so it reaches nobody as it stands, and ' + 'saving or sending this email lets it go.'
1413
+ }) : null
1414
+ ]
1415
+ }) : null,
1416
+ designed ? null : /*#__PURE__*/ _jsx(TextField, {
1417
+ label: "Message",
1418
+ value: body,
1419
+ onChange: (event)=>setBody(event.target.value),
1420
+ size: "small",
1421
+ multiline: true,
1422
+ minRows: 4
1423
+ }),
1424
+ designed ? null : /*#__PURE__*/ _jsxs(Stack, {
1425
+ direction: "row",
1426
+ spacing: 1,
1427
+ sx: {
1428
+ alignItems: 'center',
1429
+ flexWrap: 'wrap'
1430
+ },
1431
+ children: [
1432
+ /*#__PURE__*/ _jsx(Typography, {
1433
+ variant: "caption",
1434
+ color: "text.secondary",
1435
+ children: 'Personalize:'
1436
+ }),
1437
+ EMAIL_MERGE_TAGS.map((tag)=>/*#__PURE__*/ _jsx(Chip, {
1438
+ size: "small",
1439
+ variant: "outlined",
1440
+ label: tag.token,
1441
+ title: tag.description,
1442
+ onClick: ()=>setBody((current)=>current ? `${current}${tag.token}` : tag.token)
1443
+ }, tag.token)),
1444
+ /*#__PURE__*/ _jsx(Typography, {
1445
+ variant: "caption",
1446
+ color: "text.secondary",
1447
+ children: '— resolved per recipient at send time'
1448
+ })
1449
+ ]
1450
+ }),
1451
+ /*#__PURE__*/ _jsxs(Stack, {
1452
+ direction: "row",
1453
+ spacing: 1,
1454
+ sx: {
1455
+ alignItems: 'center'
1456
+ },
1457
+ children: [
1458
+ /*#__PURE__*/ _jsx(Button, {
1459
+ size: "small",
1460
+ onClick: ()=>setShowPreview((open)=>!open),
1461
+ children: showPreview ? 'Hide preview' : 'Preview email'
1462
+ }),
1463
+ /*#__PURE__*/ _jsx(Typography, {
1464
+ variant: "caption",
1465
+ color: "text.secondary",
1466
+ children: 'Rendered by the send path, personalized for your own account'
1467
+ })
1468
+ ]
1469
+ }),
1470
+ showPreview ? rendered === null ? /*#__PURE__*/ _jsx(Typography, {
1471
+ variant: "caption",
1472
+ color: "text.secondary",
1473
+ children: 'Rendering…'
1474
+ }) : 'error' in rendered ? /*#__PURE__*/ _jsx(Alert, {
1475
+ severity: "warning",
1476
+ children: rendered.error
1477
+ }) : /*#__PURE__*/ _jsxs(Stack, {
1478
+ spacing: 0.5,
1479
+ children: [
1480
+ /*#__PURE__*/ _jsx(Typography, {
1481
+ variant: "caption",
1482
+ color: "text.secondary",
1483
+ children: `Subject: ${rendered.subject || '(none)'}`
1484
+ }),
1485
+ /*#__PURE__*/ _jsx(Box, {
1486
+ component: "iframe",
1487
+ title: "Email preview",
1488
+ sandbox: "",
1489
+ srcDoc: rendered.html,
1490
+ sx: {
1491
+ width: '100%',
1492
+ height: 420,
1493
+ border: 1,
1494
+ borderColor: 'divider',
1495
+ borderRadius: 1,
1496
+ backgroundColor: 'background.paper'
1497
+ }
1498
+ }),
1499
+ /*#__PURE__*/ _jsx(Typography, {
1500
+ variant: "overline",
1501
+ color: "text.secondary",
1502
+ children: 'Plain-text version, as it will send'
1503
+ }),
1504
+ /*#__PURE__*/ _jsx(Box, {
1505
+ sx: {
1506
+ border: 1,
1507
+ borderColor: 'divider',
1508
+ borderRadius: 1,
1509
+ p: 1.5,
1510
+ maxHeight: 240,
1511
+ overflow: 'auto',
1512
+ backgroundColor: 'background.paper',
1513
+ fontFamily: 'monospace',
1514
+ fontSize: '0.8rem',
1515
+ whiteSpace: 'pre-wrap',
1516
+ wordBreak: 'break-word'
1517
+ },
1518
+ children: rendered.text || '(empty)'
1519
+ })
1520
+ ]
1521
+ }) : null,
1522
+ preview && 'error' in preview && preview.blocking ? /*#__PURE__*/ _jsxs(Alert, {
1523
+ severity: "warning",
1524
+ children: [
1525
+ /*#__PURE__*/ _jsx(Typography, {
1526
+ variant: "body2",
1527
+ sx: {
1528
+ fontWeight: 'bold'
1529
+ },
1530
+ children: 'This email cannot be sent yet'
1531
+ }),
1532
+ /*#__PURE__*/ _jsx(Typography, {
1533
+ variant: "body2",
1534
+ children: preview.error
1535
+ }),
1536
+ /*#__PURE__*/ _jsx(Typography, {
1537
+ variant: "body2",
1538
+ sx: {
1539
+ mt: 0.5
1540
+ },
1541
+ children: 'Nothing has been sent, and nothing about this draft is lost. ' + 'Emails → Sending is where this is settled — it says what this ' + 'site sends as and what it would take to change it.'
1542
+ })
1543
+ ]
1544
+ }) : preview && !('error' in preview) && preview.identity ? /*#__PURE__*/ _jsx(Typography, {
1545
+ variant: "caption",
1546
+ color: "text.secondary",
1547
+ children: preview.identity
1548
+ }) : null,
1549
+ /*#__PURE__*/ _jsxs(Stack, {
1550
+ direction: "row",
1551
+ spacing: 1,
1552
+ sx: {
1553
+ alignItems: 'center'
1554
+ },
1555
+ children: [
1556
+ /*#__PURE__*/ _jsx(Button, {
1557
+ variant: "contained",
1558
+ color: "primary",
1559
+ disabled: busy || !subject.trim() || !messageReady || /*
1560
+ * REFUSED BEFORE THE CLICK, not after it.
1561
+ *
1562
+ * The send would answer 409 anyway — that boundary holds whatever
1563
+ * this component does. What a disabled button buys is that the
1564
+ * merchant reads the reason instead of pressing Send and being
1565
+ * told; the two are the same information, and only one of them
1566
+ * arrives while there is still something to do about it.
1567
+ */ Boolean(preview && 'error' in preview && preview.blocking),
1568
+ onClick: handleSend,
1569
+ children: busy ? 'Working…' : sendAt ? 'Schedule campaign' : 'Send campaign'
1570
+ }),
1571
+ campaignId ? /*#__PURE__*/ _jsx(Button, {
1572
+ size: "small",
1573
+ variant: "outlined",
1574
+ disabled: saving || busy,
1575
+ onClick: ()=>void handleSaveDraft(),
1576
+ children: saving ? 'Saving…' : 'Save draft'
1577
+ }) : null,
1578
+ /*#__PURE__*/ _jsx(Button, {
1579
+ size: "small",
1580
+ disabled: busy || !messageReady,
1581
+ onClick: ()=>setTestOpen(true),
1582
+ children: 'Send test'
1583
+ }),
1584
+ /*#__PURE__*/ _jsx(TextField, {
1585
+ size: "small",
1586
+ type: "datetime-local",
1587
+ label: "Send at (optional)",
1588
+ slotProps: {
1589
+ inputLabel: {
1590
+ shrink: true
1591
+ }
1592
+ },
1593
+ value: sendAt,
1594
+ onChange: (event)=>setSendAt(event.target.value)
1595
+ })
1596
+ ]
1597
+ }),
1598
+ addingSender ? /*#__PURE__*/ _jsx(CampaignSenderEditor, {
1599
+ hostId: hostId,
1600
+ view: identityView,
1601
+ onClose: ()=>setAddingSender(false),
1602
+ onSaved: (created)=>{
1603
+ /*
1604
+ * The new row is selected by the read, not here: it does not exist
1605
+ * in `senders` yet, because the list this picker draws from was
1606
+ * fetched before the drawer wrote anything.
1607
+ */ if (created) pendingSenderRef.current = created;
1608
+ setIdentityReads((count)=>count + 1);
1609
+ }
1610
+ }) : null,
1611
+ /*#__PURE__*/ _jsx(CampaignTestSendDrawer, {
1612
+ open: testOpen,
1613
+ onClose: ()=>setTestOpen(false),
1614
+ post: authorizedPost,
1615
+ identity: preview && !('error' in preview) ? preview.identity : '',
1616
+ message: _extends({
1617
+ subject: subject.trim() || 'Test send',
1618
+ /*
1619
+ * The mode's own field, exactly as the send carries it. A proof
1620
+ * assembled from both halves would mail something the campaign
1621
+ * cannot produce, which is the one thing a proof must never do.
1622
+ */ body: sentBody,
1623
+ fromName: fromName.trim(),
1624
+ replyTo: replyTo.trim()
1625
+ }, senderId ? {
1626
+ senderId
1627
+ } : {}, {
1628
+ preheader: preheader.trim()
1629
+ }, sentTemplateScreenId ? {
1630
+ templateScreenId: sentTemplateScreenId
1631
+ } : {}, sentPlainText ? {
1632
+ plainText: sentPlainText
1633
+ } : {}, sendingIdentity ? {
1634
+ sendingIdentity
1635
+ } : {})
1636
+ })
1637
+ ]
1638
+ });
1639
+ }
1640
+ CampaignComposer.displayName = 'CampaignComposer';
1641
+ export default CampaignComposer;
1642
+
1643
+ //# sourceMappingURL=campaign-composer.js.map