@visns-studio/visns-components 6.34.0 → 6.35.0

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.
@@ -0,0 +1,614 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { CalendarClock, Copy, Monitor, Save, Send, Smartphone, TestTube2, Trash2 } from 'lucide-react';
3
+ import { toast } from 'react-toastify';
4
+
5
+ import StandardModal from '../generic/StandardModal';
6
+ import { showConfirmDialog } from '../generic/ConfirmationDialog';
7
+ import styles from '../styles/EmailCampaigns.module.scss';
8
+ import BlockEditor from './BlockEditor';
9
+ import CampaignReport from './CampaignReport';
10
+ import { api, formatWhen, STATUS_LABELS } from './emailCampaignApi';
11
+
12
+ /**
13
+ * One campaign: its settings and blocks on the left, the email as it will
14
+ * arrive on the right, and the actions along the top.
15
+ *
16
+ * AUTOSAVE, no Save button: every change is posted 700ms after the typing
17
+ * stops, and the header says Saving / Saved. A campaign editor people close
18
+ * without saving is a campaign editor that loses work.
19
+ *
20
+ * THE PREVIEW IS THE SERVER'S RENDER of the unsaved blocks, with the merge tags
21
+ * filled from the person looking at it, in a sandboxed frame. The page draws no
22
+ * email of its own: two renderers would disagree about what was sent.
23
+ *
24
+ * A campaign that has been sent (or is sending) opens on its REPORT, with the
25
+ * email beneath it, and nothing is editable.
26
+ */
27
+ const EDITABLE_FIELDS = ['name', 'subject', 'preview_text', 'from_name', 'from_email', 'reply_to', 'list_id', 'content'];
28
+
29
+ const CampaignEditor = ({ id, routes, canManage, settings, lists, onBack, onChanged }) => {
30
+ const [campaign, setCampaign] = useState(null);
31
+ const [draft, setDraft] = useState(null);
32
+ const [failed, setFailed] = useState('');
33
+ const [saving, setSaving] = useState('idle');
34
+ const [previewHtml, setPreviewHtml] = useState('');
35
+ const [previewSubject, setPreviewSubject] = useState('');
36
+ const [device, setDevice] = useState('desktop');
37
+ const [busy, setBusy] = useState(false);
38
+ const [scheduling, setScheduling] = useState(false);
39
+ const [testing, setTesting] = useState(false);
40
+ const [notice, setNotice] = useState(null);
41
+
42
+ const dirty = useRef(false);
43
+
44
+ const load = useCallback(async () => {
45
+ const result = await api(routes.campaign(id));
46
+
47
+ if (!result.ok || !result.data?.campaign) {
48
+ setFailed(result.message || 'This campaign could not be loaded.');
49
+
50
+ return;
51
+ }
52
+
53
+ setCampaign(result.data.campaign);
54
+ setDraft(pick(result.data.campaign));
55
+ dirty.current = false;
56
+ }, [routes, id]);
57
+
58
+ useEffect(() => {
59
+ load();
60
+ }, [load]);
61
+
62
+ const editable = Boolean(campaign?.editable) && canManage;
63
+
64
+ // Autosave.
65
+ useEffect(() => {
66
+ if (!draft || !editable || !dirty.current) return undefined;
67
+
68
+ setSaving('pending');
69
+
70
+ const timer = setTimeout(async () => {
71
+ setSaving('saving');
72
+ const result = await api(routes.campaign(id), 'POST', draft);
73
+
74
+ if (result.ok && result.data?.campaign) {
75
+ setCampaign(result.data.campaign);
76
+ setSaving('saved');
77
+ onChanged?.();
78
+ } else {
79
+ setSaving('error');
80
+ setNotice({ tone: 'bad', text: result.message || 'The change could not be saved.' });
81
+ }
82
+ }, 700);
83
+
84
+ return () => clearTimeout(timer);
85
+ }, [draft, editable, routes, id, onChanged]);
86
+
87
+ // Preview of the unsaved state.
88
+ useEffect(() => {
89
+ if (!draft) return undefined;
90
+
91
+ const timer = setTimeout(async () => {
92
+ const result = await api(routes.preview(id), 'POST', {
93
+ content: draft.content,
94
+ subject: draft.subject,
95
+ preview_text: draft.preview_text,
96
+ });
97
+
98
+ if (result.ok && typeof result.data?.html === 'string') {
99
+ setPreviewHtml(result.data.html);
100
+ setPreviewSubject(result.data.subject ?? '');
101
+ }
102
+ }, 450);
103
+
104
+ return () => clearTimeout(timer);
105
+ }, [draft, routes, id]);
106
+
107
+ const change = (patch) => {
108
+ dirty.current = true;
109
+ setNotice(null);
110
+ setDraft((current) => ({ ...current, ...patch }));
111
+ };
112
+
113
+ const act = async (url, body, success) => {
114
+ setBusy(true);
115
+ setNotice(null);
116
+ const result = await api(url, 'POST', body);
117
+ setBusy(false);
118
+
119
+ if (result.data?.campaign) {
120
+ setCampaign(result.data.campaign);
121
+ setDraft(pick(result.data.campaign));
122
+ dirty.current = false;
123
+ }
124
+
125
+ if (result.ok) {
126
+ toast.success(result.message || success);
127
+ onChanged?.();
128
+
129
+ return true;
130
+ }
131
+
132
+ setNotice({
133
+ tone: 'bad',
134
+ text: result.message || 'That did not work.',
135
+ blockers: Array.isArray(result.data?.blockers) ? result.data.blockers : [],
136
+ });
137
+
138
+ return false;
139
+ };
140
+
141
+ const sendNow = () =>
142
+ showConfirmDialog({
143
+ title: 'Send this campaign now?',
144
+ message: `It goes to everybody on ${listName(lists, draft?.list_id) || 'the list'} who has not unsubscribed. This cannot be undone.`,
145
+ confirmLabel: 'Send now',
146
+ cancelLabel: 'Not yet',
147
+ onConfirm: () => act(routes.send(id), {}, 'Sending now.'),
148
+ });
149
+
150
+ const duplicate = async () => {
151
+ const result = await api(routes.duplicate(id), 'POST', {});
152
+
153
+ if (result.ok && result.data?.campaign) {
154
+ toast.success('Copied into a new draft.');
155
+ onChanged?.();
156
+ onBack?.(result.data.campaign.id);
157
+ } else {
158
+ toast.error(result.message || 'It could not be copied.');
159
+ }
160
+ };
161
+
162
+ const remove = () =>
163
+ showConfirmDialog({
164
+ title: 'Delete this draft?',
165
+ message: 'The campaign and its content go. Nothing was sent, so nobody will notice.',
166
+ confirmLabel: 'Delete',
167
+ cancelLabel: 'Keep it',
168
+ onConfirm: async () => {
169
+ const result = await api(routes.campaign(id), 'DELETE');
170
+
171
+ if (result.ok) {
172
+ onChanged?.();
173
+ onBack?.();
174
+ } else {
175
+ toast.error(result.message || 'It could not be deleted.');
176
+ }
177
+ },
178
+ });
179
+
180
+ const saveTemplate = async () => {
181
+ const name = window.prompt('Name the template', draft?.name || 'Template');
182
+
183
+ if (!name) return;
184
+
185
+ const result = await api(routes.templates, 'POST', { name, content: draft.content });
186
+ result.ok ? toast.success('Saved as a template.') : toast.error(result.message || 'It could not be saved.');
187
+ };
188
+
189
+ if (failed) {
190
+ return <p className={styles.state}>{failed}</p>;
191
+ }
192
+
193
+ if (!campaign || !draft) {
194
+ return <p className={styles.state}>Loading the campaign…</p>;
195
+ }
196
+
197
+ const status = STATUS_LABELS[campaign.status] || { label: campaign.status, tone: 'quiet' };
198
+
199
+ return (
200
+ <div className={styles.editor}>
201
+ <div className={styles.editorHead}>
202
+ <div className={styles.editorTitle}>
203
+ <span className={`${styles.pill} ${styles[`tone_${status.tone}`]}`}>{status.label}</span>
204
+ {campaign.scheduled_at && campaign.status === 'scheduled' && (
205
+ <span className={styles.quiet}>for {formatWhen(campaign.scheduled_at)}</span>
206
+ )}
207
+ {campaign.sent_at && <span className={styles.quiet}>sent {formatWhen(campaign.sent_at)}</span>}
208
+ {editable && (
209
+ <span className={styles.saveMark} aria-live="polite">
210
+ {saving === 'saving' || saving === 'pending'
211
+ ? 'Saving…'
212
+ : saving === 'saved'
213
+ ? 'Saved'
214
+ : saving === 'error'
215
+ ? 'Not saved'
216
+ : ''}
217
+ </span>
218
+ )}
219
+ </div>
220
+
221
+ <div className={styles.editorActions}>
222
+ {editable && (
223
+ <>
224
+ <button
225
+ type="button"
226
+ className={styles.button}
227
+ disabled={busy}
228
+ title="Send one copy to yourself, with your own details filled in"
229
+ onClick={() => setTesting(true)}
230
+ >
231
+ <TestTube2 size={14} aria-hidden="true" />
232
+ Send test
233
+ </button>
234
+ <button
235
+ type="button"
236
+ className={styles.button}
237
+ disabled={busy}
238
+ title="Choose a date and time for it to go out"
239
+ onClick={() => setScheduling(true)}
240
+ >
241
+ <CalendarClock size={14} aria-hidden="true" />
242
+ Schedule…
243
+ </button>
244
+ <button
245
+ type="button"
246
+ className={styles.buttonPrimary}
247
+ disabled={busy}
248
+ title="Send it to the list straight away"
249
+ onClick={sendNow}
250
+ >
251
+ <Send size={14} aria-hidden="true" />
252
+ Send now
253
+ </button>
254
+ </>
255
+ )}
256
+ {canManage && campaign.status === 'scheduled' && (
257
+ <button
258
+ type="button"
259
+ className={styles.button}
260
+ disabled={busy}
261
+ title="Stop it going out. It becomes editable again"
262
+ onClick={() => act(routes.cancel(id), {}, 'Cancelled.')}
263
+ >
264
+ Cancel schedule
265
+ </button>
266
+ )}
267
+ {canManage && (
268
+ <>
269
+ <button
270
+ type="button"
271
+ className={styles.iconAction}
272
+ title="Save this content as a template for future campaigns"
273
+ aria-label="Save as template"
274
+ onClick={saveTemplate}
275
+ >
276
+ <Save size={15} aria-hidden="true" />
277
+ </button>
278
+ <button
279
+ type="button"
280
+ className={styles.iconAction}
281
+ title="Copy this campaign into a new draft"
282
+ aria-label="Duplicate"
283
+ onClick={duplicate}
284
+ >
285
+ <Copy size={15} aria-hidden="true" />
286
+ </button>
287
+ </>
288
+ )}
289
+ {editable && (
290
+ <button
291
+ type="button"
292
+ className={`${styles.iconAction} ${styles.iconDanger}`}
293
+ title="Delete this draft"
294
+ aria-label="Delete"
295
+ onClick={remove}
296
+ >
297
+ <Trash2 size={15} aria-hidden="true" />
298
+ </button>
299
+ )}
300
+ </div>
301
+ </div>
302
+
303
+ {notice && (
304
+ <div className={`${styles.notice} ${styles[`notice_${notice.tone}`]}`} role="alert">
305
+ <p>{notice.text}</p>
306
+ {notice.blockers?.length > 0 && (
307
+ <ul>
308
+ {notice.blockers.map((line) => (
309
+ <li key={line}>{line}</li>
310
+ ))}
311
+ </ul>
312
+ )}
313
+ </div>
314
+ )}
315
+
316
+ {campaign.error && campaign.status === 'failed' && (
317
+ <div className={`${styles.notice} ${styles.notice_bad}`}>
318
+ <p>{campaign.error}</p>
319
+ </div>
320
+ )}
321
+
322
+ {campaign.report && <CampaignReport report={campaign.report} />}
323
+
324
+ <div className={styles.editorBody}>
325
+ <div className={styles.editorForm}>
326
+ {/* A campaign that cannot change gets a short record of what
327
+ went out, not a greyed-out copy of the whole editor. */}
328
+ {!editable && (
329
+ <section className={styles.card}>
330
+ <h3 className={styles.cardTitle}>What went out</h3>
331
+ <dl className={styles.details}>
332
+ <dt>Subject</dt>
333
+ <dd>{draft.subject || '-'}</dd>
334
+ <dt>Preview text</dt>
335
+ <dd>{draft.preview_text || '-'}</dd>
336
+ <dt>From</dt>
337
+ <dd>
338
+ {draft.from_name || settings?.from_name} &lt;{draft.from_email || settings?.from_email}&gt;
339
+ </dd>
340
+ <dt>Replies to</dt>
341
+ <dd>{draft.reply_to || settings?.reply_to || 'The from address'}</dd>
342
+ <dt>List</dt>
343
+ <dd>{listName(lists, draft.list_id) || '-'}</dd>
344
+ </dl>
345
+ </section>
346
+ )}
347
+
348
+ {editable && (
349
+ <section className={styles.card}>
350
+ <h3 className={styles.cardTitle}>Settings</h3>
351
+
352
+ <Field label="Campaign name" hint="For you; recipients never see it.">
353
+ <input
354
+ className={styles.input}
355
+ value={draft.name || ''}
356
+ disabled={!editable}
357
+ maxLength={191}
358
+ onChange={(event) => change({ name: event.target.value })}
359
+ />
360
+ </Field>
361
+
362
+ <Field label="Send to">
363
+ <select
364
+ className={styles.select}
365
+ value={draft.list_id || ''}
366
+ disabled={!editable}
367
+ onChange={(event) => change({ list_id: event.target.value ? Number(event.target.value) : null })}
368
+ >
369
+ <option value="">Choose a list…</option>
370
+ {lists.map((list) => (
371
+ <option key={list.id} value={list.id}>
372
+ {list.name} ({list.members - list.unsubscribed} people)
373
+ </option>
374
+ ))}
375
+ </select>
376
+ </Field>
377
+
378
+ <Field label="Subject line" hint="Personal details work here too, e.g. {first_name}.">
379
+ <input
380
+ className={styles.input}
381
+ value={draft.subject || ''}
382
+ disabled={!editable}
383
+ maxLength={255}
384
+ onChange={(event) => change({ subject: event.target.value })}
385
+ />
386
+ </Field>
387
+
388
+ <Field label="Preview text" hint="The line most inboxes show after the subject.">
389
+ <input
390
+ className={styles.input}
391
+ value={draft.preview_text || ''}
392
+ disabled={!editable}
393
+ maxLength={255}
394
+ onChange={(event) => change({ preview_text: event.target.value })}
395
+ />
396
+ </Field>
397
+
398
+ <div className={styles.twoCol}>
399
+ <Field label="From name">
400
+ <input
401
+ className={styles.input}
402
+ value={draft.from_name || ''}
403
+ placeholder={settings?.from_name || ''}
404
+ disabled={!editable}
405
+ onChange={(event) => change({ from_name: event.target.value })}
406
+ />
407
+ </Field>
408
+ <Field label="From address" hint="On a domain verified in Resend.">
409
+ <input
410
+ type="email"
411
+ className={styles.input}
412
+ value={draft.from_email || ''}
413
+ placeholder={settings?.from_email || ''}
414
+ disabled={!editable}
415
+ onChange={(event) => change({ from_email: event.target.value })}
416
+ />
417
+ </Field>
418
+ </div>
419
+
420
+ <Field label="Replies go to" hint="Optional. Leave blank to use the from address.">
421
+ <input
422
+ type="email"
423
+ className={styles.input}
424
+ value={draft.reply_to || ''}
425
+ placeholder={settings?.reply_to || ''}
426
+ disabled={!editable}
427
+ onChange={(event) => change({ reply_to: event.target.value })}
428
+ />
429
+ </Field>
430
+ </section>
431
+
432
+ )}
433
+
434
+ {editable && (
435
+ <section className={styles.card}>
436
+ <h3 className={styles.cardTitle}>Content</h3>
437
+ <BlockEditor
438
+ blocks={Array.isArray(draft.content) ? draft.content : []}
439
+ disabled={!editable}
440
+ routes={routes}
441
+ tags={settings?.merge_tags}
442
+ onChange={(content) => change({ content })}
443
+ />
444
+ </section>
445
+ )}
446
+
447
+ {editable && campaign.blockers?.length > 0 && (
448
+ <section className={styles.blockersCard}>
449
+ <h3 className={styles.cardTitle}>Before it can be sent</h3>
450
+ <ul>
451
+ {campaign.blockers.map((line) => (
452
+ <li key={line}>{line}</li>
453
+ ))}
454
+ </ul>
455
+ </section>
456
+ )}
457
+ </div>
458
+
459
+ <div className={styles.previewPane}>
460
+ <div className={styles.previewBar}>
461
+ <span className={styles.quiet}>Preview, with your details filled in</span>
462
+ <span className={styles.deviceToggle} role="group" aria-label="Preview size">
463
+ <button
464
+ type="button"
465
+ className={device === 'desktop' ? styles.deviceOn : styles.device}
466
+ aria-pressed={device === 'desktop'}
467
+ title="Preview at desktop width"
468
+ onClick={() => setDevice('desktop')}
469
+ >
470
+ <Monitor size={14} aria-hidden="true" />
471
+ </button>
472
+ <button
473
+ type="button"
474
+ className={device === 'phone' ? styles.deviceOn : styles.device}
475
+ aria-pressed={device === 'phone'}
476
+ title="Preview at phone width"
477
+ onClick={() => setDevice('phone')}
478
+ >
479
+ <Smartphone size={14} aria-hidden="true" />
480
+ </button>
481
+ </span>
482
+ </div>
483
+ <div className={styles.inboxLine}>
484
+ <strong>{draft.from_name || settings?.from_name || 'Sender'}</strong>
485
+ <span>{previewSubject || draft.subject || 'No subject yet'}</span>
486
+ <span className={styles.quiet}>{draft.preview_text}</span>
487
+ </div>
488
+ {/* No allow-scripts: the frame runs nothing. allow-same-origin
489
+ is there so images on this origin load. */}
490
+ <iframe
491
+ title="Email preview"
492
+ className={device === 'phone' ? styles.framePhone : styles.frame}
493
+ sandbox="allow-same-origin"
494
+ srcDoc={previewHtml}
495
+ />
496
+ </div>
497
+ </div>
498
+
499
+ {scheduling && (
500
+ <ScheduleDialog
501
+ busy={busy}
502
+ onClose={() => setScheduling(false)}
503
+ onSchedule={async (iso) => {
504
+ if (await act(routes.send(id), { scheduled_at: iso }, 'Scheduled.')) {
505
+ setScheduling(false);
506
+ }
507
+ }}
508
+ />
509
+ )}
510
+
511
+ {testing && (
512
+ <TestDialog
513
+ busy={busy}
514
+ onClose={() => setTesting(false)}
515
+ onSend={async (to) => {
516
+ setBusy(true);
517
+ const result = await api(routes.test(id), 'POST', to ? { to } : {});
518
+ setBusy(false);
519
+
520
+ if (result.ok) {
521
+ toast.success(result.message);
522
+ setTesting(false);
523
+ } else {
524
+ toast.error(result.message || 'The test could not be sent.');
525
+ }
526
+ }}
527
+ />
528
+ )}
529
+ </div>
530
+ );
531
+ };
532
+
533
+ const ScheduleDialog = ({ onClose, onSchedule, busy }) => {
534
+ const soon = new Date(Date.now() + 60 * 60 * 1000);
535
+ soon.setMinutes(0, 0, 0);
536
+ const local = (date) => {
537
+ const pad = (n) => String(n).padStart(2, '0');
538
+
539
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
540
+ };
541
+ const [value, setValue] = useState(local(soon));
542
+
543
+ return (
544
+ <StandardModal isOpen onClose={onClose} title="Schedule the campaign" size="small" showHeader>
545
+ <div className={styles.dialog}>
546
+ <p className={styles.quiet}>It goes out at this time, in your own timezone. You can cancel it until then.</p>
547
+ <input
548
+ type="datetime-local"
549
+ className={styles.input}
550
+ value={value}
551
+ min={local(new Date())}
552
+ onChange={(event) => setValue(event.target.value)}
553
+ />
554
+ <div className={styles.dialogActions}>
555
+ <button type="button" className={styles.linkButton} onClick={onClose}>
556
+ Cancel
557
+ </button>
558
+ <button
559
+ type="button"
560
+ className={styles.buttonPrimary}
561
+ disabled={busy || !value}
562
+ onClick={() => onSchedule(new Date(value).toISOString())}
563
+ >
564
+ Schedule
565
+ </button>
566
+ </div>
567
+ </div>
568
+ </StandardModal>
569
+ );
570
+ };
571
+
572
+ const TestDialog = ({ onClose, onSend, busy }) => {
573
+ const [to, setTo] = useState('');
574
+
575
+ return (
576
+ <StandardModal isOpen onClose={onClose} title="Send a test" size="small" showHeader>
577
+ <div className={styles.dialog}>
578
+ <p className={styles.quiet}>
579
+ One copy, with your own name in place of each recipient's. Leave the address blank to send it to yourself.
580
+ </p>
581
+ <input
582
+ type="email"
583
+ className={styles.input}
584
+ value={to}
585
+ placeholder="Your own address"
586
+ onChange={(event) => setTo(event.target.value)}
587
+ />
588
+ <div className={styles.dialogActions}>
589
+ <button type="button" className={styles.linkButton} onClick={onClose}>
590
+ Cancel
591
+ </button>
592
+ <button type="button" className={styles.buttonPrimary} disabled={busy} onClick={() => onSend(to.trim())}>
593
+ {busy ? 'Sending…' : 'Send test'}
594
+ </button>
595
+ </div>
596
+ </div>
597
+ </StandardModal>
598
+ );
599
+ };
600
+
601
+ const Field = ({ label, hint, children }) => (
602
+ <label className={styles.field}>
603
+ <span className={styles.fieldLabel}>{label}</span>
604
+ {children}
605
+ {hint && <span className={styles.hint}>{hint}</span>}
606
+ </label>
607
+ );
608
+
609
+ const pick = (campaign) =>
610
+ EDITABLE_FIELDS.reduce((out, key) => ({ ...out, [key]: campaign[key] ?? (key === 'content' ? [] : '') }), {});
611
+
612
+ const listName = (lists, id) => lists.find((list) => list.id === id)?.name;
613
+
614
+ export default CampaignEditor;