@visns-studio/visns-components 6.34.0 → 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.
@@ -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: '' };