@aglyn/plugins-outreach 1.0.0-beta.219 → 1.0.0-beta.221

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 (59) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/compliance-section.d.ts +5 -0
  3. package/src/lib/components/compliance-section.js +233 -149
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enrollment-detail.js +2 -2
  6. package/src/lib/components/enrollment-detail.js.map +1 -1
  7. package/src/lib/components/sequence-editor.d.ts +13 -0
  8. package/src/lib/components/sequence-editor.js +508 -302
  9. package/src/lib/components/sequence-editor.js.map +1 -1
  10. package/src/lib/components/sequence-preview.js +25 -10
  11. package/src/lib/components/sequence-preview.js.map +1 -1
  12. package/src/lib/components/sequence-step-card.d.ts +10 -0
  13. package/src/lib/components/sequence-step-card.js +320 -202
  14. package/src/lib/components/sequence-step-card.js.map +1 -1
  15. package/src/lib/engine/compose.js +2 -2
  16. package/src/lib/engine/compose.js.map +1 -1
  17. package/src/lib/engine/delivery-status.js +1 -1
  18. package/src/lib/engine/delivery-status.js.map +1 -1
  19. package/src/lib/engine/enrollment-state.js +2 -2
  20. package/src/lib/engine/enrollment-state.js.map +1 -1
  21. package/src/lib/engine/gates.d.ts +2 -2
  22. package/src/lib/engine/gates.js.map +1 -1
  23. package/src/lib/engine/opt-out-intent.js +1 -1
  24. package/src/lib/engine/opt-out-intent.js.map +1 -1
  25. package/src/lib/engine/sequence-validation.js +2 -2
  26. package/src/lib/engine/sequence-validation.js.map +1 -1
  27. package/src/lib/engine/thread-classification.js +3 -3
  28. package/src/lib/engine/thread-classification.js.map +1 -1
  29. package/src/lib/enrollment/gate-lookups.js +5 -4
  30. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  31. package/src/lib/model/outreach.types.d.ts +1 -1
  32. package/src/lib/model/outreach.types.js.map +1 -1
  33. package/src/lib/model/sales-topic.d.ts +27 -0
  34. package/src/lib/model/sales-topic.js +27 -0
  35. package/src/lib/model/sales-topic.js.map +1 -0
  36. package/src/lib/model/sequence-draft.d.ts +1 -1
  37. package/src/lib/model/sequence-draft.js +2 -2
  38. package/src/lib/model/sequence-draft.js.map +1 -1
  39. package/src/lib/model/stored-records.js +2 -2
  40. package/src/lib/model/stored-records.js.map +1 -1
  41. package/src/lib/routes/curate-routes.js +7 -7
  42. package/src/lib/routes/curate-routes.js.map +1 -1
  43. package/src/lib/routes/enroll-routes.js +10 -13
  44. package/src/lib/routes/enroll-routes.js.map +1 -1
  45. package/src/lib/routes/sequence-routes.js +9 -9
  46. package/src/lib/routes/sequence-routes.js.map +1 -1
  47. package/src/lib/runtime/campaign-credit.js +2 -2
  48. package/src/lib/runtime/campaign-credit.js.map +1 -1
  49. package/src/lib/runtime/campaign-detach.js +3 -2
  50. package/src/lib/runtime/campaign-detach.js.map +1 -1
  51. package/src/lib/runtime/lead-records.d.ts +6 -1
  52. package/src/lib/runtime/lead-records.js +25 -6
  53. package/src/lib/runtime/lead-records.js.map +1 -1
  54. package/src/lib/runtime/platform-runtime-deps.js +3 -3
  55. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +20 -4
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/runtime/sync-job.js +3 -3
  59. package/src/lib/runtime/sync-job.js.map +1 -1
@@ -21,11 +21,11 @@ import { pluginDocsHelp } from "@aglyn/aglyn";
21
21
  import { consentGroupForHost } from "@aglyn/aglyn/app-utils/consent-groups";
22
22
  import { mdiPlus } from "@aglyn/shared-data-mdi";
23
23
  import { CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
24
- import CampaignPicker from "@aglyn/shared-ui-email-campaigns/components/campaign-picker.component";
24
+ import ContainerPicker from "@aglyn/tenant-feature-instance/components/container-picker";
25
25
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
26
- import { useOrgCampaigns, useUser } from "@aglyn/tenant-feature-instance";
26
+ import { useOrgContainerOptions, useUser } from "@aglyn/tenant-feature-instance";
27
27
  import { Alert, Autocomplete, Box, Button, Chip, FormControlLabel, Grid, Menu, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
28
- import { memo, useCallback, useEffect, useMemo, useState } from "react";
28
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
29
29
  import { isInThreadEmailStep, OUTREACH_SEQUENCE_NAME_MAX, validateOutreachSequence } from "../engine/sequence-validation.js";
30
30
  import { outreachCountryLabel } from "../model/compliance-settings.js";
31
31
  import { OUTREACH_MAX_EMAIL_STEPS, OUTREACH_MAX_STEPS, OUTREACH_TASK_KIND_LABELS, OUTREACH_TASK_KINDS } from "../model/outreach.types.js";
@@ -91,6 +91,362 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
91
91
  }
92
92
  });
93
93
  });
