@visns-studio/visns-components 6.32.3 → 6.34.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +483 -0
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -2
- package/src/components/Field.jsx +32 -0
- package/src/components/Form.jsx +39 -2
- package/src/components/Navigation.jsx +274 -15
- package/src/components/Notification.jsx +13 -2
- package/src/components/TableFilter.jsx +14 -2
- package/src/components/auth/Login.jsx +11 -4
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- package/src/components/auth/Reset.jsx +1 -1
- package/src/components/auth/TwoFactorAuth.jsx +1 -1
- package/src/components/auth/Verify.jsx +2 -2
- package/src/components/auth/authEndpoints.js +43 -0
- package/src/components/auth/passkeyClient.js +102 -0
- package/src/components/auth/passkeyPrompt.js +408 -0
- package/src/components/auth/profileLayout.js +45 -0
- package/src/components/controls/DataGridSearch.jsx +16 -2
- package/src/components/controls/DataGridSortSheet.jsx +2 -0
- package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
- package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
- package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
- package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
- package/src/components/emailCampaigns/EmailLists.jsx +505 -0
- package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
- package/src/components/generic/ActionButtons.jsx +1 -1
- package/src/components/generic/GenericAuth.jsx +197 -21
- package/src/components/generic/GenericDetail.jsx +95 -3
- package/src/components/generic/GenericMain.jsx +7 -0
- package/src/components/generic/StandardModal.jsx +71 -158
- package/src/components/sms/SmsCampaigns.jsx +2517 -0
- package/src/components/sms/SmsInbox.jsx +20 -5
- package/src/components/sms/SmsLineSettings.jsx +2 -5
- package/src/components/sms/SmsThreadPanel.jsx +20 -0
- package/src/components/sms/smsEndpoints.js +23 -0
- package/src/components/styles/DataGrid.module.scss +22 -0
- package/src/components/styles/EmailCampaigns.module.scss +922 -0
- package/src/components/styles/Form.module.scss +100 -2
- package/src/components/styles/Navigation.module.scss +322 -5
- package/src/components/styles/Notification.module.scss +140 -8
- package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
- package/src/components/styles/Profile.module.scss +157 -0
- package/src/components/styles/Sms.module.scss +484 -4
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/Vault.module.scss +5 -3
- package/src/components/styles/global.css +38 -7
- package/src/components/utils/navCollapsed.js +201 -0
- package/src/components/utils/rowActionSettings.js +76 -0
- package/src/components/utils/usePasskeysEnabled.js +84 -0
- package/src/components/utils/useShellLayout.js +131 -0
- package/src/index.js +107 -1
- package/src/utils/rememberTab.js +70 -0
|
@@ -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} <{draft.from_email || settings?.from_email}>
|
|
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;
|