@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.
- package/README.md +504 -0
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -2
- package/src/components/Field.jsx +32 -0
- package/src/components/Form.jsx +39 -2
- package/src/components/Navigation.jsx +353 -43
- package/src/components/Notification.jsx +13 -2
- package/src/components/TableFilter.jsx +14 -2
- package/src/components/auth/Login.jsx +9 -2
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- package/src/components/auth/authEndpoints.js +43 -0
- package/src/components/auth/passkeyClient.js +102 -0
- package/src/components/auth/passkeyPrompt.js +408 -0
- package/src/components/auth/profileLayout.js +45 -0
- package/src/components/controls/DataGridSearch.jsx +16 -2
- package/src/components/controls/DataGridSortSheet.jsx +2 -0
- package/src/components/generic/ActionButtons.jsx +1 -1
- package/src/components/generic/GenericAuth.jsx +197 -21
- package/src/components/generic/GenericDetail.jsx +95 -3
- package/src/components/generic/GenericMain.jsx +7 -0
- package/src/components/generic/StandardModal.jsx +71 -158
- package/src/components/sms/SmsCampaigns.jsx +2487 -0
- package/src/components/sms/SmsInbox.jsx +18 -0
- package/src/components/sms/SmsThreadPanel.jsx +20 -0
- package/src/components/sms/smsEndpoints.js +23 -0
- package/src/components/styles/DataGrid.module.scss +22 -0
- package/src/components/styles/Form.module.scss +100 -2
- package/src/components/styles/Navigation.module.scss +354 -15
- package/src/components/styles/Notification.module.scss +140 -8
- package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
- package/src/components/styles/Profile.module.scss +157 -0
- package/src/components/styles/Sms.module.scss +451 -0
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/global.css +24 -5
- package/src/components/utils/navCollapsed.js +201 -0
- package/src/components/utils/rowActionSettings.js +76 -0
- package/src/components/utils/usePasskeysEnabled.js +84 -0
- package/src/components/utils/useShellLayout.js +131 -0
- package/src/index.js +103 -1
- 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;
|