@visns-studio/visns-components 6.32.2 → 6.34.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.
Files changed (41) hide show
  1. package/README.md +504 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +353 -43
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +9 -2
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/authEndpoints.js +43 -0
  13. package/src/components/auth/passkeyClient.js +102 -0
  14. package/src/components/auth/passkeyPrompt.js +408 -0
  15. package/src/components/auth/profileLayout.js +45 -0
  16. package/src/components/controls/DataGridSearch.jsx +16 -2
  17. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  18. package/src/components/generic/ActionButtons.jsx +1 -1
  19. package/src/components/generic/GenericAuth.jsx +197 -21
  20. package/src/components/generic/GenericDetail.jsx +95 -3
  21. package/src/components/generic/GenericMain.jsx +7 -0
  22. package/src/components/generic/StandardModal.jsx +71 -158
  23. package/src/components/sms/SmsCampaigns.jsx +2487 -0
  24. package/src/components/sms/SmsInbox.jsx +18 -0
  25. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  26. package/src/components/sms/smsEndpoints.js +23 -0
  27. package/src/components/styles/DataGrid.module.scss +22 -0
  28. package/src/components/styles/Form.module.scss +100 -2
  29. package/src/components/styles/Navigation.module.scss +354 -15
  30. package/src/components/styles/Notification.module.scss +140 -8
  31. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  32. package/src/components/styles/Profile.module.scss +157 -0
  33. package/src/components/styles/Sms.module.scss +451 -0
  34. package/src/components/styles/StandardModal.module.scss +273 -0
  35. package/src/components/styles/global.css +24 -5
  36. package/src/components/utils/navCollapsed.js +201 -0
  37. package/src/components/utils/rowActionSettings.js +76 -0
  38. package/src/components/utils/usePasskeysEnabled.js +84 -0
  39. package/src/components/utils/useShellLayout.js +131 -0
  40. package/src/index.js +103 -1
  41. package/src/utils/rememberTab.js +70 -0