94
+ /**
95
+ * Each step's issues, the SAME record for a step while what it holds reads
96
+ * the same (AGL-3423), so a step a keystroke did not touch keeps its props.
97
+ */ function useStepIssues(issues, count) {
98
+ const held = useRef([]);
99
+ return useMemo(()=>{
100
+ const found = Array.from({
101
+ length: count
102
+ }, ()=>({}));
103
+ for (const issue of issues){
104
+ const key = stepIssueKey(issue.path);
105
+ const record = key ? found[key.index] : undefined;
106
+ if (key && record && !record[key.field]) record[key.field] = issue.message;
107
+ }
108
+ held.current = found.map((value, index)=>{
109
+ const key = JSON.stringify(value);
110
+ const before = held.current[index];
111
+ return (before == null ? void 0 : before.key) === key ? before : {
112
+ key,
113
+ value
114
+ };
115
+ });
116
+ return held.current.map((entry)=>entry.value);
117
+ }, [
118
+ issues,
119
+ count
120
+ ]);
121
+ }
122
+ /** A text field that redraws only when what it shows changes (AGL-3423). */ const MemoTextField = /*#__PURE__*/ memo(TextField);
123
+ /*
124
+ * The sections below are drawn from the one draft, and each redraws only
125
+ * when what it shows changes (AGL-3423).
126
+ */ const MemoMailboxPicker = /*#__PURE__*/ memo(OutreachSequenceMailboxPicker);
127
+ const MemoContainerPicker = /*#__PURE__*/ memo(ContainerPicker);
128
+ const MemoSendWindowFields = /*#__PURE__*/ memo(OutreachSendWindowFields);
129
+ const MemoSequencePreview = /*#__PURE__*/ memo(OutreachSequencePreview);
130
+ const NAME_SLOT_PROPS = {
131
+ htmlInput: {
132
+ maxLength: OUTREACH_SEQUENCE_NAME_MAX + 20
133
+ }
134
+ };
135
+ const ADD_STEP_ROW = {
136
+ flexWrap: 'wrap',
137
+ rowGap: 1
138
+ };
139
+ const PREVIEW_COLUMN = {
140
+ position: {
141
+ md: 'sticky'
142
+ },
143
+ top: {
144
+ md: 16
145
+ }
146
+ };
147
+ const SequenceSiteField = /*#__PURE__*/ memo(function SequenceSiteField(props) {
148
+ const { hosts, value, issue } = props;
149
+ return /*#__PURE__*/ _jsx(TextField, {
150
+ select: true,
151
+ label: "Site",
152
+ value: hosts.some((host)=>host.id === value) ? value : '',
153
+ onChange: props.onChange,
154
+ disabled: props.disabled || !hosts.length,
155
+ error: Boolean(issue),
156
+ helperText: issue != null ? issue : 'The site whose contacts this sequence emails.',
157
+ fullWidth: true,
158
+ children: hosts.map((host)=>/*#__PURE__*/ _jsx(MenuItem, {
159
+ value: host.id,
160
+ children: host.name
161
+ }, host.id))
162
+ });
163
+ });
164
+ /** The sequence card's header actions: Cancel, and the save of the whole sequence. */ const SequenceSaveActions = /*#__PURE__*/ memo(function SequenceSaveActions(props) {
165
+ return /*#__PURE__*/ _jsxs(Stack, {
166
+ direction: "row",
167
+ spacing: 1,
168
+ children: [
169
+ props.onCancel ? /*#__PURE__*/ _jsx(Button, {
170
+ size: "small",
171
+ variant: "text",
172
+ onClick: props.onCancel,
173
+ disabled: props.saving,
174
+ children: "Cancel"
175
+ }) : null,
176
+ /*#__PURE__*/ _jsx(Button, {
177
+ size: "small",
178
+ variant: "contained",
179
+ onClick: props.onSave,
180
+ disabled: props.saving,
181
+ children: props.saving ? 'Saving…' : props.saveLabel
182
+ })
183
+ ]
184
+ });
185
+ });
186
+ /** The sequence's own card: its name, site, mailbox and campaigns. */ const SequenceCard = /*#__PURE__*/ memo(function SequenceCard(props) {
187
+ var _props_campaignsIssue;
188
+ const { archived } = props;
189
+ return /*#__PURE__*/ _jsx(CardDisplay, {
190
+ header: "Sequence",
191
+ help: pluginDocsHelp('sequences', {
192
+ anchor: '#build-a-sequence'
193
+ }),
194
+ // The whole sequence (steps and sending rules included) saves
195
+ // from its first card's header, where the console puts a card's
196
+ // actions (AGL-3333).
197
+ HeaderProps: {
198
+ action: archived ? undefined : /*#__PURE__*/ _jsx(SequenceSaveActions, {
199
+ saving: props.saving,
200
+ saveLabel: props.saveLabel,
201
+ onCancel: props.onCancel,
202
+ onSave: props.onSave
203
+ })
204
+ },
205
+ contentGutterX: true,
206
+ contentGutterY: true,
207
+ children: /*#__PURE__*/ _jsxs(Stack, {
208
+ spacing: 2,
209
+ children: [
210
+ /*#__PURE__*/ _jsx(MemoTextField, {
211
+ label: "Name",
212
+ value: props.name,
213
+ onChange: props.onName,
214
+ disabled: archived,
215
+ error: Boolean(props.nameIssue),
216
+ helperText: props.nameIssue,
217
+ slotProps: NAME_SLOT_PROPS,
218
+ fullWidth: true
219
+ }),
220
+ /*#__PURE__*/ _jsx(SequenceSiteField, {
221
+ hosts: props.hosts,
222
+ value: props.hostId,
223
+ issue: props.hostIssue,
224
+ disabled: archived,
225
+ onChange: props.onHost
226
+ }),
227
+ /*#__PURE__*/ _jsx(MemoMailboxPicker, {
228
+ orgId: props.orgId,
229
+ uid: props.uid,
230
+ mailboxes: props.mailboxes,
231
+ mailboxesPath: props.mailboxesPath,
232
+ value: props.mailboxId,
233
+ onChange: props.onMailbox,
234
+ error: props.mailboxIssue,
235
+ disabled: archived
236
+ }),
237
+ /*#__PURE__*/ _jsx(MemoContainerPicker, {
238
+ kind: "campaign",
239
+ options: props.campaignOptions,
240
+ value: props.campaignIds,
241
+ onChange: props.onCampaigns,
242
+ label: "Campaigns",
243
+ helperText: (_props_campaignsIssue = props.campaignsIssue) != null ? _props_campaignsIssue : 'The campaigns this sequence is part of. Everyone enrolled joins them, and its sends, replies, meetings and conversions count on their pages.',
244
+ disabled: archived,
245
+ empty: props.noCampaigns,
246
+ emptyText: "There are no campaigns yet. Create one from Marketing to file this sequence under it."
247
+ })
248
+ ]
249
+ })
250
+ });
251
+ });
252
+ /**
253
+ * One step's card, bound to its position. Memoized, with handlers that keep
254
+ * their identity, so a keystroke in one step leaves the others undrawn.
255
+ */ const SequenceStepItem = /*#__PURE__*/ memo(function SequenceStepItem(props) {
256
+ const { index, canSendTest, onStepChange, onStepMove, onStepRemove, onStepTest } = props, card = _object_without_properties_loose(props, [
257
+ "index",
258
+ "canSendTest",
259
+ "onStepChange",
260
+ "onStepMove",
261
+ "onStepRemove",
262
+ "onStepTest"
263
+ ]);
264
+ const onChange = useCallback((step)=>onStepChange(index, step), [
265
+ index,
266
+ onStepChange
267
+ ]);
268
+ const onMove = useCallback((direction)=>onStepMove(index, direction), [
269
+ index,
270
+ onStepMove
271
+ ]);
272
+ const onRemove = useCallback(()=>onStepRemove(index), [
273
+ index,
274
+ onStepRemove
275
+ ]);
276
+ const onSendTest = useCallback(()=>onStepTest(index), [
277
+ index,
278
+ onStepTest
279
+ ]);
280
+ return /*#__PURE__*/ _jsx(OutreachStepCard, _extends({}, card, {
281
+ index: index,
282
+ onChange: onChange,
283
+ onMove: onMove,
284
+ onRemove: onRemove,
285
+ onSendTest: canSendTest ? onSendTest : undefined
286
+ }));
287
+ });
288
+ /** Adds an email, or a task of a kind picked from its menu. */ const SequenceAddStep = /*#__PURE__*/ memo(function SequenceAddStep(props) {
289
+ const { onAddTask } = props;
290
+ const [taskAnchor, setTaskAnchor] = useState(null);
291
+ return /*#__PURE__*/ _jsxs(Stack, {
292
+ direction: "row",
293
+ spacing: 1,
294
+ sx: ADD_STEP_ROW,
295
+ children: [
296
+ /*#__PURE__*/ _jsx(Button, {
297
+ variant: "outlined",
298
+ startIcon: /*#__PURE__*/ _jsx(MdiIcon, {
299
+ path: mdiPlus.path,
300
+ fontSize: "small"
301
+ }),
302
+ disabled: props.emailDisabled,
303
+ onClick: props.onAddEmail,
304
+ children: "Add email"
305
+ }),
306
+ /*#__PURE__*/ _jsx(Button, {
307
+ variant: "outlined",
308
+ startIcon: /*#__PURE__*/ _jsx(MdiIcon, {
309
+ path: mdiPlus.path,
310
+ fontSize: "small"
311
+ }),
312
+ disabled: props.taskDisabled,
313
+ onClick: (event)=>setTaskAnchor(event.currentTarget),
314
+ "aria-haspopup": "menu",
315
+ children: "Add task"
316
+ }),
317
+ /*#__PURE__*/ _jsx(Menu, {
318
+ anchorEl: taskAnchor,
319
+ open: Boolean(taskAnchor),
320
+ onClose: ()=>setTaskAnchor(null),
321
+ children: OUTREACH_TASK_KINDS.map((kind)=>/*#__PURE__*/ _jsx(MenuItem, {
322
+ onClick: ()=>{
323
+ setTaskAnchor(null);
324
+ onAddTask(kind);
325
+ },
326
+ children: OUTREACH_TASK_KIND_LABELS[kind]
327
+ }, kind))
328
+ })
329
+ ]
330
+ });
331
+ });
332
+ /** Who the sequence sends to, and when: its countries, switches and hours. */ const SequenceSendingCard = /*#__PURE__*/ memo(function SequenceSendingCard(props) {
333
+ const { settings, disabled, onSettings } = props;
334
+ const setAllowCustomers = useCallback((event)=>onSettings({
335
+ allowCustomers: event.target.checked
336
+ }), [
337
+ onSettings
338
+ ]);
339
+ const setTrackClicks = useCallback((event)=>onSettings({
340
+ trackClicks: event.target.checked
341
+ }), [
342
+ onSettings
343
+ ]);
344
+ const setCountOpens = useCallback((event)=>onSettings({
345
+ countOpens: event.target.checked
346
+ }), [
347
+ onSettings
348
+ ]);
349
+ const setListUnsubscribe = useCallback((event)=>onSettings({
350
+ listUnsubscribe: event.target.checked
351
+ }), [
352
+ onSettings
353
+ ]);
354
+ const setWindow = useCallback((window)=>onSettings({
355
+ window
356
+ }), [
357
+ onSettings
358
+ ]);
359
+ return /*#__PURE__*/ _jsx(CardDisplay, {
360
+ header: "Who it sends to, and when",
361
+ help: pluginDocsHelp('sequences', {
362
+ anchor: '#build-a-sequence',
363
+ excerpt: 'A sequence sends only to the countries both it and your organization allow, and in its mailbox’s sending hours unless it has its own.'
364
+ }),
365
+ contentGutterX: true,
366
+ contentGutterY: true,
367
+ children: /*#__PURE__*/ _jsxs(Stack, {
368
+ spacing: 2,
369
+ children: [
370
+ /*#__PURE__*/ _jsx(OutreachCountriesField, {
371
+ options: props.countryOptions,
372
+ value: settings.allowedCountries,
373
+ onChange: props.onCountries,
374
+ issue: props.countriesIssue,
375
+ disabled: disabled
376
+ }),
377
+ /*#__PURE__*/ _jsx(FormControlLabel, {
378
+ control: /*#__PURE__*/ _jsx(Switch, {
379
+ checked: settings.allowCustomers,
380
+ onChange: setAllowCustomers,
381
+ disabled: disabled
382
+ }),
383
+ label: "Include customers"
384
+ }),
385
+ /*#__PURE__*/ _jsxs(Stack, {
386
+ spacing: 0,
387
+ children: [
388
+ /*#__PURE__*/ _jsx(FormControlLabel, {
389
+ control: /*#__PURE__*/ _jsx(Switch, {
390
+ checked: settings.trackClicks,
391
+ onChange: setTrackClicks,
392
+ disabled: disabled
393
+ }),
394
+ label: "Count link clicks"
395
+ }),
396
+ /*#__PURE__*/ _jsx(Typography, {
397
+ variant: "caption",
398
+ color: "text.secondary",
399
+ children: "Links in these emails are replaced with links of ours that forward to the same page, so clicks can be counted. The recipient sees the replacement, not your address. The emails stay plain text."
400
+ })
401
+ ]
402
+ }),
403
+ /*#__PURE__*/ _jsxs(Stack, {
404
+ spacing: 0,
405
+ children: [
406
+ /*#__PURE__*/ _jsx(FormControlLabel, {
407
+ control: /*#__PURE__*/ _jsx(Switch, {
408
+ checked: settings.countOpens,
409
+ onChange: setCountOpens,
410
+ disabled: disabled
411
+ }),
412
+ label: "Count opens"
413
+ }),
414
+ /*#__PURE__*/ _jsx(Typography, {
415
+ variant: "caption",
416
+ color: "text.secondary",
417
+ children: "Each email also goes out as HTML — the same words — with a tiny tracking image, so opens can be counted. The plain-text version is unchanged. It makes the email look less like one written by hand, and some spam filters score HTML with a remote image against you, so turn it on to measure a slice of your sends rather than for every sequence. Gmail opens count; Apple Mail loads every image on arrival, so its loads are counted separately."
418
+ })
419
+ ]
420
+ }),
421
+ /*#__PURE__*/ _jsxs(Stack, {
422
+ spacing: 0,
423
+ children: [
424
+ /*#__PURE__*/ _jsx(FormControlLabel, {
425
+ control: /*#__PURE__*/ _jsx(Switch, {
426
+ checked: settings.listUnsubscribe,
427
+ onChange: setListUnsubscribe,
428
+ disabled: disabled
429
+ }),
430
+ label: "Add a mail-client unsubscribe button"
431
+ }),
432
+ /*#__PURE__*/ _jsx(Typography, {
433
+ variant: "caption",
434
+ color: "text.secondary",
435
+ children: "Adds the List-Unsubscribe header, so the recipient’s mail app shows its own Unsubscribe button. It’s the easiest way out for them, and fewer people mark you as spam when they have it, but mail apps then label the email as coming from a mailing list. Off, the footer’s “reply ‘no’” line is the way out, and it’s always there."
436
+ })
437
+ ]
438
+ }),
439
+ /*#__PURE__*/ _jsx(MemoSendWindowFields, {
440
+ window: settings.window,
441
+ onChange: setWindow,
442
+ daysIssue: props.daysIssue,
443
+ hoursIssue: props.hoursIssue,
444
+ disabled: disabled
445
+ })
446
+ ]
447
+ })
448
+ });
449
+ });
94
450
  /**
95
451
  * The sequence editor (AGL-2980): the name, the site and mailbox, the steps,
96
452
  * who it sends to and when, beside a live preview of each email.
@@ -101,8 +457,21 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
101
457
  * judge (whose mailbox, which countries the organization allows, what may
102
458
  * no longer change once people are enrolled) comes back from the save and
103
459
  * is shown in the same places.
460
+ *
461
+ * ## A keystroke redraws its own field (AGL-3423)
462
+ *
463
+ * The whole sequence is one draft, so every letter typed anywhere is a new
464
+ * draft, and drawing the editor from it inline redrew every field of every
465
+ * step, the settings and the preview with each one: 17 inputs and some 1,150
466
+ * components for one letter in a three-step sequence. That cost is what lets
467
+ * typed input queue, and queued keystrokes processed back to back are what
468
+ * drove the countries chips into React's #185. The sequence card, each step,
469
+ * the sending card and the preview are memoized and handed only what they
470
+ * show, through handlers that keep their identity; the handlers that wrote
471
+ * from the draft as drawn still do, through `drawn`. A letter now redraws the
472
+ * field it lands in, and the preview when it shows that step's email.
104
473
  */ export function OutreachSequenceEditor(props) {
105
- var _ref, _props_mailboxes_mailboxes_find, _ref1, _issueAt, _issueAt1, _props_org, _ref2;
474
+ var _ref, _props_mailboxes_mailboxes_find, _ref1, _props_org, _ref2;
106
475
  var _settings_settings, _settings_settings1, _issues_find, _hosts_find;
107
476
  const { orgId, orgMount, sequence, settings } = props;
108
477
  const api = useOutreachApi(orgId);
@@ -126,9 +495,11 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
126
495
  const [attempted, setAttempted] = useState(false);
127
496
  const [serverIssues, setServerIssues] = useState([]);
128
497
  const [saving, setSaving] = useState(false);
129
- const [taskAnchor, setTaskAnchor] = useState(null);
130
498
  // The step whose test-send dialog is open (AGL-3325), by index.
131
499
  const [testing, setTesting] = useState(null);
500
+ // The draft as last drawn, which the handlers that build on it read.
501
+ const drawn = useRef(draft);
502
+ drawn.current = draft;
132
503
  // A stored sequence the listener updated (a save, a status change) is the
133
504
  // new starting point.
134
505
  useEffect(()=>{
@@ -174,13 +545,19 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
174
545
  ]);
175
546
  const templates = useOutreachEmailTemplates(orgId, draft.hostId || null, uid);
176
547
  const mailbox = (_props_mailboxes_mailboxes_find = props.mailboxes.mailboxes.find((entry)=>entry.id === draft.mailboxId)) != null ? _props_mailboxes_mailboxes_find : null;
548
+ const sender = useMemo(()=>mailbox ? {
549
+ name: mailbox.displayName,
550
+ email: mailbox.sendAs || mailbox.email
551
+ } : null, [
552
+ mailbox
553
+ ]);
177
554
  const archived = (sequence == null ? void 0 : sequence.status) === 'archived';
178
555
  /*
179
556
  * The org's campaigns, for the picker (AGL-3254): read while the editor
180
557
  * is open. A sequence is the organization's record and so is a campaign,
181
558
  * so the choice is every campaign in the org, whichever site the
182
559
  * sequence sends as.
183
- */ const campaigns = useOrgCampaigns(orgId, {
560
+ */ const campaigns = useOrgContainerOptions('campaign', orgId, {
184
561
  enabled: true
185
562
  });
186
563
  const issues = useMemo(()=>{
@@ -198,23 +575,18 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
198
575
  var _issues_find;
199
576
  return (_issues_find = issues.find((issue)=>issue.path === path)) == null ? void 0 : _issues_find.message;
200
577
  };
201
- const stepIssues = (index)=>{
202
- const found = {};
203
- for (const issue of issues){
204
- const key = stepIssueKey(issue.path);
205
- if ((key == null ? void 0 : key.index) === index && !found[key.field]) found[key.field] = issue.message;
206
- }
207
- return found;
208
- };
578
+ const stepIssues = useStepIssues(issues, draft.steps.length);
209
579
  const listIssue = issueAt('steps');
210
580
  const countriesIssue = (_issues_find = issues.find((issue)=>issue.path.startsWith('settings.allowedCountries'))) == null ? void 0 : _issues_find.message;
211
581
  // An edit clears what the last save refused. The empty list stays the same
212
582
  // list, so an edit with nothing to clear does not change that state.
213
583
  const clearServerIssues = useCallback(()=>setServerIssues((previous)=>previous.length ? [] : previous), []);
214
- const update = (next)=>{
584
+ const update = useCallback((next)=>{
215
585
  clearServerIssues();
216
586
  setDraft((previous)=>_extends({}, previous, next));
217
- };
587
+ }, [
588
+ clearServerIssues
589
+ ]);
218
590
  // Stable, for the memoized countries field: it reads the settings it
219
591
  // replaces from the draft it is given rather than from this render's.
220
592
  const setCountries = useCallback((next)=>{
@@ -227,21 +599,72 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
227
599
  }, [
228
600
  clearServerIssues
229
601
  ]);
230
- const setStep = (index, step)=>update({
231
- steps: draft.steps.map((entry, position)=>position === index ? step : entry)
232
- });
233
- const moveStep = (index, direction)=>{
602
+ const setName = useCallback((event)=>update({
603
+ name: event.target.value
604
+ }), [
605
+ update
606
+ ]);
607
+ const setHost = useCallback((event)=>update({
608
+ hostId: event.target.value
609
+ }), [
610
+ update
611
+ ]);
612
+ const setMailbox = useCallback((mailboxId)=>update({
613
+ mailboxId
614
+ }), [
615
+ update
616
+ ]);
617
+ const setCampaigns = useCallback((campaignIds)=>update({
618
+ campaignIds
619
+ }), [
620
+ update
621
+ ]);
622
+ const setSettings = useCallback((patch)=>update({
623
+ settings: _extends({}, drawn.current.settings, patch)
624
+ }), [
625
+ update
626
+ ]);
627
+ const setStep = useCallback((index, step)=>update({
628
+ steps: drawn.current.steps.map((entry, position)=>position === index ? step : entry)
629
+ }), [
630
+ update
631
+ ]);
632
+ const moveStep = useCallback((index, direction)=>{
234
633
  const steps = [
235
- ...draft.steps
634
+ ...drawn.current.steps
236
635
  ];
237
636
  const [moved] = steps.splice(index, 1);
238
637
  steps.splice(index + direction, 0, moved);
239
638
  update({
240
639
  steps
241
640
  });
242
- };
641
+ }, [
642
+ update
643
+ ]);
644
+ const removeStep = useCallback((index)=>update({
645
+ steps: drawn.current.steps.filter((_entry, position)=>position !== index)
646
+ }), [
647
+ update
648
+ ]);
649
+ const addEmail = useCallback(()=>update({
650
+ steps: [
651
+ ...drawn.current.steps,
652
+ newOutreachEmailStep(drawn.current.steps)
653
+ ]
654
+ }), [
655
+ update
656
+ ]);
657
+ const addTask = useCallback((kind)=>update({
658
+ steps: [
659
+ ...drawn.current.steps,
660
+ newOutreachTaskStep(drawn.current.steps, kind)
661
+ ]
662
+ }), [
663
+ update
664
+ ]);
243
665
  const emailCount = draft.steps.filter((step)=>step.kind === 'email').length;
244
666
  const full = draft.steps.length >= OUTREACH_MAX_STEPS;
667
+ const firstEmail = draft.steps.findIndex((entry)=>entry.kind === 'email');
245
668
  const threadSubject = (index)=>{
246
669
  for(let position = index - 1; position >= 0; position -= 1){
247
670
  const step = draft.steps[position];
@@ -274,6 +697,10 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
274
697
  setSaving(false);
275
698
  }
276
699
  };
700
+ // The save as last drawn, behind a handler that keeps its identity.
701
+ const drawnSave = useRef(save);
702
+ drawnSave.current = save;
703
+ const onSave = useCallback(()=>void drawnSave.current(), []);
277
704
  const siteName = (_ref1 = (_hosts_find = hosts.find((host)=>host.id === draft.hostId)) == null ? void 0 : _hosts_find.name) != null ? _ref1 : '';
278
705
  const draftCountries = draft.settings.allowedCountries;
279
706
  const countryOptions = useMemo(()=>[
@@ -308,289 +735,78 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
308
735
  severity: "info",
309
736
  children: "This sequence is archived, so it can’t be edited."
310
737
  }) : null,
311
- /*#__PURE__*/ _jsx(CardDisplay, {
312
- header: "Sequence",
313
- help: pluginDocsHelp('sequences', {
314
- anchor: '#build-a-sequence'
315
- }),
316
- // The whole sequence (steps and sending rules included) saves
317
- // from its first card's header, where the console puts a card's
318
- // actions (AGL-3333).
319
- HeaderProps: {
320
- action: archived ? undefined : /*#__PURE__*/ _jsxs(Stack, {
321
- direction: "row",
322
- spacing: 1,
323
- children: [
324
- props.onCancel ? /*#__PURE__*/ _jsx(Button, {
325
- size: "small",
326
- variant: "text",
327
- onClick: props.onCancel,
328
- disabled: saving,
329
- children: "Cancel"
330
- }) : null,
331
- /*#__PURE__*/ _jsx(Button, {
332
- size: "small",
333
- variant: "contained",
334
- onClick: ()=>void save(),
335
- disabled: saving,
336
- children: saving ? 'Saving…' : sequence ? 'Save' : 'Create sequence'
337
- })
338
- ]
339
- })
340
- },
341
- contentGutterX: true,
342
- contentGutterY: true,
343
- children: /*#__PURE__*/ _jsxs(Stack, {
344
- spacing: 2,
345
- children: [
346
- /*#__PURE__*/ _jsx(TextField, {
347
- label: "Name",
348
- value: draft.name,
349
- onChange: (event)=>update({
350
- name: event.target.value
351
- }),
352
- disabled: archived,
353
- error: Boolean(issueAt('name')),
354
- helperText: issueAt('name'),
355
- slotProps: {
356
- htmlInput: {
357
- maxLength: OUTREACH_SEQUENCE_NAME_MAX + 20
358
- }
359
- },
360
- fullWidth: true
361
- }),
362
- /*#__PURE__*/ _jsx(TextField, {
363
- select: true,
364
- label: "Site",
365
- value: hosts.some((host)=>host.id === draft.hostId) ? draft.hostId : '',
366
- onChange: (event)=>update({
367
- hostId: event.target.value
368
- }),
369
- disabled: archived || !hosts.length,
370
- error: Boolean(issueAt('hostId')),
371
- helperText: (_issueAt = issueAt('hostId')) != null ? _issueAt : 'The site whose contacts this sequence emails.',
372
- fullWidth: true,
373
- children: hosts.map((host)=>/*#__PURE__*/ _jsx(MenuItem, {
374
- value: host.id,
375
- children: host.name
376
- }, host.id))
377
- }),
378
- /*#__PURE__*/ _jsx(OutreachSequenceMailboxPicker, {
379
- orgId: orgId,
380
- uid: uid,
381
- mailboxes: props.mailboxes,
382
- mailboxesPath: props.mailboxesPath,
383
- value: draft.mailboxId,
384
- onChange: (mailboxId)=>update({
385
- mailboxId
386
- }),
387
- error: issueAt('mailboxId'),
388
- disabled: archived
389
- }),
390
- /*#__PURE__*/ _jsx(CampaignPicker, {
391
- options: campaigns.options,
392
- value: draft.campaignIds,
393
- onChange: (campaignIds)=>update({
394
- campaignIds
395
- }),
396
- label: "Campaigns",
397
- helperText: (_issueAt1 = issueAt('campaignIds')) != null ? _issueAt1 : 'The campaigns this sequence is part of. Everyone enrolled joins them, and its sends, replies, meetings and conversions count on their pages.',
398
- disabled: archived,
399
- empty: campaigns.ready && !campaigns.options.length,
400
- emptyText: "There are no campaigns yet. Create one from Marketing to file this sequence under it."
401
- })
402
- ]
403
- })
738
+ /*#__PURE__*/ _jsx(SequenceCard, {
739
+ orgId: orgId,
740
+ uid: uid,
741
+ name: draft.name,
742
+ hostId: draft.hostId,
743
+ mailboxId: draft.mailboxId,
744
+ campaignIds: draft.campaignIds,
745
+ hosts: hosts,
746
+ mailboxes: props.mailboxes,
747
+ mailboxesPath: props.mailboxesPath,
748
+ campaignOptions: campaigns.options,
749
+ noCampaigns: campaigns.ready && !campaigns.options.length,
750
+ nameIssue: issueAt('name'),
751
+ hostIssue: issueAt('hostId'),
752
+ mailboxIssue: issueAt('mailboxId'),
753
+ campaignsIssue: issueAt('campaignIds'),
754
+ archived: archived,
755
+ saving: saving,
756
+ saveLabel: sequence ? 'Save' : 'Create sequence',
757
+ onCancel: props.onCancel,
758
+ onSave: onSave,
759
+ onName: setName,
760
+ onHost: setHost,
761
+ onMailbox: setMailbox,
762
+ onCampaigns: setCampaigns
404
763
  }),
405
764
  listIssue ? /*#__PURE__*/ _jsx(Alert, {
406
765
  severity: "error",
407
766
  children: listIssue
408
767
  }) : null,
409
- draft.steps.map((step, index)=>/*#__PURE__*/ _jsx(OutreachStepCard, {
768
+ draft.steps.map((step, index)=>{
769
+ const inThread = isInThreadEmailStep(draft.steps, index);
770
+ return /*#__PURE__*/ _jsx(SequenceStepItem, {
410
771
  step: step,
411
772
  index: index,
412
773
  count: draft.steps.length,
413
- firstEmail: draft.steps.findIndex((entry)=>entry.kind === 'email') === index,
414
- inThread: isInThreadEmailStep(draft.steps, index),
415
- threadSubject: threadSubject(index),
774
+ firstEmail: firstEmail === index,
775
+ inThread: inThread,
776
+ // Read only by an email sent in the thread, so no other
777
+ // step is redrawn as the subject it replies to is typed.
778
+ threadSubject: inThread ? threadSubject(index) : '',
416
779
  templates: templates.data,
417
- issues: stepIssues(index),
780
+ issues: stepIssues[index],
418
781
  disabled: archived,
419
- onChange: (next)=>setStep(index, next),
420
- onMove: (direction)=>moveStep(index, direction),
421
- onRemove: ()=>update({
422
- steps: draft.steps.filter((_entry, position)=>position !== index)
423
- }),
424
- onSendTest: step.kind === 'email' && !archived ? ()=>setTesting(index) : undefined,
425
- sendTestDisabledReason: sendTestDisabledReason
426
- }, step.id || `step-${index}`)),
427
- /*#__PURE__*/ _jsxs(Stack, {
428
- direction: "row",
429
- spacing: 1,
430
- sx: {
431
- flexWrap: 'wrap',
432
- rowGap: 1
433
- },
434
- children: [
435
- /*#__PURE__*/ _jsx(Button, {
436
- variant: "outlined",
437
- startIcon: /*#__PURE__*/ _jsx(MdiIcon, {
438
- path: mdiPlus.path,
439
- fontSize: "small"
440
- }),
441
- disabled: archived || full || emailCount >= OUTREACH_MAX_EMAIL_STEPS,
442
- onClick: ()=>update({
443
- steps: [
444
- ...draft.steps,
445
- newOutreachEmailStep(draft.steps)
446
- ]
447
- }),
448
- children: "Add email"
449
- }),
450
- /*#__PURE__*/ _jsx(Button, {
451
- variant: "outlined",
452
- startIcon: /*#__PURE__*/ _jsx(MdiIcon, {
453
- path: mdiPlus.path,
454
- fontSize: "small"
455
- }),
456
- disabled: archived || full,
457
- onClick: (event)=>setTaskAnchor(event.currentTarget),
458
- "aria-haspopup": "menu",
459
- children: "Add task"
460
- }),
461
- /*#__PURE__*/ _jsx(Menu, {
462
- anchorEl: taskAnchor,
463
- open: Boolean(taskAnchor),
464
- onClose: ()=>setTaskAnchor(null),
465
- children: OUTREACH_TASK_KINDS.map((kind)=>/*#__PURE__*/ _jsx(MenuItem, {
466
- onClick: ()=>{
467
- setTaskAnchor(null);
468
- update({
469
- steps: [
470
- ...draft.steps,
471
- newOutreachTaskStep(draft.steps, kind)
472
- ]
473
- });
474
- },
475
- children: OUTREACH_TASK_KIND_LABELS[kind]
476
- }, kind))
477
- })
478
- ]
782
+ canSendTest: step.kind === 'email' && !archived,
783
+ sendTestDisabledReason: sendTestDisabledReason,
784
+ onStepChange: setStep,
785
+ onStepMove: moveStep,
786
+ onStepRemove: removeStep,
787
+ onStepTest: setTesting
788
+ }, step.id || `step-${index}`);
789
+ }),
790
+ /*#__PURE__*/ _jsx(SequenceAddStep, {
791
+ emailDisabled: archived || full || emailCount >= OUTREACH_MAX_EMAIL_STEPS,
792
+ taskDisabled: archived || full,
793
+ onAddEmail: addEmail,
794
+ onAddTask: addTask
479
795
  }),
480
796
  /*#__PURE__*/ _jsx(Typography, {
481
797
  variant: "caption",
482
798
  color: "text.secondary",
483
799
  children: `Up to ${OUTREACH_MAX_STEPS} steps, ${OUTREACH_MAX_EMAIL_STEPS} of them emails.`
484
800
  }),
485
- /*#__PURE__*/ _jsx(CardDisplay, {
486
- header: "Who it sends to, and when",
487
- help: pluginDocsHelp('sequences', {
488
- anchor: '#build-a-sequence',
489
- excerpt: 'A sequence sends only to the countries both it and your organization allow, and in its mailbox’s sending hours unless it has its own.'
490
- }),
491
- contentGutterX: true,
492
- contentGutterY: true,
493
- children: /*#__PURE__*/ _jsxs(Stack, {
494
- spacing: 2,
495
- children: [
496
- /*#__PURE__*/ _jsx(OutreachCountriesField, {
497
- options: countryOptions,
498
- value: draftCountries,
499
- onChange: setCountries,
500
- issue: countriesIssue,
501
- disabled: archived
502
- }),
503
- /*#__PURE__*/ _jsx(FormControlLabel, {
504
- control: /*#__PURE__*/ _jsx(Switch, {
505
- checked: draft.settings.allowCustomers,
506
- onChange: (event)=>update({
507
- settings: _extends({}, draft.settings, {
508
- allowCustomers: event.target.checked
509
- })
510
- }),
511
- disabled: archived
512
- }),
513
- label: "Include customers"
514
- }),
515
- /*#__PURE__*/ _jsxs(Stack, {
516
- spacing: 0,
517
- children: [
518
- /*#__PURE__*/ _jsx(FormControlLabel, {
519
- control: /*#__PURE__*/ _jsx(Switch, {
520
- checked: draft.settings.trackClicks,
521
- onChange: (event)=>update({
522
- settings: _extends({}, draft.settings, {
523
- trackClicks: event.target.checked
524
- })
525
- }),
526
- disabled: archived
527
- }),
528
- label: "Count link clicks"
529
- }),
530
- /*#__PURE__*/ _jsx(Typography, {
531
- variant: "caption",
532
- color: "text.secondary",
533
- children: "Links in these emails are replaced with links of ours that forward to the same page, so clicks can be counted. The recipient sees the replacement, not your address. The emails stay plain text."
534
- })
535
- ]
536
- }),
537
- /*#__PURE__*/ _jsxs(Stack, {
538
- spacing: 0,
539
- children: [
540
- /*#__PURE__*/ _jsx(FormControlLabel, {
541
- control: /*#__PURE__*/ _jsx(Switch, {
542
- checked: draft.settings.countOpens,
543
- onChange: (event)=>update({
544
- settings: _extends({}, draft.settings, {
545
- countOpens: event.target.checked
546
- })
547
- }),
548
- disabled: archived
549
- }),
550
- label: "Count opens"
551
- }),
552
- /*#__PURE__*/ _jsx(Typography, {
553
- variant: "caption",
554
- color: "text.secondary",
555
- children: "Each email also goes out as HTML — the same words — with a tiny tracking image, so opens can be counted. The plain-text version is unchanged. It makes the email look less like one written by hand, and some spam filters score HTML with a remote image against you, so turn it on to measure a slice of your sends rather than for every sequence. Gmail opens count; Apple Mail loads every image on arrival, so its loads are counted separately."
556
- })
557
- ]
558
- }),
559
- /*#__PURE__*/ _jsxs(Stack, {
560
- spacing: 0,
561
- children: [
562
- /*#__PURE__*/ _jsx(FormControlLabel, {
563
- control: /*#__PURE__*/ _jsx(Switch, {
564
- checked: draft.settings.listUnsubscribe,
565
- onChange: (event)=>update({
566
- settings: _extends({}, draft.settings, {
567
- listUnsubscribe: event.target.checked
568
- })
569
- }),
570
- disabled: archived
571
- }),
572
- label: "Add a mail-client unsubscribe button"
573
- }),
574
- /*#__PURE__*/ _jsx(Typography, {
575
- variant: "caption",
576
- color: "text.secondary",
577
- children: "Adds the List-Unsubscribe header, so the recipient’s mail app shows its own Unsubscribe button. It’s the easiest way out for them, and fewer people mark you as spam when they have it, but mail apps then label the email as coming from a mailing list. Off, the footer’s “reply ‘no’” line is the way out, and it’s always there."
578
- })
579
- ]
580
- }),
581
- /*#__PURE__*/ _jsx(OutreachSendWindowFields, {
582
- window: draft.settings.window,
583
- onChange: (window)=>update({
584
- settings: _extends({}, draft.settings, {
585
- window
586
- })
587
- }),
588
- daysIssue: issueAt('settings.window.days'),
589
- hoursIssue: issueAt('settings.window'),
590
- disabled: archived
591
- })
592
- ]
593
- })
801
+ /*#__PURE__*/ _jsx(SequenceSendingCard, {
802
+ settings: draft.settings,
803
+ countryOptions: countryOptions,
804
+ countriesIssue: countriesIssue,
805
+ daysIssue: issueAt('settings.window.days'),
806
+ hoursIssue: issueAt('settings.window'),
807
+ disabled: archived,
808
+ onCountries: setCountries,
809
+ onSettings: setSettings
594
810
  })
595
811
  ]
596
812
  })
@@ -612,23 +828,13 @@ import { useOutreachEmailTemplates } from "./use-outreach-crm.js";
612
828
  md: 5
613
829
  },
614
830
  children: /*#__PURE__*/ _jsx(Box, {
615
- sx: {
616
- position: {
617
- md: 'sticky'
618
- },
619
- top: {
620
- md: 16
621
- }
622
- },
623
- children: /*#__PURE__*/ _jsx(OutreachSequencePreview, {
831
+ sx: PREVIEW_COLUMN,
832
+ children: /*#__PURE__*/ _jsx(MemoSequencePreview, {
624
833
  steps: draft.steps,
625
834
  orgSettings: settings.settings,
626
835
  orgSettingsStatus: settings.status,
627
836
  templates: templates.data,
628
- sender: mailbox ? {
629
- name: mailbox.displayName,
630
- email: mailbox.sendAs || mailbox.email
631
- } : null,
837
+ sender: sender,
632
838
  siteName: siteName
633
839
  })
634
840
  })