@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,505 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { FileUp, RefreshCw, Trash2, Users } from 'lucide-react';
|
|
3
|
+
import { toast } from 'react-toastify';
|
|
4
|
+
|
|
5
|
+
import StandardModal from '../generic/StandardModal';
|
|
6
|
+
import { showConfirmDialog } from '../generic/ConfirmationDialog';
|
|
7
|
+
import useDebouncedValue from '../vault/useDebouncedValue';
|
|
8
|
+
import { cellText, guessColumn, readSheetRows, readSpreadsheet } from '../sms/SmsCampaigns';
|
|
9
|
+
import styles from '../styles/EmailCampaigns.module.scss';
|
|
10
|
+
import { api, formatWhen } from './emailCampaignApi';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Who campaigns go to.
|
|
14
|
+
*
|
|
15
|
+
* A list is either built from the host's contacts (pick clients, tick options,
|
|
16
|
+
* and it is re-read on Refresh so a client's new contact is on the next
|
|
17
|
+
* campaign), or imported from a spreadsheet. Either way it is copied to Resend
|
|
18
|
+
* in the background, and the row says how far that has got.
|
|
19
|
+
*/
|
|
20
|
+
const EMAIL_HINTS = [/^e-?mail/i, /email/i];
|
|
21
|
+
const FIRST_HINTS = [/^first/i, /given/i];
|
|
22
|
+
const LAST_HINTS = [/^last/i, /surname/i, /family/i];
|
|
23
|
+
const COMPANY_HINTS = [/company/i, /organi[sz]ation/i, /business/i, /client/i];
|
|
24
|
+
|
|
25
|
+
const EmailLists = ({ lists, routes, canManage, settings, onChanged, openListId, onOpenList }) => {
|
|
26
|
+
const [creating, setCreating] = useState(false);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={styles.tabBody}>
|
|
30
|
+
<div className={styles.toolbar}>
|
|
31
|
+
<p className={styles.quiet}>
|
|
32
|
+
Lists are copied to Resend in the background, a few hundred people a minute.
|
|
33
|
+
</p>
|
|
34
|
+
{canManage && (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
className={styles.buttonPrimary}
|
|
38
|
+
title="Build a list from your contacts, or import one"
|
|
39
|
+
onClick={() => setCreating(true)}
|
|
40
|
+
>
|
|
41
|
+
<Users size={14} aria-hidden="true" />
|
|
42
|
+
New list
|
|
43
|
+
</button>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{lists.length === 0 ? (
|
|
48
|
+
<p className={styles.state}>No lists yet. A campaign needs a list to go to.</p>
|
|
49
|
+
) : (
|
|
50
|
+
<table className={styles.table}>
|
|
51
|
+
<thead>
|
|
52
|
+
<tr>
|
|
53
|
+
<th>List</th>
|
|
54
|
+
<th>Kind</th>
|
|
55
|
+
<th className={styles.num}>People</th>
|
|
56
|
+
<th className={styles.num}>Unsubscribed</th>
|
|
57
|
+
<th>Copied to Resend</th>
|
|
58
|
+
</tr>
|
|
59
|
+
</thead>
|
|
60
|
+
<tbody>
|
|
61
|
+
{lists.map((list) => (
|
|
62
|
+
<tr key={list.id} className={styles.clickRow} onClick={() => onOpenList(list.id)}>
|
|
63
|
+
<td>
|
|
64
|
+
<button type="button" className={styles.rowLink} title={`Open ${list.name}`}>
|
|
65
|
+
{list.name}
|
|
66
|
+
</button>
|
|
67
|
+
{list.description && <div className={styles.quiet}>{list.description}</div>}
|
|
68
|
+
</td>
|
|
69
|
+
<td>{list.kind === 'crm' ? 'From contacts' : 'Imported'}</td>
|
|
70
|
+
<td className={styles.num}>{list.members}</td>
|
|
71
|
+
<td className={styles.num}>{list.unsubscribed}</td>
|
|
72
|
+
<td>
|
|
73
|
+
<SyncState list={list} />
|
|
74
|
+
</td>
|
|
75
|
+
</tr>
|
|
76
|
+
))}
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{creating && (
|
|
82
|
+
<NewListDialog
|
|
83
|
+
routes={routes}
|
|
84
|
+
settings={settings}
|
|
85
|
+
onClose={() => setCreating(false)}
|
|
86
|
+
onCreated={(list) => {
|
|
87
|
+
setCreating(false);
|
|
88
|
+
onChanged();
|
|
89
|
+
onOpenList(list.id);
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{openListId && (
|
|
95
|
+
<ListDetail
|
|
96
|
+
id={openListId}
|
|
97
|
+
routes={routes}
|
|
98
|
+
canManage={canManage}
|
|
99
|
+
onClose={() => onOpenList(null)}
|
|
100
|
+
onChanged={onChanged}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const SyncState = ({ list }) => {
|
|
108
|
+
if (list.sync_error) {
|
|
109
|
+
return <span className={styles.bad}>{list.sync_error}</span>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (list.syncing) {
|
|
113
|
+
return <span className={styles.warn}>In progress ({list.pending} to go)</span>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<span>
|
|
118
|
+
<span className={styles.good}>Up to date</span>
|
|
119
|
+
{list.failed > 0 ? (
|
|
120
|
+
<span className={styles.warn} title="Resend would not take these addresses; open the list to see why">
|
|
121
|
+
{' '}· {list.failed} refused
|
|
122
|
+
</span>
|
|
123
|
+
) : null}
|
|
124
|
+
{list.synced_at ? <span className={styles.quiet}> · {formatWhen(list.synced_at)}</span> : null}
|
|
125
|
+
</span>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const NewListDialog = ({ routes, settings, onClose, onCreated }) => {
|
|
130
|
+
const source = settings?.source;
|
|
131
|
+
const [kind, setKind] = useState(source ? 'crm' : 'import');
|
|
132
|
+
const [name, setName] = useState('');
|
|
133
|
+
const [description, setDescription] = useState('');
|
|
134
|
+
const [groups, setGroups] = useState([]);
|
|
135
|
+
const [options, setOptions] = useState({});
|
|
136
|
+
const [sheet, setSheet] = useState(null);
|
|
137
|
+
const [columns, setColumns] = useState({ email: -1, first: -1, last: -1, company: -1 });
|
|
138
|
+
const [busy, setBusy] = useState(false);
|
|
139
|
+
const [error, setError] = useState('');
|
|
140
|
+
|
|
141
|
+
const rows = useMemo(() => {
|
|
142
|
+
if (!sheet || columns.email < 0) return [];
|
|
143
|
+
|
|
144
|
+
return sheet.records.map((record) => ({
|
|
145
|
+
email: cellText(record[columns.email]),
|
|
146
|
+
first_name: columns.first >= 0 ? cellText(record[columns.first]) : '',
|
|
147
|
+
last_name: columns.last >= 0 ? cellText(record[columns.last]) : '',
|
|
148
|
+
company: columns.company >= 0 ? cellText(record[columns.company]) : '',
|
|
149
|
+
}));
|
|
150
|
+
}, [sheet, columns]);
|
|
151
|
+
|
|
152
|
+
const readFile = async (file) => {
|
|
153
|
+
if (!file) return;
|
|
154
|
+
|
|
155
|
+
setError('');
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const read = readSheetRows(await readSpreadsheet(file));
|
|
159
|
+
setSheet(read);
|
|
160
|
+
setColumns({
|
|
161
|
+
email: guessColumn(read.headers, EMAIL_HINTS),
|
|
162
|
+
first: guessColumn(read.headers, FIRST_HINTS),
|
|
163
|
+
last: guessColumn(read.headers, LAST_HINTS),
|
|
164
|
+
company: guessColumn(read.headers, COMPANY_HINTS),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!name) setName(file.name.replace(/\.[a-z0-9]+$/i, ''));
|
|
168
|
+
} catch (failure) {
|
|
169
|
+
setError('That file could not be read. A .csv or .xlsx exported from a spreadsheet is what this expects.');
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const ready = name.trim() !== '' && (kind === 'crm' || rows.length > 0) && !busy;
|
|
174
|
+
|
|
175
|
+
const create = async () => {
|
|
176
|
+
setBusy(true);
|
|
177
|
+
setError('');
|
|
178
|
+
|
|
179
|
+
const result = await api(
|
|
180
|
+
routes.lists,
|
|
181
|
+
'POST',
|
|
182
|
+
kind === 'crm'
|
|
183
|
+
? { name, description, kind, groups: groups.map((group) => group.id), options }
|
|
184
|
+
: { name, description, kind, rows }
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
setBusy(false);
|
|
188
|
+
|
|
189
|
+
if (result.ok && result.data?.list) {
|
|
190
|
+
toast.success(result.data.message || 'List created.');
|
|
191
|
+
onCreated(result.data.list);
|
|
192
|
+
} else {
|
|
193
|
+
setError(result.message || 'The list could not be created.');
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<StandardModal isOpen onClose={onClose} title="New list" size="medium" showHeader>
|
|
199
|
+
<div className={styles.dialog}>
|
|
200
|
+
{error && <p className={styles.fieldError}>{error}</p>}
|
|
201
|
+
|
|
202
|
+
{source && (
|
|
203
|
+
<div className={styles.segmented} role="group" aria-label="Where the people come from">
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
className={kind === 'crm' ? styles.segmentOn : styles.segment}
|
|
207
|
+
aria-pressed={kind === 'crm'}
|
|
208
|
+
onClick={() => setKind('crm')}
|
|
209
|
+
>
|
|
210
|
+
From your {source.noun?.plural || 'contacts'}
|
|
211
|
+
</button>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
className={kind === 'import' ? styles.segmentOn : styles.segment}
|
|
215
|
+
aria-pressed={kind === 'import'}
|
|
216
|
+
onClick={() => setKind('import')}
|
|
217
|
+
>
|
|
218
|
+
Import a spreadsheet
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
<label className={styles.field}>
|
|
224
|
+
<span className={styles.fieldLabel}>Name</span>
|
|
225
|
+
<input className={styles.input} value={name} maxLength={191} onChange={(event) => setName(event.target.value)} />
|
|
226
|
+
</label>
|
|
227
|
+
|
|
228
|
+
<label className={styles.field}>
|
|
229
|
+
<span className={styles.fieldLabel}>Description (optional)</span>
|
|
230
|
+
<input
|
|
231
|
+
className={styles.input}
|
|
232
|
+
value={description}
|
|
233
|
+
maxLength={500}
|
|
234
|
+
onChange={(event) => setDescription(event.target.value)}
|
|
235
|
+
/>
|
|
236
|
+
</label>
|
|
237
|
+
|
|
238
|
+
{kind === 'crm' ? (
|
|
239
|
+
<>
|
|
240
|
+
<GroupPicker
|
|
241
|
+
routes={routes}
|
|
242
|
+
noun={source?.noun}
|
|
243
|
+
value={groups}
|
|
244
|
+
onChange={setGroups}
|
|
245
|
+
/>
|
|
246
|
+
{Object.entries(source?.options || {}).map(([key, label]) => (
|
|
247
|
+
<label key={key} className={styles.check}>
|
|
248
|
+
<input
|
|
249
|
+
type="checkbox"
|
|
250
|
+
checked={Boolean(options[key])}
|
|
251
|
+
onChange={(event) => setOptions({ ...options, [key]: event.target.checked })}
|
|
252
|
+
/>
|
|
253
|
+
{label}
|
|
254
|
+
</label>
|
|
255
|
+
))}
|
|
256
|
+
<p className={styles.hint}>
|
|
257
|
+
{groups.length === 0
|
|
258
|
+
? `No ${source?.noun?.plural || 'groups'} picked means every contact, of every ${source?.noun?.singular || 'group'}, with an email address.`
|
|
259
|
+
: 'The list is re-read from these whenever you refresh it, so new contacts join automatically.'}
|
|
260
|
+
</p>
|
|
261
|
+
</>
|
|
262
|
+
) : (
|
|
263
|
+
<>
|
|
264
|
+
<label className={styles.drop}>
|
|
265
|
+
<FileUp size={18} aria-hidden="true" />
|
|
266
|
+
<span>{sheet ? `${sheet.records.length} rows read. Choose another file to replace them.` : 'Choose a .csv or .xlsx file. It is read in this browser.'}</span>
|
|
267
|
+
<input type="file" accept=".csv,.xlsx,.xls" hidden onChange={(event) => readFile(event.target.files?.[0])} />
|
|
268
|
+
</label>
|
|
269
|
+
|
|
270
|
+
{sheet && (
|
|
271
|
+
<div className={styles.mapping}>
|
|
272
|
+
{[
|
|
273
|
+
['email', 'Email (required)'],
|
|
274
|
+
['first', 'First name'],
|
|
275
|
+
['last', 'Last name'],
|
|
276
|
+
['company', 'Company'],
|
|
277
|
+
].map(([key, label]) => (
|
|
278
|
+
<label key={key} className={styles.field}>
|
|
279
|
+
<span className={styles.fieldLabel}>{label}</span>
|
|
280
|
+
<select
|
|
281
|
+
className={styles.select}
|
|
282
|
+
value={columns[key]}
|
|
283
|
+
onChange={(event) => setColumns({ ...columns, [key]: Number(event.target.value) })}
|
|
284
|
+
>
|
|
285
|
+
<option value={-1}>Not in the file</option>
|
|
286
|
+
{sheet.headers.map((header, index) => (
|
|
287
|
+
<option key={header} value={index}>
|
|
288
|
+
{header}
|
|
289
|
+
</option>
|
|
290
|
+
))}
|
|
291
|
+
</select>
|
|
292
|
+
</label>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{rows.length > 0 && (
|
|
298
|
+
<p className={styles.hint}>
|
|
299
|
+
{rows.length} people, starting with {rows.slice(0, 3).map((row) => row.email).join(', ')}. Rows without a valid address are skipped and listed after.
|
|
300
|
+
</p>
|
|
301
|
+
)}
|
|
302
|
+
</>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
<div className={styles.dialogActions}>
|
|
306
|
+
<button type="button" className={styles.linkButton} onClick={onClose}>
|
|
307
|
+
Cancel
|
|
308
|
+
</button>
|
|
309
|
+
<button type="button" className={styles.buttonPrimary} disabled={!ready} onClick={create}>
|
|
310
|
+
{busy ? 'Building the list…' : 'Create list'}
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</StandardModal>
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** Search the host's groups (clients) and collect chips. */
|
|
319
|
+
const GroupPicker = ({ routes, noun, value, onChange }) => {
|
|
320
|
+
const [search, setSearch] = useState('');
|
|
321
|
+
const [results, setResults] = useState([]);
|
|
322
|
+
const query = useDebouncedValue(search, 250);
|
|
323
|
+
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
let live = true;
|
|
326
|
+
|
|
327
|
+
api(routes.groups, 'GET', { search: query }).then((result) => {
|
|
328
|
+
if (live) setResults(Array.isArray(result.data?.groups) ? result.data.groups : []);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return () => {
|
|
332
|
+
live = false;
|
|
333
|
+
};
|
|
334
|
+
}, [routes, query]);
|
|
335
|
+
|
|
336
|
+
const chosen = new Set(value.map((group) => group.id));
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div className={styles.field}>
|
|
340
|
+
<span className={styles.fieldLabel}>{noun?.plural ? noun.plural[0].toUpperCase() + noun.plural.slice(1) : 'Groups'}</span>
|
|
341
|
+
|
|
342
|
+
{value.length > 0 && (
|
|
343
|
+
<div className={styles.chips}>
|
|
344
|
+
{value.map((group) => (
|
|
345
|
+
<button
|
|
346
|
+
key={group.id}
|
|
347
|
+
type="button"
|
|
348
|
+
className={styles.chip}
|
|
349
|
+
title={`Take ${group.label} off the list`}
|
|
350
|
+
onClick={() => onChange(value.filter((entry) => entry.id !== group.id))}
|
|
351
|
+
>
|
|
352
|
+
{group.label} ×
|
|
353
|
+
</button>
|
|
354
|
+
))}
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
<input
|
|
359
|
+
className={styles.input}
|
|
360
|
+
value={search}
|
|
361
|
+
placeholder={`Search ${noun?.plural || 'groups'}…`}
|
|
362
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
363
|
+
/>
|
|
364
|
+
|
|
365
|
+
<div className={styles.results}>
|
|
366
|
+
{results
|
|
367
|
+
.filter((group) => !chosen.has(group.id))
|
|
368
|
+
.slice(0, 8)
|
|
369
|
+
.map((group) => (
|
|
370
|
+
<button
|
|
371
|
+
key={group.id}
|
|
372
|
+
type="button"
|
|
373
|
+
className={styles.result}
|
|
374
|
+
onClick={() => onChange([...value, group])}
|
|
375
|
+
>
|
|
376
|
+
+ {group.label}
|
|
377
|
+
</button>
|
|
378
|
+
))}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const ListDetail = ({ id, routes, canManage, onClose, onChanged }) => {
|
|
385
|
+
const [data, setData] = useState(null);
|
|
386
|
+
const [search, setSearch] = useState('');
|
|
387
|
+
const [busy, setBusy] = useState(false);
|
|
388
|
+
const query = useDebouncedValue(search, 250);
|
|
389
|
+
|
|
390
|
+
const load = async () => {
|
|
391
|
+
const result = await api(routes.list(id), 'GET', { search: query });
|
|
392
|
+
|
|
393
|
+
if (result.ok) setData(result.data);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
load();
|
|
398
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
399
|
+
}, [id, query]);
|
|
400
|
+
|
|
401
|
+
const refresh = async () => {
|
|
402
|
+
setBusy(true);
|
|
403
|
+
const result = await api(routes.refreshList(id), 'POST', {});
|
|
404
|
+
setBusy(false);
|
|
405
|
+
result.ok ? toast.success(result.data?.message || 'Refreshed.') : toast.error(result.message);
|
|
406
|
+
onChanged();
|
|
407
|
+
load();
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const remove = () =>
|
|
411
|
+
showConfirmDialog({
|
|
412
|
+
title: 'Delete this list?',
|
|
413
|
+
message: 'The list goes, here and in Resend. Campaigns already sent to it keep their reports.',
|
|
414
|
+
confirmLabel: 'Delete',
|
|
415
|
+
cancelLabel: 'Keep it',
|
|
416
|
+
onConfirm: async () => {
|
|
417
|
+
const result = await api(routes.list(id), 'DELETE');
|
|
418
|
+
|
|
419
|
+
if (result.ok) {
|
|
420
|
+
onChanged();
|
|
421
|
+
onClose();
|
|
422
|
+
} else {
|
|
423
|
+
toast.error(result.message || 'It could not be deleted.');
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const list = data?.list;
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<StandardModal isOpen onClose={onClose} title={list?.name || 'List'} size="large" showHeader variant="sheet">
|
|
432
|
+
<div className={styles.dialog}>
|
|
433
|
+
{!list ? (
|
|
434
|
+
<p className={styles.state}>Loading…</p>
|
|
435
|
+
) : (
|
|
436
|
+
<>
|
|
437
|
+
<div className={styles.toolbar}>
|
|
438
|
+
<span className={styles.quiet}>
|
|
439
|
+
{list.members} people · {list.unsubscribed} unsubscribed · <SyncState list={list} />
|
|
440
|
+
</span>
|
|
441
|
+
{canManage && (
|
|
442
|
+
<span className={styles.toolbarRight}>
|
|
443
|
+
{list.kind === 'crm' && (
|
|
444
|
+
<button type="button" className={styles.button} disabled={busy} onClick={refresh} title="Re-read the list from your contacts">
|
|
445
|
+
<RefreshCw size={14} aria-hidden="true" />
|
|
446
|
+
Refresh
|
|
447
|
+
</button>
|
|
448
|
+
)}
|
|
449
|
+
<button type="button" className={`${styles.button} ${styles.dangerButton}`} onClick={remove} title="Delete this list">
|
|
450
|
+
<Trash2 size={14} aria-hidden="true" />
|
|
451
|
+
Delete
|
|
452
|
+
</button>
|
|
453
|
+
</span>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
{list.kind === 'crm' && Object.keys(data.list.group_labels || {}).length > 0 && (
|
|
458
|
+
<p className={styles.hint}>From: {Object.values(data.list.group_labels).join(', ')}</p>
|
|
459
|
+
)}
|
|
460
|
+
|
|
461
|
+
<input
|
|
462
|
+
className={styles.input}
|
|
463
|
+
value={search}
|
|
464
|
+
placeholder="Search by name, email or company…"
|
|
465
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
466
|
+
/>
|
|
467
|
+
|
|
468
|
+
<table className={styles.table}>
|
|
469
|
+
<thead>
|
|
470
|
+
<tr>
|
|
471
|
+
<th>Email</th>
|
|
472
|
+
<th>Name</th>
|
|
473
|
+
<th>Company</th>
|
|
474
|
+
<th>State</th>
|
|
475
|
+
</tr>
|
|
476
|
+
</thead>
|
|
477
|
+
<tbody>
|
|
478
|
+
{(data.members || []).map((member) => (
|
|
479
|
+
<tr key={member.id}>
|
|
480
|
+
<td>{member.email}</td>
|
|
481
|
+
<td>{member.name || '-'}</td>
|
|
482
|
+
<td>{member.company || '-'}</td>
|
|
483
|
+
<td>
|
|
484
|
+
{member.unsubscribed_at ? (
|
|
485
|
+
<span className={styles.bad}>Unsubscribed</span>
|
|
486
|
+
) : member.status === 'failed' ? (
|
|
487
|
+
<span className={styles.bad} title={member.error || ''}>Refused by Resend</span>
|
|
488
|
+
) : member.status === 'pending' ? (
|
|
489
|
+
<span className={styles.warn}>Copying</span>
|
|
490
|
+
) : (
|
|
491
|
+
<span className={styles.good}>Ready</span>
|
|
492
|
+
)}
|
|
493
|
+
</td>
|
|
494
|
+
</tr>
|
|
495
|
+
))}
|
|
496
|
+
</tbody>
|
|
497
|
+
</table>
|
|
498
|
+
</>
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
</StandardModal>
|
|
502
|
+
);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
export default EmailLists;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email campaign module's endpoints, and one way to call them.
|
|
3
|
+
*
|
|
4
|
+
* NOT CustomFetch, for two reasons that both matter here: it caches GETs, and
|
|
5
|
+
* the numbers on this screen (sync progress, open rates) are the thing that
|
|
6
|
+
* must not be two minutes old; and it hands a refusal back as one string, where
|
|
7
|
+
* a send refusal carries a LIST of blockers the screen prints in full.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const resolveEmailCampaignEndpoints = (base = '/ajax/email-campaigns') => {
|
|
11
|
+
const root = String(base).replace(/\/+$/, '');
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
index: root,
|
|
15
|
+
groups: `${root}/groups`,
|
|
16
|
+
templates: `${root}/templates`,
|
|
17
|
+
template: (id) => `${root}/templates/${id}`,
|
|
18
|
+
images: `${root}/images`,
|
|
19
|
+
campaigns: `${root}/campaigns`,
|
|
20
|
+
campaign: (id) => `${root}/campaigns/${id}`,
|
|
21
|
+
preview: (id) => `${root}/campaigns/${id}/preview`,
|
|
22
|
+
test: (id) => `${root}/campaigns/${id}/test`,
|
|
23
|
+
send: (id) => `${root}/campaigns/${id}/send`,
|
|
24
|
+
cancel: (id) => `${root}/campaigns/${id}/cancel`,
|
|
25
|
+
duplicate: (id) => `${root}/campaigns/${id}/duplicate`,
|
|
26
|
+
lists: `${root}/lists`,
|
|
27
|
+
list: (id) => `${root}/lists/${id}`,
|
|
28
|
+
refreshList: (id) => `${root}/lists/${id}/refresh`,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const csrf = () =>
|
|
33
|
+
typeof document === 'undefined'
|
|
34
|
+
? null
|
|
35
|
+
: document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @returns {Promise<{ok: boolean, status: number, data: object, message: string}>}
|
|
39
|
+
* never rejects: a network failure is `{ok: false, status: 0}`.
|
|
40
|
+
*/
|
|
41
|
+
export const api = async (url, method = 'GET', body = null) => {
|
|
42
|
+
const headers = { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
|
|
43
|
+
const token = csrf();
|
|
44
|
+
|
|
45
|
+
if (token) {
|
|
46
|
+
headers['X-CSRF-TOKEN'] = token;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const options = { method, headers, credentials: 'same-origin' };
|
|
50
|
+
let target = url;
|
|
51
|
+
|
|
52
|
+
if (body instanceof FormData) {
|
|
53
|
+
options.body = body;
|
|
54
|
+
} else if (method === 'GET' && body) {
|
|
55
|
+
const query = new URLSearchParams(
|
|
56
|
+
Object.entries(body).filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
57
|
+
).toString();
|
|
58
|
+
target = query ? `${url}${url.includes('?') ? '&' : '?'}${query}` : url;
|
|
59
|
+
} else if (body !== null) {
|
|
60
|
+
headers['Content-Type'] = 'application/json';
|
|
61
|
+
options.body = JSON.stringify(body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(target, options);
|
|
66
|
+
let data = {};
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
data = await response.json();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
data = {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const firstError =
|
|
75
|
+
data?.errors && typeof data.errors === 'object'
|
|
76
|
+
? Object.values(data.errors).flat()[0]
|
|
77
|
+
: null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ok: response.ok && data?.ok !== false,
|
|
81
|
+
status: response.status,
|
|
82
|
+
data,
|
|
83
|
+
message: String(data?.message || firstError || (response.ok ? '' : `Request failed (${response.status}).`)),
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { ok: false, status: 0, data: {}, message: 'The server could not be reached. Check the connection and try again.' };
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** "18 Sep 2026, 1:09pm", from an ISO string, in the viewer's own zone. */
|
|
91
|
+
export const formatWhen = (iso) => {
|
|
92
|
+
if (!iso) return '';
|
|
93
|
+
|
|
94
|
+
const date = new Date(iso);
|
|
95
|
+
|
|
96
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
97
|
+
|
|
98
|
+
return date.toLocaleString('en-AU', {
|
|
99
|
+
day: 'numeric',
|
|
100
|
+
month: 'short',
|
|
101
|
+
year: 'numeric',
|
|
102
|
+
hour: 'numeric',
|
|
103
|
+
minute: '2-digit',
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const STATUS_LABELS = {
|
|
108
|
+
draft: { label: 'Draft', tone: 'quiet' },
|
|
109
|
+
scheduled: { label: 'Scheduled', tone: 'warn' },
|
|
110
|
+
sending: { label: 'Sending', tone: 'warn' },
|
|
111
|
+
sent: { label: 'Sent', tone: 'good' },
|
|
112
|
+
cancelled: { label: 'Cancelled', tone: 'quiet' },
|
|
113
|
+
failed: { label: 'Failed', tone: 'bad' },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const BLOCK_TYPES = [
|
|
117
|
+
{ type: 'heading', label: 'Heading' },
|
|
118
|
+
{ type: 'text', label: 'Text' },
|
|
119
|
+
{ type: 'image', label: 'Image' },
|
|
120
|
+
{ type: 'button', label: 'Button' },
|
|
121
|
+
{ type: 'columns', label: 'Two columns' },
|
|
122
|
+
{ type: 'divider', label: 'Divider' },
|
|
123
|
+
{ type: 'spacer', label: 'Space' },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
export const newBlock = (type) =>
|
|
127
|
+
({
|
|
128
|
+
heading: { type, level: 1, text: '', align: 'left' },
|
|
129
|
+
text: { type, html: '' },
|
|
130
|
+
image: { type, url: '', alt: '', href: '', width: 100, align: 'center' },
|
|
131
|
+
button: { type, label: 'Find out more', url: '', align: 'left' },
|
|
132
|
+
columns: { type, left: '', right: '' },
|
|
133
|
+
divider: { type },
|
|
134
|
+
spacer: { type, height: 24 },
|
|
135
|
+
})[type] || { type: 'text', html: '' };
|
|
@@ -10,7 +10,7 @@ function ActionButtons({ actions, onActionClick }) {
|
|
|
10
10
|
key={`action-${action.id}`}
|
|
11
11
|
className={styles.actionButton}
|
|
12
12
|
onClick={() => onActionClick(action)}
|
|
13
|
-
title={action.label}
|
|
13
|
+
title={action.tooltip || action.label}
|
|
14
14
|
>
|
|
15
15
|
{action.label}
|
|
16
16
|
</button>
|