@@ -0,0 +1,2487 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ ArrowLeft,
4
+ ArrowRight,
5
+ Ban,
6
+ CircleSlash,
7
+ ExternalLink,
8
+ FileSpreadsheet,
9
+ Loader2,
10
+ Megaphone,
11
+ Pause,
12
+ Play,
13
+ Plus,
14
+ RotateCcw,
15
+ Search,
16
+ Send,
17
+ Trash2,
18
+ TriangleAlert,
19
+ Upload,
20
+ X,
21
+ } from 'lucide-react';
22
+ import { useDropzone } from 'react-dropzone';
23
+ import { toast } from 'react-toastify';
24
+
25
+ import CustomFetch from '../Fetch';
26
+ import StandardModal from '../generic/StandardModal';
27
+ import showConfirmDialog from '../generic/ConfirmationDialog';
28
+ import styles from '../styles/Sms.module.scss';
29
+ import withVaultNavigation from '../vault/vaultNavigation';
30
+ import useDebouncedValue from '../vault/useDebouncedValue';
31
+ import { resolveSmsEndpoints } from './smsEndpoints';
32
+ import {
33
+ initialsFor,
34
+ looksLikeNumber,
35
+ normaliseNumberForDisplay,
36
+ relativeTime,
37
+ segmentCount,
38
+ } from './smsHelpers';
39
+
40
+ /**
41
+ * What the screen falls back to before `GET campaigns` has answered.
42
+ *
43
+ * Every one of these is the server's to decide and the server's answer always
44
+ * wins — they exist so the first paint of the wizard has a body limit and a
45
+ * cap to obey rather than `undefined`, which would let somebody type a message
46
+ * the send then refuses. `max_body_length` is six GSM-7 parts, which is what
47
+ * `MAX_SEGMENTS` already calls the most a composer will send in one go.
48
+ */
49
+ export const DEFAULT_CAMPAIGN_SETTINGS = {
50
+ per_minute: 20,
51
+ max_recipients: 1000,
52
+ footer: '',
53
+ footer_required: false,
54
+ max_body_length: 918,
55
+ };
56
+
57
+ /**
58
+ * A campaign's status, and the tone it is drawn in.
59
+ *
60
+ * `sending` is deliberately NOT warn. A campaign that is doing exactly what it
61
+ * was told to do is not a warning, and a page of amber chips on an ordinary
62
+ * Tuesday teaches people to stop reading them — so it gets the brand tint,
63
+ * which says "live" without saying "look at this". `paused` is the amber one:
64
+ * somebody stopped it and nothing is going out until somebody starts it again.
65
+ */
66
+ export const CAMPAIGN_STATUSES = {
67
+ draft: { label: 'Draft', tone: 'muted' },
68
+ sending: { label: 'Sending', tone: 'live' },
69
+ paused: { label: 'Paused', tone: 'warn' },
70
+ completed: { label: 'Completed', tone: 'ok' },
71
+ cancelled: { label: 'Cancelled', tone: 'muted' },
72
+ };
73
+
74
+ /** A recipient's own status, in the same vocabulary. */
75
+ export const RECIPIENT_STATUSES = {
76
+ pending: { label: 'Pending', tone: 'muted' },
77
+ sent: { label: 'Sent', tone: 'ok' },
78
+ failed: { label: 'Failed', tone: 'bad' },
79
+ skipped: { label: 'Skipped', tone: 'warn' },
80
+ };
81
+
82
+ const RECIPIENT_FILTERS = [
83
+ { id: '', label: 'All' },
84
+ { id: 'pending', label: 'Pending' },
85
+ { id: 'sent', label: 'Sent' },
86
+ { id: 'failed', label: 'Failed' },
87
+ { id: 'skipped', label: 'Skipped' },
88
+ ];
89
+
90
+ const TONE_CLASSES = {
91
+ ok: 'pillOk',
92
+ warn: 'pillWarn',
93
+ bad: 'pillBad',
94
+ muted: 'pillMuted',
95
+ live: 'campaignPillLive',
96
+ };
97
+
98
+ /** Laravel's error bag arrives joined with `<br />`; a sentence is text. */
99
+ const asPlainText = (message) =>
100
+ String(message ?? '')
101
+ .replace(/<br\s*\/?>/gi, ' ')
102
+ .replace(/\s+/g, ' ')
103
+ .trim();
104
+
105
+ /**
106
+ * The `?campaign=` a deep link carries, if any.
107
+ *
108
+ * Local rather than beside `threadIdFromSearch` in smsHelpers, because that
109
+ * one is a contract between two components — the header popover builds those
110
+ * links and the inbox reads them — while this parameter is written and read by
111
+ * this page alone. Same shape, on purpose: a link into a campaign and a link
112
+ * into a conversation should not need two different explanations.
113
+ */
114
+ export const campaignIdFromSearch = (search) => {
115
+ const raw = String(search ?? '');
116
+
117
+ if (raw === '') return null;
118
+
119
+ const query = raw.startsWith('?') ? raw.slice(1) : raw;
120
+ const match = query.match(/(?:^|&)campaign=([^&#]*)/);
121
+
122
+ if (!match) return null;
123
+
124
+ const value = decodeURIComponent(match[1]);
125
+
126
+ return value === '' ? null : value;
127
+ };
128
+
129
+ /**
130
+ * Open a recipient's conversation in the inbox, in this tab.
131
+ *
132
+ * The row's "Open conversation" was a plain `<a target="_blank">`, and it was
133
+ * the ONLY navigation in the messaging module that left the router out of it —
134
+ * `SmsInboxBadge` reaches the very same `?thread=` URL with `navigate()` and
135
+ * has always worked. A new-tab hand-off is not something this page can rely on:
136
+ * it is the browser's to honour or drop, and an installed/standalone window or
137
+ * a blocked target leaves the click doing nothing at all, with no error
138
+ * anywhere. The inbox has read `?thread=` on mount since it was written, so the
139
+ * destination was never the problem — reaching it was.
140
+ *
141
+ * This is the ordinary SPA link rule, and the `href` is deliberately kept: a
142
+ * modified click (⌘/ctrl/shift/alt, or a middle click) is left to the browser,
143
+ * so opening the conversation in a new tab on purpose still works, and the
144
+ * status bar still shows where the link goes.
145
+ */
146
+ const openInInbox = (event, href, navigate) => {
147
+ if (!navigate) return;
148
+ if (event.defaultPrevented) return;
149
+ if (event.button !== 0) return;
150
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
151
+
152
+ event.preventDefault();
153
+ navigate(href);
154
+ };
155
+
156
+ /** Keep the address bar in step without pushing a history entry per click. */
157
+ const syncCampaignInUrl = (campaignId) => {
158
+ if (typeof window === 'undefined' || !window.history?.replaceState) return;
159
+
160
+ const url = new URL(window.location.href);
161
+
162
+ if (campaignId) url.searchParams.set('campaign', campaignId);
163
+ else url.searchParams.delete('campaign');
164
+
165
+ window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
166
+ };
167
+
168
+ /* ------------------------------------------------------------ the recipients */
169
+
170
+ /**
171
+ * SheetJS, loaded only once somebody actually drops a file.
172
+ *
173
+ * The same arrangement `qrDecode.js` makes with jsQR, and for the same reason:
174
+ * `xlsx` is the better part of a megabyte of parser, and a consumer that never
175
+ * imports a recipient list should never download it. The promise is cached, so
176
+ * a second file costs nothing.
177
+ *
178
+ * ONE HONEST CAVEAT, and the build says so out loud: `GenericReport.jsx`
179
+ * imports `xlsx` STATICALLY, so in the single-bundle build the parser is in
180
+ * the download either way and the split buys nothing today. That is a reason
181
+ * to fix GenericReport, not a reason to import it statically here — the
182
+ * warning names the one file standing between this module and the saving, and
183
+ * a second static import would remove the warning by making the problem
184
+ * permanent. A consumer importing this component directly from `src` already
185
+ * gets the saving.
186
+ */
187
+ let xlsxPromise = null;
188
+
189
+ const loadXlsx = () => {
190
+ if (!xlsxPromise) {
191
+ xlsxPromise = import('xlsx').then((module) => {
192
+ // A UMD package: what a namespace import puts on `default` depends
193
+ // on the loader, so take whichever object actually has `read`.
194
+ const candidates = [module, module?.default, module?.default?.default];
195
+ const found = candidates.find((one) => typeof one?.read === 'function');
196
+
197
+ if (!found) throw new Error('The spreadsheet reader did not load.');
198
+
199
+ return found;
200
+ });
201
+ }
202
+
203
+ return xlsxPromise;
204
+ };
205
+
206
+ /** The first sheet of a `.csv` or `.xlsx`, as a grid of raw cells. */
207
+ export const readSpreadsheet = async (file) => {
208
+ const XLSX = await loadXlsx();
209
+ const buffer = await file.arrayBuffer();
210
+ const book = XLSX.read(new Uint8Array(buffer), { type: 'array' });
211
+ const first = book.SheetNames?.[0];
212
+
213
+ if (!first) return [];
214
+
215
+ return XLSX.utils.sheet_to_json(book.Sheets[first], {
216
+ header: 1,
217
+ blankrows: false,
218
+ defval: '',
219
+ });
220
+ };
221
+
222
+ /**
223
+ * One cell as text.
224
+ *
225
+ * The case worth knowing: a mobile typed into Excel as `0412 345 678` is
226
+ * stored as the NUMBER 412345678 and handed back with its leading zero gone,
227
+ * which is the commonest way an imported list arrives full of numbers that
228
+ * cannot be sent to. Nine digits beginning with a 4, arriving as a number
229
+ * rather than a string, is an Australian mobile that has been through exactly
230
+ * that — and nothing else in a recipient list looks like it. The zero goes
231
+ * back on. Everything else is stringified and left alone.
232
+ */
233
+ export const cellText = (value) => {
234
+ if (value === null || value === undefined) return '';
235
+
236
+ if (typeof value === 'number') {
237
+ const digits = String(value);
238
+
239
+ return /^4\d{8}$/.test(digits) ? `0${digits}` : digits;
240
+ }
241
+
242
+ return String(value).replace(/\s+/g, ' ').trim();
243
+ };
244
+
245
+ /**
246
+ * Which column holds the name, and which the mobile.
247
+ *
248
+ * Ordered, and the order is the rule: an exact `name` beats a `first name`
249
+ * beats anything merely containing "name", so a sheet carrying both `Name` and
250
+ * `Company Name` defaults to the person rather than the company. Every guess
251
+ * is only a default — the two selects above the preview are what actually
252
+ * decide, and they are drawn whether the guess found anything or not.
253
+ */
254
+ export const NAME_HINTS = [/^name$/i, /^full/i, /^first/i, /full/i, /name/i];
255
+ export const NUMBER_HINTS = [
256
+ /^mobile/i,
257
+ /^phone/i,
258
+ /^cell/i,
259
+ /mobile/i,
260
+ /phone/i,
261
+ /cell/i,
262
+ /number/i,
263
+ ];
264
+
265
+ export const guessColumn = (headers, hints) => {
266
+ for (const hint of hints) {
267
+ const index = headers.findIndex((header) => hint.test(String(header ?? '')));
268
+
269
+ if (index !== -1) return index;
270
+ }
271
+
272
+ return -1;
273
+ };
274
+
275
+ /**
276
+ * The header row, made safe to key an object with.
277
+ *
278
+ * A blank heading becomes `Column 3` and a repeated one is suffixed, because
279
+ * these strings are the keys of `extra` — and `extra` is what the server fills
280
+ * `{<column>}` from. Two columns sharing a key would silently drop one of
281
+ * them, and the placeholder chip would then offer a token that filled with the
282
+ * wrong value on every row.
283
+ */
284
+ const headerKeys = (row, width) => {
285
+ const keys = [];
286
+
287
+ for (let index = 0; index < width; index += 1) {
288
+ const raw = cellText(row?.[index]);
289
+ const base = raw === '' ? `Column ${index + 1}` : raw;
290
+
291
+ let unique = base;
292
+ let suffix = 2;
293
+
294
+ while (keys.includes(unique)) {
295
+ unique = `${base} ${suffix}`;
296
+ suffix += 1;
297
+ }
298
+
299
+ keys.push(unique);
300
+ }
301
+
302
+ return keys;
303
+ };
304
+
305
+ /**
306
+ * A grid of cells split into headings and records.
307
+ *
308
+ * The first row is the header row, except when it plainly is not: a list
309
+ * exported straight out of another system often starts at the first person.
310
+ * The test for that is deliberately narrow — nothing in the first row reads as
311
+ * a heading we recognise, AND one of its cells is a phone number — because
312
+ * both ways of getting it wrong are immediately visible on the preview table
313
+ * underneath (a missing first person, or a row of column names pretending to
314
+ * be one) and the narrow test is the one that almost never fires by accident.
315
+ */
316
+ export const readSheetRows = (matrix) => {
317
+ const rows = (matrix || []).filter(
318
+ (row) =>
319
+ Array.isArray(row) && row.some((cell) => cellText(cell) !== '')
320
+ );
321
+
322
+ if (rows.length === 0) return { headers: [], records: [] };
323
+
324
+ const width = rows.reduce((widest, row) => Math.max(widest, row.length), 0);
325
+ const first = rows[0];
326
+ const headings = first.map((cell) => cellText(cell));
327
+
328
+ const named =
329
+ guessColumn(headings, NAME_HINTS) !== -1 ||
330
+ guessColumn(headings, NUMBER_HINTS) !== -1;
331
+ const looksLikeData = headings.some((cell) => looksLikeNumber(cell));
332
+
333
+ if (!named && looksLikeData) {
334
+ return { headers: headerKeys([], width), records: rows };
335
+ }
336
+
337
+ return { headers: headerKeys(first, width), records: rows.slice(1) };
338
+ };
339
+
340
+ /**
341
+ * Records plus a column choice, as the recipients the contract wants.
342
+ *
343
+ * Every column that is neither the name nor the mobile is kept in `extra`
344
+ * **under its own heading**, and that is what makes the placeholder chips
345
+ * honest: the chip inserts the same string this object is keyed by, so
346
+ * whatever a heading happens to be called — `Client Ref`, `Due date` — the
347
+ * token the sender clicks and the data the server holds cannot disagree.
348
+ */
349
+ export const recipientsFromRecords = (records, headers, nameColumn, numberColumn) => {
350
+ const nameIndex = headers.indexOf(nameColumn);
351
+ const numberIndex = headers.indexOf(numberColumn);
352
+
353
+ if (numberIndex === -1) return [];
354
+
355
+ return (records || [])
356
+ .map((record) => {
357
+ const extra = {};
358
+
359
+ headers.forEach((header, index) => {
360
+ if (index === nameIndex || index === numberIndex) return;
361
+
362
+ const value = cellText(record?.[index]);
363
+
364
+ if (value !== '') extra[header] = value;
365
+ });
366
+
367
+ return {
368
+ name: nameIndex === -1 ? '' : cellText(record?.[nameIndex]),
369
+ number: cellText(record?.[numberIndex]),
370
+ extra,
371
+ };
372
+ })
373
+ .filter((row) => row.name !== '' || row.number !== '');
374
+ };
375
+
376
+ /**
377
+ * Lines of `Name, 04xx xxx xxx` typed or pasted into the box.
378
+ *
379
+ * Two separators, and which one is tried first matters. A paste out of a
380
+ * spreadsheet is tab separated, so a tab wins outright. Failing that the split
381
+ * is on the LAST comma rather than the first, because the CRM writes people
382
+ * surname-first — "Perera, Darshini, 0412 345 678" — and splitting on the
383
+ * first comma would make "Darshini, 0412 345 678" the phone number and lose
384
+ * half the name with it.
385
+ *
386
+ * A line with one field is a bare number, which is the shape a column of
387
+ * numbers copied out of anywhere arrives in.
388
+ */
389
+ export const parsePastedRecipients = (text) =>
390
+ String(text ?? '')
391
+ .split(/\r?\n/)
392
+ .map((line) => line.trim())
393
+ .filter((line) => line !== '')
394
+ .map((line) => {
395
+ if (line.includes('\t')) {
396
+ const parts = line.split('\t').map((part) => part.trim());
397
+
398
+ return parts.length > 1
399
+ ? { name: parts[0], number: parts[1], extra: {} }
400
+ : { name: '', number: parts[0], extra: {} };
401
+ }
402
+
403
+ const comma = line.lastIndexOf(',');
404
+
405
+ if (comma === -1) return { name: '', number: line, extra: {} };
406
+
407
+ return {
408
+ name: line.slice(0, comma).trim(),
409
+ number: line.slice(comma + 1).trim(),
410
+ extra: {},
411
+ };
412
+ })
413
+ .filter((row) => row.number !== '' || row.name !== '');
414
+
415
+ /** Today, as the date half of a campaign's default name. */
416
+ const todayStamp = (now = new Date()) =>
417
+ `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
418
+ now.getDate()
419
+ ).padStart(2, '0')}`;
420
+
421
+ /**
422
+ * What a campaign is called until somebody names it themselves.
423
+ *
424
+ * The date alone is a name nobody can pick out of a list of six, so the first
425
+ * few words of the message go on the end. Both halves, because two campaigns
426
+ * on one morning would otherwise be called the same thing.
427
+ */
428
+ export const defaultCampaignName = (body, now = new Date()) => {
429
+ const words = String(body ?? '')
430
+ .replace(/\s+/g, ' ')
431
+ .trim()
432
+ .split(' ')
433
+ .filter(Boolean)
434
+ .slice(0, 6)
435
+ .join(' ');
436
+
437
+ return words === '' ? todayStamp(now) : `${todayStamp(now)} · ${words}`;
438
+ };
439
+
440
+ /** How long a send of this size takes at the line's throttle. */
441
+ export const estimateMinutes = (recipients, perMinute) => {
442
+ const rate = Number(perMinute) || 0;
443
+ const count = Number(recipients) || 0;
444
+
445
+ if (rate <= 0 || count <= 0) return 0;
446
+
447
+ return Math.max(1, Math.ceil(count / rate));
448
+ };
449
+
450
+ /* ------------------------------------------------------------ small pieces */
451
+
452
+ const StatusChip = ({ status, table = CAMPAIGN_STATUSES }) => {
453
+ const entry = table[status] || { label: status || '—', tone: 'muted' };
454
+
455
+ return (
456
+ <span className={`${styles.pill} ${styles[TONE_CLASSES[entry.tone]]}`}>
457
+ {entry.label}
458
+ </span>
459
+ );
460
+ };
461
+
462
+ /**
463
+ * The progress bar, and the number beside it.
464
+ *
465
+ * The figure is `sent/total` rather than a percentage: on a send of forty,
466
+ * "12 of 40" is a thing somebody can act on and "30%" is not. The bar carries
467
+ * the percentage because that is what a bar is for.
468
+ */
469
+ const ProgressBar = ({ campaign }) => {
470
+ const counts = campaign?.counts || {};
471
+ const total = Number(counts.total) || 0;
472
+ const sent = Number(counts.sent) || 0;
473
+ const percent = Math.max(0, Math.min(100, Number(campaign?.progress) || 0));
474
+
475
+ return (
476
+ <div className={styles.campaignProgress}>
477
+ <div
478
+ className={styles.campaignBar}
479
+ role="img"
480
+ aria-label={`${percent}% sent`}
481
+ >
482
+ <span
483
+ className={styles.campaignBarFill}
484
+ style={{ width: `${percent}%` }}
485
+ />
486
+ </div>
487
+ <span className={styles.campaignProgressCount}>
488
+ {sent} / {total}
489
+ </span>
490
+ </div>
491
+ );
492
+ };
493
+
494
+ /* --------------------------------------------------------- the opt-out list */
495
+
496
+ /**
497
+ * Numbers that must not be sent to again.
498
+ *
499
+ * A modal rather than a fourth view, because it is a reference list somebody
500
+ * opens to check or correct one entry and then closes — not somewhere anybody
501
+ * works. Everything here is read live; nothing is held while it is shut.
502
+ */
503
+ const OptOutManager = ({ routes, canManage, onClose }) => {
504
+ const [optOuts, setOptOuts] = useState([]);
505
+ const [loading, setLoading] = useState(true);
506
+ const [search, setSearch] = useState('');
507
+ const [number, setNumber] = useState('');
508
+ const [note, setNote] = useState('');
509
+ const [saving, setSaving] = useState(false);
510
+ const [error, setError] = useState('');
511
+
512
+ const mountedRef = useRef(true);
513
+ const debouncedSearch = useDebouncedValue(search, 300);
514
+
515
+ useEffect(
516
+ () => () => {
517
+ mountedRef.current = false;
518
+ },
519
+ []
520
+ );
521
+
522
+ const load = useCallback(
523
+ (query) => {
524
+ CustomFetch(
525
+ routes.optOuts,
526
+ 'GET',
527
+ query ? { search: query } : null,
528
+ (result) => {
529
+ if (!mountedRef.current) return;
530
+
531
+ setOptOuts(Array.isArray(result?.opt_outs) ? result.opt_outs : []);
532
+ setLoading(false);
533
+ },
534
+ () => {
535
+ if (mountedRef.current) setLoading(false);
536
+ }
537
+ ).catch(() => {
538
+ if (mountedRef.current) setLoading(false);
539
+ });
540
+ },
541
+ [routes]
542
+ );
543
+
544
+ useEffect(() => {
545
+ load(debouncedSearch.trim());
546
+ }, [load, debouncedSearch]);
547
+
548
+ const add = () => {
549
+ if (number.trim() === '') return;
550
+
551
+ setSaving(true);
552
+ setError('');
553
+
554
+ CustomFetch(
555
+ routes.optOuts,
556
+ 'POST',
557
+ { number: number.trim(), note: note.trim() === '' ? undefined : note.trim() },
558
+ () => {
559
+ if (!mountedRef.current) return;
560
+
561
+ setSaving(false);
562
+ setNumber('');
563
+ setNote('');
564
+ toast.success('Number added to the opt-out list');
565
+ load(debouncedSearch.trim());
566
+ },
567
+ (message) => {
568
+ if (!mountedRef.current) return;
569
+
570
+ setSaving(false);
571
+ // The one failure a person can fix from here is the number
572
+ // itself, so it stays under the field rather than becoming a
573
+ // toast behind the dialog.
574
+ setError(asPlainText(message) || 'That number could not be added.');
575
+ }
576
+ ).catch(() => {
577
+ if (mountedRef.current) setSaving(false);
578
+ });
579
+ };
580
+
581
+ const remove = (entry) => {
582
+ showConfirmDialog({
583
+ title: 'Remove this opt-out?',
584
+ message:
585
+ 'The number goes back into future campaigns. Messages already skipped stay skipped.',
586
+ confirmLabel: 'Remove',
587
+ cancelLabel: 'Keep it',
588
+ data: { name: entry.display_number || entry.number },
589
+ onConfirm: () => {
590
+ CustomFetch(
591
+ routes.optOut(entry.id),
592
+ 'DELETE',
593
+ {},
594
+ () => {
595
+ toast.success('Opt-out removed');
596
+ load(debouncedSearch.trim());
597
+ },
598
+ null
599
+ ).catch(() => {});
600
+ },
601
+ });
602
+ };
603
+
604
+ return (
605
+ <StandardModal isOpen onClose={onClose} size="medium">
606
+ <div className={styles.modalForm}>
607
+ <div className={styles.modalHead}>
608
+ <h3 className={styles.modalTitle}>
609
+ <CircleSlash size={16} strokeWidth={2.2} aria-hidden="true" />
610
+ Opt-outs
611
+ </h3>
612
+ <button
613
+ type="button"
614
+ className={styles.iconButton}
615
+ onClick={onClose}
616
+ aria-label="Close"
617
+ >
618
+ <X size={15} strokeWidth={2} aria-hidden="true" />
619
+ </button>
620
+ </div>
621
+
622
+ <p className={styles.help}>
623
+ A campaign skips every number on this list and says so in its
624
+ report. One-to-one replies in the inbox are not affected.
625
+ </p>
626
+
627
+ <div className={styles.searchBar}>
628
+ <Search size={15} strokeWidth={2} aria-hidden="true" />
629
+ <input
630
+ type="text"
631
+ className={styles.searchInput}
632
+ value={search}
633
+ placeholder="Search numbers and notes…"
634
+ aria-label="Search opt-outs"
635
+ autoComplete="off"
636
+ onChange={(event) => setSearch(event.target.value)}
637
+ />
638
+ </div>
639
+
640
+ {canManage && (
641
+ <div className={styles.optOutAdd}>
642
+ <label className={styles.field}>
643
+ <span className={styles.labelSmall}>Number</span>
644
+ <input
645
+ type="text"
646
+ className={styles.input}
647
+ value={number}
648
+ placeholder="0412 345 678"
649
+ autoComplete="off"
650
+ onChange={(event) => {
651
+ setNumber(event.target.value);
652
+ if (error !== '') setError('');
653
+ }}
654
+ />
655
+ </label>
656
+
657
+ <label className={styles.field}>
658
+ <span className={styles.labelSmall}>Note</span>
659
+ <input
660
+ type="text"
661
+ className={styles.input}
662
+ value={note}
663
+ placeholder="Asked to stop, 4 September"
664
+ autoComplete="off"
665
+ onChange={(event) => setNote(event.target.value)}
666
+ />
667
+ </label>
668
+
669
+ <button
670
+ type="button"
671
+ className={styles.primaryButton}
672
+ onClick={add}
673
+ disabled={saving || number.trim() === ''}
674
+ >
675
+ {saving ? (
676
+ <Loader2 size={14} className={styles.spin} aria-hidden="true" />
677
+ ) : (
678
+ <Plus size={14} strokeWidth={2} aria-hidden="true" />
679
+ )}
680
+ Add number
681
+ </button>
682
+ </div>
683
+ )}
684
+
685
+ {error !== '' && <p className={styles.fieldError}>{error}</p>}
686
+
687
+ <div className={styles.tableWrap}>
688
+ <table className={styles.table}>
689
+ <thead>
690
+ <tr>
691
+ <th>Number</th>
692
+ <th>Source</th>
693
+ <th>Note</th>
694
+ <th>Added by</th>
695
+ <th>When</th>
696
+ {canManage && <th className={styles.actionsHead}>Actions</th>}
697
+ </tr>
698
+ </thead>
699
+ <tbody>
700
+ {optOuts.map((entry) => (
701
+ <tr key={entry.id}>
702
+ <td className={styles.numberCell}>
703
+ <strong>
704
+ {entry.display_number ||
705
+ normaliseNumberForDisplay(entry.number)}
706
+ </strong>
707
+ </td>
708
+ <td>{entry.source || <span className={styles.muted}>—</span>}</td>
709
+ <td>{entry.note || <span className={styles.muted}>—</span>}</td>
710
+ <td>
711
+ {entry.user?.name || (
712
+ <span className={styles.muted}>Automatic</span>
713
+ )}
714
+ </td>
715
+ <td>{relativeTime(entry.created_at)}</td>
716
+ {canManage && (
717
+ <td className={styles.actionsCell}>
718
+ <button
719
+ type="button"
720
+ className={styles.dangerLink}
721
+ onClick={() => remove(entry)}
722
+ >
723
+ Remove
724
+ </button>
725
+ </td>
726
+ )}
727
+ </tr>
728
+ ))}
729
+
730
+ {optOuts.length === 0 && (
731
+ <tr>
732
+ <td
733
+ colSpan={canManage ? 6 : 5}
734
+ className={styles.tableEmpty}
735
+ >
736
+ {loading ? (
737
+ <Loader2
738
+ size={18}
739
+ className={styles.spin}
740
+ aria-label="Loading"
741
+ />
742
+ ) : (
743
+ <p>
744
+ {search.trim() === ''
745
+ ? 'Nobody has opted out.'
746
+ : `No opt-out matches "${search.trim()}".`}
747
+ </p>
748
+ )}
749
+ </td>
750
+ </tr>
751
+ )}
752
+ </tbody>
753
+ </table>
754
+ </div>
755
+
756
+ <div className={styles.modalActions}>
757
+ <button type="button" className={styles.ghostButton} onClick={onClose}>
758
+ Close
759
+ </button>
760
+ </div>
761
+ </div>
762
+ </StandardModal>
763
+ );
764
+ };
765
+
766
+ /* ------------------------------------------------------------------ the list */
767
+
768
+ const CampaignList = ({
769
+ campaigns,
770
+ loading,
771
+ canManage,
772
+ onNew,
773
+ onOpen,
774
+ onOptOuts,
775
+ }) => (
776
+ <>
777
+ <div className={styles.sectionHead}>
778
+ <h2 className={styles.sectionTitle}>Campaigns</h2>
779
+
780
+ <div className={styles.headActions}>
781
+ <button type="button" className={styles.ghostButton} onClick={onOptOuts}>
782
+ <CircleSlash size={14} strokeWidth={2} aria-hidden="true" />
783
+ Opt-outs
784
+ </button>
785
+ {canManage && (
786
+ <button type="button" className={styles.primaryButton} onClick={onNew}>
787
+ <Plus size={14} strokeWidth={2} aria-hidden="true" />
788
+ New campaign
789
+ </button>
790
+ )}
791
+ </div>
792
+ </div>
793
+
794
+ <div className={styles.tableWrap}>
795
+ <table className={styles.table}>
796
+ <thead>
797
+ <tr>
798
+ <th>Campaign</th>
799
+ <th>Line</th>
800
+ <th>Status</th>
801
+ <th>Progress</th>
802
+ <th>Created by</th>
803
+ <th>Created</th>
804
+ </tr>
805
+ </thead>
806
+ <tbody>
807
+ {campaigns.map((campaign) => (
808
+ <tr key={campaign.id}>
809
+ <td>
810
+ <button
811
+ type="button"
812
+ className={styles.linkButton}
813
+ onClick={() => onOpen(campaign.id)}
814
+ >
815
+ {campaign.name || 'Untitled campaign'}
816
+ </button>
817
+ </td>
818
+ <td>
819
+ {campaign.line?.label || (
820
+ <span className={styles.muted}>No line</span>
821
+ )}
822
+ </td>
823
+ <td>
824
+ <StatusChip status={campaign.status} />
825
+ </td>
826
+ <td className={styles.campaignProgressCell}>
827
+ <ProgressBar campaign={campaign} />
828
+ </td>
829
+ <td>
830
+ {campaign.user?.name || (
831
+ <span className={styles.muted}>—</span>
832
+ )}
833
+ </td>
834
+ <td>{relativeTime(campaign.created_at)}</td>
835
+ </tr>
836
+ ))}
837
+
838
+ {campaigns.length === 0 && (
839
+ <tr>
840
+ <td colSpan={6} className={styles.tableEmpty}>
841
+ {loading ? (
842
+ <Loader2
843
+ size={18}
844
+ className={styles.spin}
845
+ aria-label="Loading"
846
+ />
847
+ ) : (
848
+ <p>
849
+ No campaigns yet. A campaign sends one message to
850
+ a list of numbers, one at a time, from a line you
851
+ choose.
852
+ </p>
853
+ )}
854
+ </td>
855
+ </tr>
856
+ )}
857
+ </tbody>
858
+ </table>
859
+ </div>
860
+ </>
861
+ );
862
+
863
+ /* --------------------------------------------------------------- the wizard */
864
+
865
+ /**
866
+ * Three steps, and it is a page rather than a modal.
867
+ *
868
+ * The recipient table is the reason: a list of four hundred people with a
869
+ * sanity flag on the numbers that will not send is the one thing on this
870
+ * screen somebody genuinely reads, and a dialog gives it a third of the
871
+ * window. The steps are local state rather than the URL — a half-built
872
+ * campaign is not something a link can restore, so a `?step=2` anybody could
873
+ * paste would open on an empty list.
874
+ */
875
+ const CampaignWizard = ({ routes, settings, lines, onCancel, onSaved, onOpen }) => {
876
+ const [step, setStep] = useState(1);
877
+
878
+ // Step one.
879
+ const [rows, setRows] = useState([]);
880
+ const [headers, setHeaders] = useState([]);
881
+ const [records, setRecords] = useState([]);
882
+ const [nameColumn, setNameColumn] = useState('');
883
+ const [numberColumn, setNumberColumn] = useState('');
884
+ const [fileName, setFileName] = useState('');
885
+ const [fileError, setFileError] = useState('');
886
+ const [reading, setReading] = useState(false);
887
+ const [paste, setPaste] = useState('');
888
+ const [capped, setCapped] = useState(false);
889
+
890
+ // Step two.
891
+ const [lineId, setLineId] = useState('');
892
+ const [name, setName] = useState('');
893
+ const [nameTouched, setNameTouched] = useState(false);
894
+ const [body, setBody] = useState('');
895
+ const [previews, setPreviews] = useState([]);
896
+ const [previewing, setPreviewing] = useState(false);
897
+
898
+ // Step three.
899
+ const [creating, setCreating] = useState(false);
900
+ const [createError, setCreateError] = useState('');
901
+ const [created, setCreated] = useState(null);
902
+ const [report, setReport] = useState(null);
903
+ const [starting, setStarting] = useState(false);
904
+
905
+ const mountedRef = useRef(true);
906
+ const bodyRef = useRef(null);
907
+ const debouncedBody = useDebouncedValue(body, 400);
908
+
909
+ useEffect(
910
+ () => () => {
911
+ mountedRef.current = false;
912
+ },
913
+ []
914
+ );
915
+
916
+ const maxRecipients = Number(settings.max_recipients) || 0;
917
+
918
+ /**
919
+ * Add rows, and stop at the cap rather than letting the server refuse the
920
+ * whole list after somebody has written the message.
921
+ */
922
+ const addRows = useCallback(
923
+ (incoming) => {
924
+ setRows((previous) => {
925
+ const next = [...previous, ...incoming];
926
+
927
+ if (maxRecipients > 0 && next.length > maxRecipients) {
928
+ setCapped(true);
929
+
930
+ return next.slice(0, maxRecipients);
931
+ }
932
+
933
+ return next;
934
+ });
935
+ },
936
+ [maxRecipients]
937
+ );
938
+
939
+ /* --------------------------------------------------------- step 1 input */
940
+
941
+ const onDrop = useCallback((accepted) => {
942
+ const file = accepted?.[0];
943
+
944
+ if (!file) return;
945
+
946
+ setReading(true);
947
+ setFileError('');
948
+ setFileName(file.name);
949
+
950
+ readSpreadsheet(file)
951
+ .then((matrix) => {
952
+ if (!mountedRef.current) return;
953
+
954
+ const sheet = readSheetRows(matrix);
955
+
956
+ setReading(false);
957
+
958
+ if (sheet.records.length === 0) {
959
+ setFileError('That file has no rows in it.');
960
+ setHeaders([]);
961
+ setRecords([]);
962
+
963
+ return;
964
+ }
965
+
966
+ setHeaders(sheet.headers);
967
+ setRecords(sheet.records);
968
+
969
+ const nameIndex = guessColumn(sheet.headers, NAME_HINTS);
970
+ const numberIndex = guessColumn(sheet.headers, NUMBER_HINTS);
971
+
972
+ setNameColumn(nameIndex === -1 ? '' : sheet.headers[nameIndex]);
973
+ setNumberColumn(
974
+ numberIndex === -1 ? '' : sheet.headers[numberIndex]
975
+ );
976
+ })
977
+ .catch(() => {
978
+ if (!mountedRef.current) return;
979
+
980
+ setReading(false);
981
+ setFileError(
982
+ 'That file could not be read. A .csv or .xlsx exported from a spreadsheet is what this expects.'
983
+ );
984
+ });
985
+ }, []);
986
+
987
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
988
+ accept: {
989
+ 'text/csv': ['.csv'],
990
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
991
+ },
992
+ maxFiles: 1,
993
+ multiple: false,
994
+ onDrop,
995
+ });
996
+
997
+ /**
998
+ * The imported rows follow the two column selects.
999
+ *
1000
+ * Re-derived rather than stamped once on drop, so changing "which column
1001
+ * is the mobile" corrects the list in front of the person changing it —
1002
+ * which is the whole reason the selects are there.
1003
+ */
1004
+ const importedRows = useMemo(
1005
+ () => recipientsFromRecords(records, headers, nameColumn, numberColumn),
1006
+ [records, headers, nameColumn, numberColumn]
1007
+ );
1008
+
1009
+ const takeImported = () => {
1010
+ addRows(importedRows);
1011
+ setHeaders([]);
1012
+ setRecords([]);
1013
+ setNameColumn('');
1014
+ setNumberColumn('');
1015
+ setFileName('');
1016
+ };
1017
+
1018
+ const takePasted = () => {
1019
+ addRows(parsePastedRecipients(paste));
1020
+ setPaste('');
1021
+ };
1022
+
1023
+ const removeRow = (index) => {
1024
+ setRows((previous) => previous.filter((one, at) => at !== index));
1025
+ // The cap notice is about a list that no longer exists the moment
1026
+ // somebody takes a row out of it, and a sentence saying "stopped at
1027
+ // 500" over 499 recipients is a sentence nobody can make sense of.
1028
+ setCapped(false);
1029
+ };
1030
+
1031
+ /* ---------------------------------------------------------- step 2 body */
1032
+
1033
+ const activeLines = useMemo(
1034
+ () => lines.filter((line) => line.active !== false),
1035
+ [lines]
1036
+ );
1037
+
1038
+ useEffect(() => {
1039
+ if (lineId !== '' || activeLines.length === 0) return;
1040
+
1041
+ setLineId(String(activeLines[0].id));
1042
+ }, [activeLines, lineId]);
1043
+
1044
+ const extraKeys = useMemo(() => {
1045
+ const seen = [];
1046
+
1047
+ rows.forEach((row) => {
1048
+ Object.keys(row.extra || {}).forEach((key) => {
1049
+ if (!seen.includes(key)) seen.push(key);
1050
+ });
1051
+ });
1052
+
1053
+ return seen;
1054
+ }, [rows]);
1055
+
1056
+ const placeholders = useMemo(
1057
+ () => ['{first_name}', '{name}', '{last_name}', ...extraKeys.map((key) => `{${key}}`)],
1058
+ [extraKeys]
1059
+ );
1060
+
1061
+ const footer = String(settings.footer ?? '');
1062
+ const footerRequired = Boolean(settings.footer_required) && footer !== '';
1063
+
1064
+ /**
1065
+ * The message as it actually leaves: body, then the footer the server
1066
+ * appends. Counted together because that is what the recipient's phone
1067
+ * charges for — a body that fits in one part and a footer that does not is
1068
+ * a two-part message, and a counter that said "1 SMS" would be wrong on
1069
+ * every single send.
1070
+ */
1071
+ const composed = footerRequired ? `${body}\n${footer}` : body;
1072
+ const counts = segmentCount(composed);
1073
+
1074
+ const bodyLimit = footerRequired
1075
+ ? Math.max(0, Number(settings.max_body_length) - footer.length - 1)
1076
+ : Number(settings.max_body_length) || 0;
1077
+ const overBodyLimit = bodyLimit > 0 && body.length > bodyLimit;
1078
+
1079
+ // The name follows the message until somebody types one of their own —
1080
+ // after that it is theirs, including if they clear it.
1081
+ useEffect(() => {
1082
+ if (nameTouched) return;
1083
+
1084
+ setName(defaultCampaignName(body));
1085
+ }, [body, nameTouched]);
1086
+
1087
+ /** Put a placeholder where the caret is, not on the end of the message. */
1088
+ const insertPlaceholder = (token) => {
1089
+ const box = bodyRef.current;
1090
+ const at = box ? box.selectionStart : body.length;
1091
+ const to = box ? box.selectionEnd : body.length;
1092
+
1093
+ const next = `${body.slice(0, at)}${token}${body.slice(to)}`;
1094
+
1095
+ setBody(next);
1096
+
1097
+ requestAnimationFrame(() => {
1098
+ const target = bodyRef.current;
1099
+
1100
+ if (!target) return;
1101
+
1102
+ target.focus();
1103
+ target.setSelectionRange(at + token.length, at + token.length);
1104
+ });
1105
+ };
1106
+
1107
+ useEffect(() => {
1108
+ if (step !== 2 || debouncedBody.trim() === '' || rows.length === 0) {
1109
+ setPreviews([]);
1110
+
1111
+ return;
1112
+ }
1113
+
1114
+ setPreviewing(true);
1115
+
1116
+ CustomFetch(
1117
+ routes.campaignPreview,
1118
+ 'POST',
1119
+ { body: debouncedBody, recipients: rows.slice(0, 3) },
1120
+ (result) => {
1121
+ if (!mountedRef.current) return;
1122
+
1123
+ setPreviews(Array.isArray(result?.previews) ? result.previews : []);
1124
+ setPreviewing(false);
1125
+ },
1126
+ () => {
1127
+ if (!mountedRef.current) return;
1128
+
1129
+ // A preview that cannot be fetched is not a reason to stop
1130
+ // somebody writing; the panel simply says nothing.
1131
+ setPreviews([]);
1132
+ setPreviewing(false);
1133
+ }
1134
+ ).catch(() => {
1135
+ if (mountedRef.current) setPreviewing(false);
1136
+ });
1137
+ }, [step, debouncedBody, rows, routes]);
1138
+
1139
+ /* -------------------------------------------------------------- step 3 */
1140
+
1141
+ const line = activeLines.find((one) => String(one.id) === String(lineId)) || null;
1142
+
1143
+ const create = () => {
1144
+ setCreating(true);
1145
+ setCreateError('');
1146
+
1147
+ CustomFetch(
1148
+ routes.campaigns,
1149
+ 'POST',
1150
+ { line_id: lineId, name: name.trim(), body, recipients: rows },
1151
+ (result) => {
1152
+ if (!mountedRef.current) return;
1153
+
1154
+ setCreating(false);
1155
+ setCreated(result?.campaign ?? null);
1156
+ setReport(result?.report ?? null);
1157
+ },
1158
+ (message) => {
1159
+ if (!mountedRef.current) return;
1160
+
1161
+ setCreating(false);
1162
+ // At the top of the step, never a toast alone: the refusal
1163
+ // names a list the person is standing in front of, and a
1164
+ // notice that fades is no use for reading one.
1165
+ setCreateError(
1166
+ asPlainText(message) || 'That campaign could not be created.'
1167
+ );
1168
+ }
1169
+ ).catch(() => {
1170
+ if (mountedRef.current) setCreating(false);
1171
+ });
1172
+ };
1173
+
1174
+ const start = () => {
1175
+ if (!created) return;
1176
+
1177
+ setStarting(true);
1178
+
1179
+ CustomFetch(
1180
+ routes.campaignStart(created.id),
1181
+ 'POST',
1182
+ {},
1183
+ () => {
1184
+ if (!mountedRef.current) return;
1185
+
1186
+ setStarting(false);
1187
+ onOpen(created.id);
1188
+ },
1189
+ (message) => {
1190
+ if (!mountedRef.current) return;
1191
+
1192
+ setStarting(false);
1193
+ setCreateError(asPlainText(message) || 'That campaign could not be started.');
1194
+ }
1195
+ ).catch(() => {
1196
+ if (mountedRef.current) setStarting(false);
1197
+ });
1198
+ };
1199
+
1200
+ /* --------------------------------------------------------------- render */
1201
+
1202
+ const badNumbers = rows.filter((row) => !looksLikeNumber(row.number)).length;
1203
+ const canLeaveOne = rows.length > 0;
1204
+ const canLeaveTwo =
1205
+ lineId !== '' && body.trim() !== '' && !overBodyLimit && name.trim() !== '';
1206
+
1207
+ return (
1208
+ <div className={styles.campaignWizard}>
1209
+ <div className={styles.sectionHead}>
1210
+ <h2 className={styles.sectionTitle}>New campaign</h2>
1211
+ <button type="button" className={styles.ghostButton} onClick={onCancel}>
1212
+ <X size={14} strokeWidth={2} aria-hidden="true" />
1213
+ Cancel
1214
+ </button>
1215
+ </div>
1216
+
1217
+ <ol className={styles.campaignSteps}>
1218
+ {['Recipients', 'Message', 'Review and send'].map((label, index) => (
1219
+ <li
1220
+ key={label}
1221
+ className={`${styles.campaignStep} ${
1222
+ step === index + 1 ? styles.campaignStepOn : ''
1223
+ }`}
1224
+ aria-current={step === index + 1 ? 'step' : undefined}
1225
+ >
1226
+ <span className={styles.campaignStepNumber}>{index + 1}</span>
1227
+ {label}
1228
+ </li>
1229
+ ))}
1230
+ </ol>
1231
+
1232
+ {step === 1 && (
1233
+ <>
1234
+ <div className={styles.campaignSources}>
1235
+ <section className={styles.campaignSource}>
1236
+ <h3 className={styles.campaignSourceTitle}>
1237
+ <FileSpreadsheet size={15} strokeWidth={2} aria-hidden="true" />
1238
+ From a spreadsheet
1239
+ </h3>
1240
+
1241
+ <div
1242
+ {...getRootProps({
1243
+ className: `${styles.campaignDrop} ${
1244
+ isDragActive ? styles.campaignDropOn : ''
1245
+ }`,
1246
+ })}
1247
+ >
1248
+ <input {...getInputProps()} />
1249
+ {reading ? (
1250
+ <Loader2
1251
+ size={18}
1252
+ className={styles.spin}
1253
+ aria-label="Reading"
1254
+ />
1255
+ ) : (
1256
+ <Upload size={18} strokeWidth={1.8} aria-hidden="true" />
1257
+ )}
1258
+ <p className={styles.help}>
1259
+ {fileName !== '' && !reading
1260
+ ? fileName
1261
+ : 'Drop a .csv or .xlsx here, or click to pick one. The file is read in this browser and never uploaded.'}
1262
+ </p>
1263
+ </div>
1264
+
1265
+ {fileError !== '' && (
1266
+ <p className={styles.fieldError}>{fileError}</p>
1267
+ )}
1268
+
1269
+ {headers.length > 0 && (
1270
+ <div className={styles.campaignColumns}>
1271
+ <label className={styles.field}>
1272
+ <span className={styles.labelSmall}>Name column</span>
1273
+ <select
1274
+ className={styles.select}
1275
+ value={nameColumn}
1276
+ onChange={(event) =>
1277
+ setNameColumn(event.target.value)
1278
+ }
1279
+ >
1280
+ <option value="">No name column</option>
1281
+ {headers.map((header) => (
1282
+ <option key={header} value={header}>
1283
+ {header}
1284
+ </option>
1285
+ ))}
1286
+ </select>
1287
+ </label>
1288
+
1289
+ <label className={styles.field}>
1290
+ <span className={styles.labelSmall}>Mobile column</span>
1291
+ <select
1292
+ className={styles.select}
1293
+ value={numberColumn}
1294
+ onChange={(event) =>
1295
+ setNumberColumn(event.target.value)
1296
+ }
1297
+ >
1298
+ <option value="">Choose a column</option>
1299
+ {headers.map((header) => (
1300
+ <option key={header} value={header}>
1301
+ {header}
1302
+ </option>
1303
+ ))}
1304
+ </select>
1305
+ </label>
1306
+
1307
+ <button
1308
+ type="button"
1309
+ className={styles.primaryButton}
1310
+ onClick={takeImported}
1311
+ disabled={importedRows.length === 0}
1312
+ >
1313
+ Add {importedRows.length}{' '}
1314
+ {importedRows.length === 1 ? 'row' : 'rows'}
1315
+ </button>
1316
+
1317
+ <p className={styles.help}>
1318
+ Every other column travels with the row and can be
1319
+ used in the message.
1320
+ </p>
1321
+ </div>
1322
+ )}
1323
+ </section>
1324
+
1325
+ <section className={styles.campaignSource}>
1326
+ <h3 className={styles.campaignSourceTitle}>
1327
+ <Megaphone size={15} strokeWidth={2} aria-hidden="true" />
1328
+ Typed or pasted
1329
+ </h3>
1330
+
1331
+ <textarea
1332
+ className={styles.textarea}
1333
+ rows={6}
1334
+ value={paste}
1335
+ placeholder={'Jane Fraser, 0412 345 678\n0498 765 432'}
1336
+ aria-label="Paste recipients"
1337
+ onChange={(event) => setPaste(event.target.value)}
1338
+ />
1339
+
1340
+ <p className={styles.help}>
1341
+ One person per line, name first. A line with nothing but a
1342
+ number is taken as a number with no name.
1343
+ </p>
1344
+
1345
+ <button
1346
+ type="button"
1347
+ className={styles.primaryButton}
1348
+ onClick={takePasted}
1349
+ disabled={paste.trim() === ''}
1350
+ >
1351
+ <Plus size={14} strokeWidth={2} aria-hidden="true" />
1352
+ Add these
1353
+ </button>
1354
+ </section>
1355
+ </div>
1356
+
1357
+ {capped && (
1358
+ <p className={`${styles.banner} ${styles.bannerWarn}`} role="status">
1359
+ <TriangleAlert size={14} strokeWidth={2} aria-hidden="true" />
1360
+ <span>
1361
+ <strong>Stopped at {maxRecipients}.</strong> That is the
1362
+ most one campaign takes — send the rest as a second one.
1363
+ </span>
1364
+ </p>
1365
+ )}
1366
+
1367
+ <div className={styles.campaignCount}>
1368
+ <strong>
1369
+ {rows.length} {rows.length === 1 ? 'recipient' : 'recipients'}
1370
+ </strong>
1371
+ {badNumbers > 0 && (
1372
+ <span className={styles.muted}>
1373
+ {' '}
1374
+ · {badNumbers} do not read as a phone number
1375
+ </span>
1376
+ )}
1377
+ </div>
1378
+
1379
+ <div className={styles.tableWrap}>
1380
+ <table className={styles.table}>
1381
+ <thead>
1382
+ <tr>
1383
+ <th>Name</th>
1384
+ <th>Mobile</th>
1385
+ <th>Other columns</th>
1386
+ <th className={styles.actionsHead}>Actions</th>
1387
+ </tr>
1388
+ </thead>
1389
+ <tbody>
1390
+ {rows.map((row, index) => {
1391
+ const bad = !looksLikeNumber(row.number);
1392
+
1393
+ return (
1394
+ <tr key={`${row.number}-${index}`}>
1395
+ <td>
1396
+ <strong>
1397
+ {row.name || (
1398
+ <span className={styles.muted}>
1399
+ No name
1400
+ </span>
1401
+ )}
1402
+ </strong>
1403
+ </td>
1404
+ <td className={styles.numberCell}>
1405
+ {normaliseNumberForDisplay(row.number) ||
1406
+ row.number}
1407
+ {bad && (
1408
+ <span
1409
+ className={`${styles.pill} ${styles.pillWarn} ${styles.campaignFlag}`}
1410
+ >
1411
+ Check this
1412
+ </span>
1413
+ )}
1414
+ </td>
1415
+ <td>
1416
+ <span className={styles.chipRow}>
1417
+ {Object.entries(row.extra || {}).map(
1418
+ ([key, value]) => (
1419
+ <span
1420
+ key={key}
1421
+ className={styles.staffChip}
1422
+ >
1423
+ {key}: {value}
1424
+ </span>
1425
+ )
1426
+ )}
1427
+ </span>
1428
+ </td>
1429
+ <td className={styles.actionsCell}>
1430
+ <button
1431
+ type="button"
1432
+ className={styles.dangerLink}
1433
+ onClick={() => removeRow(index)}
1434
+ >
1435
+ Remove
1436
+ </button>
1437
+ </td>
1438
+ </tr>
1439
+ );
1440
+ })}
1441
+
1442
+ {rows.length === 0 && (
1443
+ <tr>
1444
+ <td colSpan={4} className={styles.tableEmpty}>
1445
+ <p>
1446
+ Nobody on the list yet. Drop a file or paste
1447
+ some lines above.
1448
+ </p>
1449
+ </td>
1450
+ </tr>
1451
+ )}
1452
+ </tbody>
1453
+ </table>
1454
+ </div>
1455
+ </>
1456
+ )}
1457
+
1458
+ {step === 2 && (
1459
+ <div className={styles.campaignCompose}>
1460
+ <div className={styles.campaignComposeMain}>
1461
+ <label className={styles.field}>
1462
+ <span className={styles.labelSmall}>Send from</span>
1463
+ <select
1464
+ className={styles.select}
1465
+ value={lineId}
1466
+ onChange={(event) => setLineId(event.target.value)}
1467
+ >
1468
+ {activeLines.length === 0 && (
1469
+ <option value="">No lines available</option>
1470
+ )}
1471
+ {activeLines.map((one) => (
1472
+ <option key={one.id} value={one.id}>
1473
+ {(one.label || 'Line') +
1474
+ ' · ' +
1475
+ (one.display_number ||
1476
+ normaliseNumberForDisplay(one.phone_number))}
1477
+ </option>
1478
+ ))}
1479
+ </select>
1480
+ </label>
1481
+
1482
+ <label className={styles.field}>
1483
+ <span className={styles.labelSmall}>Campaign name</span>
1484
+ <input
1485
+ type="text"
1486
+ className={styles.input}
1487
+ value={name}
1488
+ autoComplete="off"
1489
+ onChange={(event) => {
1490
+ setNameTouched(true);
1491
+ setName(event.target.value);
1492
+ }}
1493
+ />
1494
+ <p className={styles.help}>
1495
+ Only staff see this. It is what the campaign is called in
1496
+ the list.
1497
+ </p>
1498
+ </label>
1499
+
1500
+ <div className={styles.field}>
1501
+ <span className={styles.labelSmall}>Message</span>
1502
+
1503
+ <div className={styles.chips}>
1504
+ {placeholders.map((token) => (
1505
+ <button
1506
+ key={token}
1507
+ type="button"
1508
+ className={styles.chip}
1509
+ onClick={() => insertPlaceholder(token)}
1510
+ >
1511
+ {token}
1512
+ </button>
1513
+ ))}
1514
+ </div>
1515
+
1516
+ <textarea
1517
+ ref={bodyRef}
1518
+ className={styles.textarea}
1519
+ rows={6}
1520
+ value={body}
1521
+ placeholder="Hi {first_name}, …"
1522
+ aria-label="Message"
1523
+ onChange={(event) => setBody(event.target.value)}
1524
+ />
1525
+
1526
+ <p
1527
+ className={`${styles.counter} ${
1528
+ overBodyLimit ? styles.counterOver : ''
1529
+ }`}
1530
+ >
1531
+ {counts.characters} characters · {counts.segments} SMS ·{' '}
1532
+ {counts.encoding}
1533
+ {bodyLimit > 0 ? ` · ${bodyLimit} allowed` : ''}
1534
+ </p>
1535
+
1536
+ {overBodyLimit && (
1537
+ <p className={styles.fieldError}>
1538
+ That is longer than one campaign message may be.
1539
+ </p>
1540
+ )}
1541
+
1542
+ {footer !== '' && (
1543
+ <p className={styles.help}>
1544
+ {footerRequired ? 'Added to every message: ' : 'Footer: '}
1545
+ <span className={styles.campaignFooter}>{footer}</span>
1546
+ </p>
1547
+ )}
1548
+ </div>
1549
+ </div>
1550
+
1551
+ <aside className={styles.campaignPreview}>
1552
+ <h3 className={styles.campaignSourceTitle}>
1553
+ Preview
1554
+ {previewing && (
1555
+ <Loader2
1556
+ size={13}
1557
+ className={styles.spin}
1558
+ aria-label="Loading"
1559
+ />
1560
+ )}
1561
+ </h3>
1562
+
1563
+ <p className={styles.help}>
1564
+ The first few recipients, rendered by the server — the same
1565
+ code that fills the placeholders when the campaign runs.
1566
+ </p>
1567
+
1568
+ {previews.length === 0 ? (
1569
+ <p className={styles.help}>
1570
+ {body.trim() === ''
1571
+ ? 'Write a message to see it filled in.'
1572
+ : 'Nothing to preview yet.'}
1573
+ </p>
1574
+ ) : (
1575
+ <ul className={styles.campaignPreviewList}>
1576
+ {previews.map((preview, index) => (
1577
+ <li
1578
+ key={`${preview.number}-${index}`}
1579
+ className={styles.campaignPreviewItem}
1580
+ >
1581
+ <span className={styles.campaignPreviewWho}>
1582
+ <span className={styles.avatar} aria-hidden="true">
1583
+ {initialsFor(preview.name || preview.number)}
1584
+ </span>
1585
+ <span>
1586
+ <strong>{preview.name || 'No name'}</strong>
1587
+ <small>
1588
+ {normaliseNumberForDisplay(preview.number)}
1589
+ </small>
1590
+ </span>
1591
+ </span>
1592
+ <p className={styles.campaignPreviewBody}>
1593
+ {preview.body}
1594
+ </p>
1595
+ <span className={styles.counter}>
1596
+ {preview.segments}{' '}
1597
+ {preview.segments === 1 ? 'SMS' : 'SMS parts'}
1598
+ </span>
1599
+ </li>
1600
+ ))}
1601
+ </ul>
1602
+ )}
1603
+ </aside>
1604
+ </div>
1605
+ )}
1606
+
1607
+ {step === 3 && (
1608
+ <div className={styles.campaignReview}>
1609
+ {createError !== '' && (
1610
+ <p className={`${styles.banner} ${styles.bannerBad}`} role="alert">
1611
+ <TriangleAlert size={14} strokeWidth={2} aria-hidden="true" />
1612
+ <span>{createError}</span>
1613
+ </p>
1614
+ )}
1615
+
1616
+ <div className={styles.campaignFacts}>
1617
+ <div className={styles.statusItem}>
1618
+ <span className={styles.statusLabel}>Recipients</span>
1619
+ <span className={styles.statusValue}>{rows.length}</span>
1620
+ </div>
1621
+ <div className={styles.statusItem}>
1622
+ <span className={styles.statusLabel}>Line</span>
1623
+ <span className={styles.statusValue}>
1624
+ {line ? line.label || 'Line' : '—'}
1625
+ </span>
1626
+ </div>
1627
+ <div className={styles.statusItem}>
1628
+ <span className={styles.statusLabel}>Parts per message</span>
1629
+ <span className={styles.statusValue}>{counts.segments}</span>
1630
+ </div>
1631
+ <div className={styles.statusItem}>
1632
+ <span className={styles.statusLabel}>Time to send</span>
1633
+ <span className={styles.statusValue}>
1634
+ about {estimateMinutes(rows.length, settings.per_minute)}{' '}
1635
+ min
1636
+ </span>
1637
+ </div>
1638
+
1639
+ <p className={styles.statusMeaning}>
1640
+ Parts are counted on the message as written. A placeholder
1641
+ that fills with a long name can push a borderline message on
1642
+ to another part.
1643
+ </p>
1644
+ </div>
1645
+
1646
+ {report && (
1647
+ <div className={styles.campaignReport}>
1648
+ <p className={`${styles.banner} ${styles.bannerOk}`}>
1649
+ <span>
1650
+ <strong>
1651
+ {Number(report.accepted) || 0}{' '}
1652
+ {Number(report.accepted) === 1
1653
+ ? 'recipient'
1654
+ : 'recipients'}{' '}
1655
+ accepted.
1656
+ </strong>
1657
+ {Number(report.duplicates) > 0
1658
+ ? ` ${report.duplicates} duplicate ${
1659
+ Number(report.duplicates) === 1
1660
+ ? 'number was'
1661
+ : 'numbers were'
1662
+ } dropped.`
1663
+ : ' No duplicates.'}
1664
+ </span>
1665
+ </p>
1666
+
1667
+ {(report.opted_out || []).length > 0 && (
1668
+ <section className={styles.section}>
1669
+ <h3 className={styles.campaignSourceTitle}>
1670
+ <CircleSlash
1671
+ size={15}
1672
+ strokeWidth={2}
1673
+ aria-hidden="true"
1674
+ />
1675
+ Opted out
1676
+ </h3>
1677
+ <p className={styles.help}>
1678
+ These numbers have asked not to be messaged, so
1679
+ nothing will be sent to them.
1680
+ </p>
1681
+ <span className={styles.chipRow}>
1682
+ {report.opted_out.map((one, index) => (
1683
+ <span
1684
+ key={`${one.number}-${index}`}
1685
+ className={styles.staffChip}
1686
+ >
1687
+ {one.name || normaliseNumberForDisplay(one.number)}
1688
+ </span>
1689
+ ))}
1690
+ </span>
1691
+ </section>
1692
+ )}
1693
+
1694
+ {(report.invalid || []).length > 0 && (
1695
+ <section className={styles.section}>
1696
+ <h3 className={styles.campaignSourceTitle}>
1697
+ <TriangleAlert
1698
+ size={15}
1699
+ strokeWidth={2}
1700
+ aria-hidden="true"
1701
+ />
1702
+ Rows that could not be used
1703
+ </h3>
1704
+ <div className={styles.tableWrap}>
1705
+ <table className={styles.table}>
1706
+ <thead>
1707
+ <tr>
1708
+ <th>Row</th>
1709
+ <th>Name</th>
1710
+ <th>Number</th>
1711
+ <th>Why</th>
1712
+ </tr>
1713
+ </thead>
1714
+ <tbody>
1715
+ {report.invalid.map((one, index) => (
1716
+ <tr key={`${one.row}-${index}`}>
1717
+ <td className={styles.numberCell}>
1718
+ {one.row}
1719
+ </td>
1720
+ <td>
1721
+ {one.name || (
1722
+ <span className={styles.muted}>
1723
+ No name
1724
+ </span>
1725
+ )}
1726
+ </td>
1727
+ <td className={styles.numberCell}>
1728
+ {one.number}
1729
+ </td>
1730
+ <td>{one.reason}</td>
1731
+ </tr>
1732
+ ))}
1733
+ </tbody>
1734
+ </table>
1735
+ </div>
1736
+ </section>
1737
+ )}
1738
+ </div>
1739
+ )}
1740
+ </div>
1741
+ )}
1742
+
1743
+ <div className={styles.campaignFoot}>
1744
+ {step > 1 && (
1745
+ <button
1746
+ type="button"
1747
+ className={styles.ghostButton}
1748
+ onClick={() => setStep(step - 1)}
1749
+ disabled={created !== null}
1750
+ >
1751
+ <ArrowLeft size={14} strokeWidth={2} aria-hidden="true" />
1752
+ Back
1753
+ </button>
1754
+ )}
1755
+
1756
+ {step < 3 && (
1757
+ <button
1758
+ type="button"
1759
+ className={styles.primaryButton}
1760
+ onClick={() => setStep(step + 1)}
1761
+ disabled={step === 1 ? !canLeaveOne : !canLeaveTwo}
1762
+ >
1763
+ Next
1764
+ <ArrowRight size={14} strokeWidth={2} aria-hidden="true" />
1765
+ </button>
1766
+ )}
1767
+
1768
+ {step === 3 && created === null && (
1769
+ <button
1770
+ type="button"
1771
+ className={styles.primaryButton}
1772
+ onClick={create}
1773
+ disabled={creating || rows.length === 0}
1774
+ >
1775
+ {creating && (
1776
+ <Loader2 size={14} className={styles.spin} aria-hidden="true" />
1777
+ )}
1778
+ Create campaign
1779
+ </button>
1780
+ )}
1781
+
1782
+ {step === 3 && created !== null && (
1783
+ <>
1784
+ <button
1785
+ type="button"
1786
+ className={styles.ghostButton}
1787
+ onClick={onSaved}
1788
+ >
1789
+ Save as draft
1790
+ </button>
1791
+ <button
1792
+ type="button"
1793
+ className={styles.primaryButton}
1794
+ onClick={start}
1795
+ disabled={starting}
1796
+ >
1797
+ {starting ? (
1798
+ <Loader2 size={14} className={styles.spin} aria-hidden="true" />
1799
+ ) : (
1800
+ <Send size={14} strokeWidth={2} aria-hidden="true" />
1801
+ )}
1802
+ Start sending
1803
+ </button>
1804
+ </>
1805
+ )}
1806
+ </div>
1807
+ </div>
1808
+ );
1809
+ };
1810
+
1811
+ /* --------------------------------------------------------------- the detail */
1812
+
1813
+ const CampaignDetail = ({
1814
+ routes,
1815
+ campaignId,
1816
+ canManage,
1817
+ inboxUrl,
1818
+ pollMs,
1819
+ navigate,
1820
+ onBack,
1821
+ }) => {
1822
+ const [campaign, setCampaign] = useState(null);
1823
+ const [recipients, setRecipients] = useState([]);
1824
+ const [meta, setMeta] = useState(null);
1825
+ const [status, setStatus] = useState('');
1826
+ const [page, setPage] = useState(1);
1827
+ const [loading, setLoading] = useState(true);
1828
+ const [acting, setActing] = useState(false);
1829
+
1830
+ const mountedRef = useRef(true);
1831
+
1832
+ useEffect(
1833
+ () => () => {
1834
+ mountedRef.current = false;
1835
+ },
1836
+ []
1837
+ );
1838
+
1839
+ const loadCampaign = useCallback(() => {
1840
+ CustomFetch(
1841
+ routes.campaign(campaignId),
1842
+ 'GET',
1843
+ null,
1844
+ (result) => {
1845
+ if (!mountedRef.current) return;
1846
+
1847
+ setCampaign(result?.campaign ?? null);
1848
+ setLoading(false);
1849
+ },
1850
+ () => {
1851
+ if (mountedRef.current) setLoading(false);
1852
+ }
1853
+ ).catch(() => {
1854
+ if (mountedRef.current) setLoading(false);
1855
+ });
1856
+ }, [routes, campaignId]);
1857
+
1858
+ const loadRecipients = useCallback(() => {
1859
+ CustomFetch(
1860
+ routes.campaignRecipients(campaignId),
1861
+ 'GET',
1862
+ { status: status === '' ? null : status, page },
1863
+ (result) => {
1864
+ if (!mountedRef.current) return;
1865
+
1866
+ setRecipients(Array.isArray(result?.recipients) ? result.recipients : []);
1867
+ setMeta(result?.meta ?? null);
1868
+ },
1869
+ () => {}
1870
+ ).catch(() => {});
1871
+ }, [routes, campaignId, status, page]);
1872
+
1873
+ useEffect(() => {
1874
+ loadCampaign();
1875
+ }, [loadCampaign]);
1876
+
1877
+ useEffect(() => {
1878
+ loadRecipients();
1879
+ }, [loadRecipients]);
1880
+
1881
+ // While it is sending, both halves move: the counters and the rows. The
1882
+ // poll stops the moment the campaign does, so a completed campaign left
1883
+ // open on somebody's second monitor costs nothing.
1884
+ const sending = campaign?.status === 'sending';
1885
+
1886
+ useEffect(() => {
1887
+ if (!sending || !pollMs) return undefined;
1888
+
1889
+ const handle = setInterval(() => {
1890
+ loadCampaign();
1891
+ loadRecipients();
1892
+ }, pollMs);
1893
+
1894
+ return () => clearInterval(handle);
1895
+ }, [sending, pollMs, loadCampaign, loadRecipients]);
1896
+
1897
+ /**
1898
+ * Every action is the same shape: post, then re-read the campaign.
1899
+ *
1900
+ * Nothing is painted ahead of the answer. A Pause that greyed itself out
1901
+ * and then lost a race with the sender would leave the screen saying
1902
+ * "paused" while messages carried on going out, which is the one lie this
1903
+ * page must never tell.
1904
+ */
1905
+ const act = (url, confirmation) => {
1906
+ const run = () => {
1907
+ setActing(true);
1908
+
1909
+ CustomFetch(
1910
+ url,
1911
+ 'POST',
1912
+ {},
1913
+ (result) => {
1914
+ if (!mountedRef.current) return;
1915
+
1916
+ setActing(false);
1917
+ setCampaign(result?.campaign ?? null);
1918
+ loadRecipients();
1919
+ },
1920
+ (message) => {
1921
+ if (!mountedRef.current) return;
1922
+
1923
+ setActing(false);
1924
+ toast.error(asPlainText(message) || 'That could not be done.');
1925
+ loadCampaign();
1926
+ }
1927
+ ).catch(() => {
1928
+ if (mountedRef.current) setActing(false);
1929
+ });
1930
+ };
1931
+
1932
+ if (!confirmation) {
1933
+ run();
1934
+
1935
+ return;
1936
+ }
1937
+
1938
+ showConfirmDialog({ ...confirmation, onConfirm: run });
1939
+ };
1940
+
1941
+ const remove = () => {
1942
+ showConfirmDialog({
1943
+ title: 'Delete this draft?',
1944
+ message: 'The recipient list goes with it. Nothing has been sent.',
1945
+ confirmLabel: 'Delete',
1946
+ cancelLabel: 'Keep it',
1947
+ data: { name: campaign?.name },
1948
+ onConfirm: () => {
1949
+ CustomFetch(
1950
+ routes.campaign(campaignId),
1951
+ 'DELETE',
1952
+ {},
1953
+ () => {
1954
+ toast.success('Draft deleted');
1955
+ onBack();
1956
+ },
1957
+ null
1958
+ ).catch(() => {});
1959
+ },
1960
+ });
1961
+ };
1962
+
1963
+ if (loading && campaign === null) {
1964
+ return (
1965
+ <div className={styles.tableEmpty}>
1966
+ <Loader2 size={18} className={styles.spin} aria-label="Loading" />
1967
+ </div>
1968
+ );
1969
+ }
1970
+
1971
+ if (campaign === null) {
1972
+ return (
1973
+ <div className={styles.tableEmpty}>
1974
+ <p>That campaign could not be opened. It may have been deleted.</p>
1975
+ <button type="button" className={styles.ghostButton} onClick={onBack}>
1976
+ <ArrowLeft size={14} strokeWidth={2} aria-hidden="true" />
1977
+ Back to campaigns
1978
+ </button>
1979
+ </div>
1980
+ );
1981
+ }
1982
+
1983
+ const counts = campaign.counts || {};
1984
+ const failed = Number(counts.failed) || 0;
1985
+
1986
+ return (
1987
+ <div className={styles.campaignDetail}>
1988
+ <div className={styles.sectionHead}>
1989
+ <div className={styles.campaignHeading}>
1990
+ <button type="button" className={styles.linkButton} onClick={onBack}>
1991
+ <ArrowLeft size={14} strokeWidth={2} aria-hidden="true" />
1992
+ Campaigns
1993
+ </button>
1994
+ <h2 className={styles.sectionTitle}>
1995
+ {campaign.name || 'Untitled campaign'}
1996
+ </h2>
1997
+ <p className={styles.help}>
1998
+ {campaign.line?.label || 'No line'}
1999
+ {campaign.line?.display_number
2000
+ ? ` · ${campaign.line.display_number}`
2001
+ : ''}
2002
+ {campaign.user?.name ? ` · created by ${campaign.user.name}` : ''}
2003
+ </p>
2004
+ </div>
2005
+
2006
+ <div className={styles.headActions}>
2007
+ <StatusChip status={campaign.status} />
2008
+
2009
+ {canManage && campaign.status === 'draft' && (
2010
+ <>
2011
+ <button
2012
+ type="button"
2013
+ className={styles.primaryButton}
2014
+ onClick={() => act(routes.campaignStart(campaign.id))}
2015
+ disabled={acting}
2016
+ >
2017
+ <Send size={14} strokeWidth={2} aria-hidden="true" />
2018
+ Start
2019
+ </button>
2020
+ <button
2021
+ type="button"
2022
+ className={styles.ghostButton}
2023
+ onClick={remove}
2024
+ >
2025
+ <Trash2 size={14} strokeWidth={2} aria-hidden="true" />
2026
+ Delete
2027
+ </button>
2028
+ </>
2029
+ )}
2030
+
2031
+ {canManage && campaign.status === 'sending' && (
2032
+ <>
2033
+ <button
2034
+ type="button"
2035
+ className={styles.ghostButton}
2036
+ onClick={() => act(routes.campaignPause(campaign.id))}
2037
+ disabled={acting}
2038
+ >
2039
+ <Pause size={14} strokeWidth={2} aria-hidden="true" />
2040
+ Pause
2041
+ </button>
2042
+ <button
2043
+ type="button"
2044
+ className={styles.ghostButton}
2045
+ onClick={() =>
2046
+ act(routes.campaignCancel(campaign.id), {
2047
+ title: 'Cancel this campaign?',
2048
+ message:
2049
+ 'Everybody still waiting is dropped. Messages already sent cannot be taken back.',
2050
+ confirmLabel: 'Cancel it',
2051
+ cancelLabel: 'Keep sending',
2052
+ })
2053
+ }
2054
+ disabled={acting}
2055
+ >
2056
+ <Ban size={14} strokeWidth={2} aria-hidden="true" />
2057
+ Cancel
2058
+ </button>
2059
+ </>
2060
+ )}
2061
+
2062
+ {canManage && campaign.status === 'paused' && (
2063
+ <>
2064
+ <button
2065
+ type="button"
2066
+ className={styles.primaryButton}
2067
+ onClick={() => act(routes.campaignStart(campaign.id))}
2068
+ disabled={acting}
2069
+ >
2070
+ <Play size={14} strokeWidth={2} aria-hidden="true" />
2071
+ Resume
2072
+ </button>
2073
+ <button
2074
+ type="button"
2075
+ className={styles.ghostButton}
2076
+ onClick={() =>
2077
+ act(routes.campaignCancel(campaign.id), {
2078
+ title: 'Cancel this campaign?',
2079
+ message:
2080
+ 'Everybody still waiting is dropped. Messages already sent cannot be taken back.',
2081
+ confirmLabel: 'Cancel it',
2082
+ cancelLabel: 'Keep it paused',
2083
+ })
2084
+ }
2085
+ disabled={acting}
2086
+ >
2087
+ <Ban size={14} strokeWidth={2} aria-hidden="true" />
2088
+ Cancel
2089
+ </button>
2090
+ </>
2091
+ )}
2092
+
2093
+ {canManage && campaign.status === 'completed' && failed > 0 && (
2094
+ <button
2095
+ type="button"
2096
+ className={styles.ghostButton}
2097
+ onClick={() => act(routes.campaignRetryFailed(campaign.id))}
2098
+ disabled={acting}
2099
+ >
2100
+ <RotateCcw size={14} strokeWidth={2} aria-hidden="true" />
2101
+ Retry {failed} failed
2102
+ </button>
2103
+ )}
2104
+ </div>
2105
+ </div>
2106
+
2107
+ {campaign.last_error && (
2108
+ <p className={`${styles.banner} ${styles.bannerBad}`} role="status">
2109
+ <TriangleAlert size={14} strokeWidth={2} aria-hidden="true" />
2110
+ <strong>Last error</strong>
2111
+ <span className={styles.bannerNote}>{campaign.last_error}</span>
2112
+ </p>
2113
+ )}
2114
+
2115
+ <ProgressBar campaign={campaign} />
2116
+
2117
+ <div className={styles.campaignFacts}>
2118
+ <div className={styles.statusItem}>
2119
+ <span className={styles.statusLabel}>Sent</span>
2120
+ <span className={styles.statusValue}>{counts.sent ?? 0}</span>
2121
+ </div>
2122
+ <div className={styles.statusItem}>
2123
+ <span className={styles.statusLabel}>Pending</span>
2124
+ <span className={styles.statusValue}>{counts.pending ?? 0}</span>
2125
+ </div>
2126
+ <div className={styles.statusItem}>
2127
+ <span className={styles.statusLabel}>Failed</span>
2128
+ <span className={styles.statusValue}>{counts.failed ?? 0}</span>
2129
+ </div>
2130
+ <div className={styles.statusItem}>
2131
+ <span className={styles.statusLabel}>Skipped</span>
2132
+ <span className={styles.statusValue}>{counts.skipped ?? 0}</span>
2133
+ </div>
2134
+
2135
+ {sending && typeof campaign.estimated_minutes_remaining === 'number' && (
2136
+ <p className={styles.statusMeaning}>
2137
+ About {campaign.estimated_minutes_remaining} minutes left at this
2138
+ line's sending rate.
2139
+ </p>
2140
+ )}
2141
+ </div>
2142
+
2143
+ {/* Folded, because the message is the thing everybody has already
2144
+ read and the counters are what they came back for. */}
2145
+ <details className={styles.campaignFold}>
2146
+ <summary>The message</summary>
2147
+ <p className={styles.campaignFoldBody}>{campaign.body}</p>
2148
+ {campaign.footer && (
2149
+ <p className={styles.help}>
2150
+ Footer: <span className={styles.campaignFooter}>{campaign.footer}</span>
2151
+ </p>
2152
+ )}
2153
+ </details>
2154
+
2155
+ <div className={styles.chips}>
2156
+ {RECIPIENT_FILTERS.map((one) => (
2157
+ <button
2158
+ key={one.id || 'all'}
2159
+ type="button"
2160
+ className={`${styles.chip} ${
2161
+ status === one.id ? styles.chipActive : ''
2162
+ }`}
2163
+ aria-pressed={status === one.id}
2164
+ onClick={() => {
2165
+ setStatus(one.id);
2166
+ setPage(1);
2167
+ }}
2168
+ >
2169
+ {one.label}
2170
+ </button>
2171
+ ))}
2172
+ </div>
2173
+
2174
+ <div className={styles.tableWrap}>
2175
+ <table className={styles.table}>
2176
+ <thead>
2177
+ <tr>
2178
+ <th>Name</th>
2179
+ <th>Mobile</th>
2180
+ <th>Status</th>
2181
+ <th>Error</th>
2182
+ <th>Sent</th>
2183
+ <th className={styles.actionsHead}>Actions</th>
2184
+ </tr>
2185
+ </thead>
2186
+ <tbody>
2187
+ {recipients.map((one) => (
2188
+ <tr key={one.id}>
2189
+ <td>
2190
+ <strong>
2191
+ {one.name || (
2192
+ <span className={styles.muted}>No name</span>
2193
+ )}
2194
+ </strong>
2195
+ </td>
2196
+ <td className={styles.numberCell}>
2197
+ {one.display_number ||
2198
+ normaliseNumberForDisplay(one.number)}
2199
+ </td>
2200
+ <td>
2201
+ <StatusChip
2202
+ status={one.status}
2203
+ table={RECIPIENT_STATUSES}
2204
+ />
2205
+ </td>
2206
+ <td>
2207
+ {one.error || <span className={styles.muted}>—</span>}
2208
+ </td>
2209
+ <td>
2210
+ {one.sent_at ? (
2211
+ relativeTime(one.sent_at)
2212
+ ) : (
2213
+ <span className={styles.muted}>—</span>
2214
+ )}
2215
+ </td>
2216
+ <td className={styles.actionsCell}>
2217
+ {one.thread_id ? (
2218
+ <a
2219
+ className={styles.linkButton}
2220
+ href={`${inboxUrl}?thread=${one.thread_id}`}
2221
+ onClick={(event) =>
2222
+ openInInbox(
2223
+ event,
2224
+ `${inboxUrl}?thread=${one.thread_id}`,
2225
+ navigate
2226
+ )
2227
+ }
2228
+ >
2229
+ Open conversation
2230
+ <ExternalLink
2231
+ size={11}
2232
+ strokeWidth={2.2}
2233
+ aria-hidden="true"
2234
+ />
2235
+ </a>
2236
+ ) : (
2237
+ <span className={styles.muted}>—</span>
2238
+ )}
2239
+ </td>
2240
+ </tr>
2241
+ ))}
2242
+
2243
+ {recipients.length === 0 && (
2244
+ <tr>
2245
+ <td colSpan={6} className={styles.tableEmpty}>
2246
+ <p>
2247
+ {status === ''
2248
+ ? 'No recipients on this campaign.'
2249
+ : `Nobody is ${status} on this campaign.`}
2250
+ </p>
2251
+ </td>
2252
+ </tr>
2253
+ )}
2254
+ </tbody>
2255
+ </table>
2256
+ </div>
2257
+
2258
+ {meta && Number(meta.last_page) > 1 && (
2259
+ <div className={styles.campaignPager}>
2260
+ <button
2261
+ type="button"
2262
+ className={styles.ghostButton}
2263
+ onClick={() => setPage((current) => Math.max(1, current - 1))}
2264
+ disabled={Number(meta.current_page) <= 1}
2265
+ >
2266
+ <ArrowLeft size={14} strokeWidth={2} aria-hidden="true" />
2267
+ Previous
2268
+ </button>
2269
+ <span className={styles.help}>
2270
+ Page {meta.current_page} of {meta.last_page} · {meta.total}{' '}
2271
+ recipients
2272
+ </span>
2273
+ <button
2274
+ type="button"
2275
+ className={styles.ghostButton}
2276
+ onClick={() =>
2277
+ setPage((current) =>
2278
+ Math.min(Number(meta.last_page) || 1, current + 1)
2279
+ )
2280
+ }
2281
+ disabled={Number(meta.current_page) >= Number(meta.last_page)}
2282
+ >
2283
+ Next
2284
+ <ArrowRight size={14} strokeWidth={2} aria-hidden="true" />
2285
+ </button>
2286
+ </div>
2287
+ )}
2288
+ </div>
2289
+ );
2290
+ };
2291
+
2292
+ /* ------------------------------------------------------------------ the page */
2293
+
2294
+ /**
2295
+ * Bulk SMS: a list of campaigns, the three-step flow that builds one, and one
2296
+ * campaign's own page.
2297
+ *
2298
+ * Three views in one component rather than three routed pages, because the
2299
+ * consumer mounts one screen and a campaign is a short-lived thing somebody
2300
+ * builds, starts and watches in a single sitting. Only the detail view is
2301
+ * worth a link, so only the detail view writes to the address bar —
2302
+ * `?campaign=<id>`, read once on mount and kept in step with
2303
+ * `history.replaceState`, exactly as `SmsInbox` handles `?thread=`. A
2304
+ * half-built campaign is not something a URL could restore, so the wizard's
2305
+ * step is deliberately not in it.
2306
+ *
2307
+ * Lines come from the same `GET lines` the inbox reads. The settings that
2308
+ * bound a campaign — the per-minute rate, the cap, the footer, the body limit
2309
+ * — ride the campaign LIST response rather than a second request, because
2310
+ * every one of them is a fact about the module rather than about a campaign
2311
+ * and the list is what this page fetches first anyway.
2312
+ */
2313
+ const SmsCampaignsInner = ({
2314
+ endpoints,
2315
+ // Accepted so the page matches the prop shape every other CRM screen is
2316
+ // mounted with, even though nothing here reads it.
2317
+ userProfile: _userProfile = null,
2318
+ canManage = true,
2319
+ pageTitle = 'SMS campaigns',
2320
+ inboxUrl = '/messages',
2321
+ pollMs = 5000,
2322
+ // Supplied by withVaultNavigation. Threaded down to CampaignDetail so a
2323
+ // recipient's "Open conversation" is a router navigation rather than a
2324
+ // new-tab hand-off — see the link itself for why that matters.
2325
+ navigate,
2326
+ locationSearch,
2327
+ }) => {
2328
+ const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
2329
+
2330
+ const [campaigns, setCampaigns] = useState([]);
2331
+ const [settings, setSettings] = useState(DEFAULT_CAMPAIGN_SETTINGS);
2332
+ const [lines, setLines] = useState([]);
2333
+ const [loading, setLoading] = useState(true);
2334
+ const [optOutsOpen, setOptOutsOpen] = useState(false);
2335
+
2336
+ const [openId, setOpenId] = useState(() => campaignIdFromSearch(locationSearch));
2337
+ const [view, setView] = useState(() =>
2338
+ campaignIdFromSearch(locationSearch) ? 'detail' : 'list'
2339
+ );
2340
+
2341
+ const mountedRef = useRef(true);
2342
+
2343
+ useEffect(
2344
+ () => () => {
2345
+ mountedRef.current = false;
2346
+ },
2347
+ []
2348
+ );
2349
+
2350
+ const loadCampaigns = useCallback(() => {
2351
+ CustomFetch(
2352
+ routes.campaigns,
2353
+ 'GET',
2354
+ null,
2355
+ (result) => {
2356
+ if (!mountedRef.current) return;
2357
+
2358
+ setCampaigns(Array.isArray(result?.campaigns) ? result.campaigns : []);
2359
+ setSettings({
2360
+ ...DEFAULT_CAMPAIGN_SETTINGS,
2361
+ ...(result?.settings || {}),
2362
+ });
2363
+ setLoading(false);
2364
+ },
2365
+ () => {
2366
+ if (mountedRef.current) setLoading(false);
2367
+ }
2368
+ ).catch(() => {
2369
+ if (mountedRef.current) setLoading(false);
2370
+ });
2371
+ }, [routes]);
2372
+
2373
+ useEffect(() => {
2374
+ loadCampaigns();
2375
+
2376
+ CustomFetch(
2377
+ routes.lines,
2378
+ 'GET',
2379
+ null,
2380
+ (result) => {
2381
+ if (!mountedRef.current) return;
2382
+
2383
+ // `{lines}` is the campaign contract's spelling and `{data}` is
2384
+ // what the inbox's own `GET lines` has always answered. Both
2385
+ // are read rather than one being picked, so this page works
2386
+ // against either without a second route.
2387
+ const answered = Array.isArray(result?.lines)
2388
+ ? result.lines
2389
+ : Array.isArray(result?.data)
2390
+ ? result.data
2391
+ : [];
2392
+
2393
+ setLines(answered);
2394
+ },
2395
+ () => {}
2396
+ ).catch(() => {});
2397
+ }, [routes, loadCampaigns]);
2398
+
2399
+ // The list only moves while something is sending, so that is the only time
2400
+ // it is polled. A page of finished campaigns makes no requests at all.
2401
+ const anySending = campaigns.some((one) => one.status === 'sending');
2402
+
2403
+ useEffect(() => {
2404
+ if (view !== 'list' || !anySending || !pollMs) return undefined;
2405
+
2406
+ const handle = setInterval(loadCampaigns, pollMs);
2407
+
2408
+ return () => clearInterval(handle);
2409
+ }, [view, anySending, pollMs, loadCampaigns]);
2410
+
2411
+ const openCampaign = useCallback((campaignId) => {
2412
+ setOpenId(campaignId);
2413
+ setView('detail');
2414
+ syncCampaignInUrl(campaignId);
2415
+ }, []);
2416
+
2417
+ const backToList = useCallback(() => {
2418
+ setOpenId(null);
2419
+ setView('list');
2420
+ syncCampaignInUrl(null);
2421
+ loadCampaigns();
2422
+ }, [loadCampaigns]);
2423
+
2424
+ return (
2425
+ <div className={styles.settings}>
2426
+ <div className={styles.settingsHead}>
2427
+ <div>
2428
+ <h1 className={styles.pageTitle}>
2429
+ <Megaphone size={22} strokeWidth={2.2} aria-hidden="true" />
2430
+ {pageTitle}
2431
+ </h1>
2432
+ <p className={styles.pageIntro}>
2433
+ One message to a list of numbers, sent one at a time from a line
2434
+ you choose. Replies come back to the inbox like any other
2435
+ conversation.
2436
+ </p>
2437
+ </div>
2438
+ </div>
2439
+
2440
+ {view === 'list' && (
2441
+ <CampaignList
2442
+ campaigns={campaigns}
2443
+ loading={loading}
2444
+ canManage={canManage}
2445
+ onNew={() => setView('new')}
2446
+ onOpen={openCampaign}
2447
+ onOptOuts={() => setOptOutsOpen(true)}
2448
+ />
2449
+ )}
2450
+
2451
+ {view === 'new' && (
2452
+ <CampaignWizard
2453
+ routes={routes}
2454
+ settings={settings}
2455
+ lines={lines}
2456
+ onCancel={backToList}
2457
+ onSaved={backToList}
2458
+ onOpen={openCampaign}
2459
+ />
2460
+ )}
2461
+
2462
+ {view === 'detail' && openId !== null && (
2463
+ <CampaignDetail
2464
+ routes={routes}
2465
+ campaignId={openId}
2466
+ canManage={canManage}
2467
+ inboxUrl={inboxUrl}
2468
+ pollMs={pollMs}
2469
+ navigate={navigate}
2470
+ onBack={backToList}
2471
+ />
2472
+ )}
2473
+
2474
+ {optOutsOpen && (
2475
+ <OptOutManager
2476
+ routes={routes}
2477
+ canManage={canManage}
2478
+ onClose={() => setOptOutsOpen(false)}
2479
+ />
2480
+ )}
2481
+ </div>
2482
+ );
2483
+ };
2484
+
2485
+ export const SmsCampaigns = withVaultNavigation(SmsCampaignsInner);
2486
+
2487
+ export default SmsCampaigns;