@visns-studio/visns-components 6.32.3 → 6.34.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +483 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +274 -15
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +11 -4
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/Reset.jsx +1 -1
  13. package/src/components/auth/TwoFactorAuth.jsx +1 -1
  14. package/src/components/auth/Verify.jsx +2 -2
  15. package/src/components/auth/authEndpoints.js +43 -0
  16. package/src/components/auth/passkeyClient.js +102 -0
  17. package/src/components/auth/passkeyPrompt.js +408 -0
  18. package/src/components/auth/profileLayout.js +45 -0
  19. package/src/components/controls/DataGridSearch.jsx +16 -2
  20. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  21. package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
  22. package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
  23. package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
  24. package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
  25. package/src/components/emailCampaigns/EmailLists.jsx +505 -0
  26. package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
  27. package/src/components/generic/ActionButtons.jsx +1 -1
  28. package/src/components/generic/GenericAuth.jsx +197 -21
  29. package/src/components/generic/GenericDetail.jsx +95 -3
  30. package/src/components/generic/GenericMain.jsx +7 -0
  31. package/src/components/generic/StandardModal.jsx +71 -158
  32. package/src/components/sms/SmsCampaigns.jsx +2517 -0
  33. package/src/components/sms/SmsInbox.jsx +20 -5
  34. package/src/components/sms/SmsLineSettings.jsx +2 -5
  35. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  36. package/src/components/sms/smsEndpoints.js +23 -0
  37. package/src/components/styles/DataGrid.module.scss +22 -0
  38. package/src/components/styles/EmailCampaigns.module.scss +922 -0
  39. package/src/components/styles/Form.module.scss +100 -2
  40. package/src/components/styles/Navigation.module.scss +322 -5
  41. package/src/components/styles/Notification.module.scss +140 -8
  42. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  43. package/src/components/styles/Profile.module.scss +157 -0
  44. package/src/components/styles/Sms.module.scss +484 -4
  45. package/src/components/styles/StandardModal.module.scss +273 -0
  46. package/src/components/styles/Vault.module.scss +5 -3
  47. package/src/components/styles/global.css +38 -7
  48. package/src/components/utils/navCollapsed.js +201 -0
  49. package/src/components/utils/rowActionSettings.js +76 -0
  50. package/src/components/utils/usePasskeysEnabled.js +84 -0
  51. package/src/components/utils/useShellLayout.js +131 -0
  52. package/src/index.js +107 -1
  53. package/src/utils/rememberTab.js +70 -0
@@ -0,0 +1,98 @@
1
+ import React from 'react';
2
+
3
+ import StatStrip, { Stat } from '../layout/StatStrip';
4
+ import styles from '../styles/EmailCampaigns.module.scss';
5
+ import { formatWhen } from './emailCampaignApi';
6
+
7
+ /**
8
+ * How a sent campaign did: every figure is the server's (EmailCampaignReport),
9
+ * rates over DELIVERED and counted by person.
10
+ *
11
+ * Opens are labelled as an estimate on purpose. Apple Mail opens every message
12
+ * it downloads, and many clients block the tracking image, so an open rate is a
13
+ * trend rather than a count. Clicks are the reliable signal and lead the links
14
+ * table.
15
+ */
16
+ const ACTIVITY_WORDS = {
17
+ opened: 'opened it',
18
+ clicked: 'clicked',
19
+ bounced: 'bounced',
20
+ complained: 'marked it as spam',
21
+ };
22
+
23
+ const CampaignReport = ({ report }) => {
24
+ const percent = (value) => (value === null || value === undefined ? '-' : `${value}%`);
25
+
26
+ return (
27
+ <section className={styles.report}>
28
+ <StatStrip minWidth="8rem">
29
+ <Stat label="Sent to" value={report.recipients ?? '-'} />
30
+ <Stat label="Delivered" value={report.delivered} />
31
+ <Stat label="Opened" value={percent(report.open_rate)} note={`${report.opened} people`} />
32
+ <Stat label="Clicked" value={percent(report.click_rate)} note={`${report.clicked} people`} tone="accent" />
33
+ <Stat label="Bounced" value={report.bounced} tone={report.bounced > 0 ? 'warn' : undefined} />
34
+ <Stat label="Unsubscribed" value={report.unsubscribed} />
35
+ <Stat label="Spam reports" value={report.complained} tone={report.complained > 0 ? "danger" : undefined} />
36
+ </StatStrip>
37
+
38
+ <p className={styles.hint}>
39
+ Open rates are an estimate: some mail apps open every message automatically and others block the
40
+ tracking. Clicks are the reliable measure. Figures arrive from Resend as people read, so they keep
41
+ moving for a few days.
42
+ </p>
43
+
44
+ <div className={styles.reportGrid}>
45
+ <div className={styles.card}>
46
+ <h3 className={styles.cardTitle}>Clicks by link</h3>
47
+ {report.links?.length ? (
48
+ <table className={styles.table}>
49
+ <thead>
50
+ <tr>
51
+ <th>Link</th>
52
+ <th className={styles.num}>People</th>
53
+ <th className={styles.num}>Clicks</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ {report.links.map((link) => (
58
+ <tr key={link.url}>
59
+ <td className={styles.linkCell}>
60
+ <a href={link.url} target="_blank" rel="noreferrer noopener">
61
+ {link.url}
62
+ </a>
63
+ </td>
64
+ <td className={styles.num}>{link.people}</td>
65
+ <td className={styles.num}>{link.clicks}</td>
66
+ </tr>
67
+ ))}
68
+ </tbody>
69
+ </table>
70
+ ) : (
71
+ <p className={styles.quiet}>Nobody has clicked a link yet.</p>
72
+ )}
73
+ </div>
74
+
75
+ <div className={styles.card}>
76
+ <h3 className={styles.cardTitle}>Recent activity</h3>
77
+ {report.activity?.length ? (
78
+ <ul className={styles.activity}>
79
+ {report.activity.map((event, index) => (
80
+ <li key={`${event.email}-${event.at}-${index}`}>
81
+ <strong>{event.email}</strong> {ACTIVITY_WORDS[event.type] || event.type}
82
+ {event.type === 'clicked' && event.link ? (
83
+ <span className={styles.quiet}> {event.link}</span>
84
+ ) : null}
85
+ <span className={styles.activityWhen}>{formatWhen(event.at)}</span>
86
+ </li>
87
+ ))}
88
+ </ul>
89
+ ) : (
90
+ <p className={styles.quiet}>Nothing yet. Activity appears here as people open and click.</p>
91
+ )}
92
+ </div>
93
+ </div>
94
+ </section>
95
+ );
96
+ };
97
+
98
+ export default CampaignReport;
@@ -0,0 +1,422 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { ChevronRight, Mail, Plus, Trash2 } from 'lucide-react';
3
+ import { toast } from 'react-toastify';
4
+
5
+ import PageHeader from '../layout/PageHeader';
6
+ import TabStrip from '../navigation/TabStrip';
7
+ import StandardModal from '../generic/StandardModal';
8
+ import { showConfirmDialog } from '../generic/ConfirmationDialog';
9
+ import styles from '../styles/EmailCampaigns.module.scss';
10
+ import CampaignEditor from './CampaignEditor';
11
+ import EmailLists from './EmailLists';
12
+ import { api, formatWhen, resolveEmailCampaignEndpoints, STATUS_LABELS } from './emailCampaignApi';
13
+
14
+ /**
15
+ * Email campaigns: newsletters and announcements sent as Resend Broadcasts.
16
+ *
17
+ * The package module (visns-packages `email_campaigns`) owns the lists, the
18
+ * rendering, the sending and the numbers; this screen is the editor and the
19
+ * reports. Three tabs — Campaigns, Lists, Templates — and a campaign or list
20
+ * opens over them, with its id in the address bar (`?campaign=12`, `?list=4`)
21
+ * so a refresh or a shared link lands on the same thing.
22
+ *
23
+ * @param {string} base the module's base URI (`email_campaigns.uris.base`)
24
+ * @param {boolean} canManage the `manage` permission: create, edit, send
25
+ * @param {string} pageTitle
26
+ */
27
+ const readParam = (key) => {
28
+ if (typeof window === 'undefined') return null;
29
+
30
+ return new URLSearchParams(window.location.search).get(key);
31
+ };
32
+
33
+ const writeParams = (params) => {
34
+ if (typeof window === 'undefined') return;
35
+
36
+ const search = new URLSearchParams(window.location.search);
37
+
38
+ Object.entries(params).forEach(([key, value]) => {
39
+ if (value === null || value === undefined || value === '') {
40
+ search.delete(key);
41
+ } else {
42
+ search.set(key, String(value));
43
+ }
44
+ });
45
+
46
+ const query = search.toString();
47
+ window.history.replaceState(window.history.state, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`);
48
+ };
49
+
50
+ const EmailCampaigns = ({ base = '/ajax/email-campaigns', canManage = false, pageTitle = 'Email campaigns' }) => {
51
+ const routes = useMemo(() => resolveEmailCampaignEndpoints(base), [base]);
52
+
53
+ const [data, setData] = useState(null);
54
+ const [failed, setFailed] = useState('');
55
+ const [tab, setTab] = useState(() => readParam('tab') || 'campaigns');
56
+ const [campaignId, setCampaignId] = useState(() => Number(readParam('campaign')) || null);
57
+ const [listId, setListId] = useState(() => Number(readParam('list')) || null);
58
+ const [creating, setCreating] = useState(false);
59
+
60
+ const load = useCallback(async () => {
61
+ const result = await api(routes.index);
62
+
63
+ if (result.ok) {
64
+ setData(result.data);
65
+ setFailed('');
66
+ } else {
67
+ setFailed(result.message || 'Email campaigns could not be loaded.');
68
+ }
69
+ }, [routes]);
70
+
71
+ useEffect(() => {
72
+ load();
73
+ }, [load]);
74
+
75
+ // While a list is copying, check back every 15 seconds so the row moves.
76
+ useEffect(() => {
77
+ if (!data?.lists?.some((list) => list.syncing)) return undefined;
78
+
79
+ const timer = setInterval(load, 15000);
80
+
81
+ return () => clearInterval(timer);
82
+ }, [data, load]);
83
+
84
+ const openCampaign = (id) => {
85
+ setCampaignId(id || null);
86
+ writeParams({ campaign: id || null });
87
+ };
88
+
89
+ const openList = (id) => {
90
+ setListId(id || null);
91
+ writeParams({ list: id || null });
92
+ };
93
+
94
+ const changeTab = (key) => {
95
+ setTab(key);
96
+ writeParams({ tab: key === 'campaigns' ? null : key });
97
+ };
98
+
99
+ const settings = data?.settings;
100
+ const lists = data?.lists || [];
101
+ const campaign = campaignId ? data?.campaigns?.find((entry) => entry.id === campaignId) : null;
102
+
103
+ if (failed && !data) {
104
+ return <p className={styles.state}>{failed}</p>;
105
+ }
106
+
107
+ return (
108
+ <div className={styles.page}>
109
+ <PageHeader
110
+ align="center"
111
+ gutter
112
+ actions={
113
+ canManage && !campaignId ? (
114
+ <button
115
+ type="button"
116
+ className={styles.buttonPrimary}
117
+ title="Start a new email campaign"
118
+ onClick={() => setCreating(true)}
119
+ >
120
+ <Plus size={14} aria-hidden="true" />
121
+ New campaign
122
+ </button>
123
+ ) : null
124
+ }
125
+ >
126
+ {/* The same breadcrumb shape every record page wears: the
127
+ section, then the campaign that is open. */}
128
+ <nav aria-label="Breadcrumb">
129
+ <h1 className={styles.trail}>
130
+ {campaignId ? (
131
+ <>
132
+ <button
133
+ type="button"
134
+ className={styles.trailParent}
135
+ title="Back to all campaigns"
136
+ onClick={() => openCampaign(null)}
137
+ >
138
+ {pageTitle}
139
+ </button>
140
+ <ChevronRight className={styles.trailChevron} strokeWidth={2} size={16} aria-hidden="true" />
141
+ <span aria-current="page">{campaign?.name || 'Campaign'}</span>
142
+ </>
143
+ ) : (
144
+ <span>{pageTitle}</span>
145
+ )}
146
+ </h1>
147
+ </nav>
148
+ </PageHeader>
149
+
150
+ {settings && !settings.connected && (
151
+ <div className={`${styles.notice} ${styles.notice_warn}`}>
152
+ <p>
153
+ Not connected to Resend yet: set RESEND_KEY. You can build lists and campaigns, but nothing
154
+ can be copied or sent until it is.
155
+ </p>
156
+ </div>
157
+ )}
158
+
159
+ {settings && settings.connected && !settings.webhook && (
160
+ <div className={`${styles.notice} ${styles.notice_warn}`}>
161
+ <p>
162
+ Resend's webhook is not set up (RESEND_WEBHOOK_SECRET), so campaigns will send but opens, clicks
163
+ and unsubscribes will not be reported here.
164
+ </p>
165
+ </div>
166
+ )}
167
+
168
+ {!data ? (
169
+ <p className={styles.state}>Loading…</p>
170
+ ) : campaignId ? (
171
+ <CampaignEditor
172
+ key={campaignId}
173
+ id={campaignId}
174
+ routes={routes}
175
+ canManage={canManage}
176
+ settings={settings}
177
+ lists={lists}
178
+ onChanged={load}
179
+ onBack={(nextId) => openCampaign(nextId || null)}
180
+ />
181
+ ) : (
182
+ <>
183
+ <TabStrip
184
+ ariaLabel="Email campaign sections"
185
+ active={tab}
186
+ onChange={changeTab}
187
+ tabs={[
188
+ { key: 'campaigns', label: 'Campaigns', badge: data.campaigns.length || undefined },
189
+ { key: 'lists', label: 'Lists', badge: lists.length || undefined },
190
+ { key: 'templates', label: 'Templates' },
191
+ ]}
192
+ />
193
+
194
+ {tab === 'lists' ? (
195
+ <EmailLists
196
+ lists={lists}
197
+ routes={routes}
198
+ canManage={canManage}
199
+ settings={settings}
200
+ onChanged={load}
201
+ openListId={listId}
202
+ onOpenList={openList}
203
+ />
204
+ ) : tab === 'templates' ? (
205
+ <Templates routes={routes} canManage={canManage} />
206
+ ) : (
207
+ <CampaignList campaigns={data.campaigns} onOpen={openCampaign} />
208
+ )}
209
+ </>
210
+ )}
211
+
212
+ {creating && (
213
+ <NewCampaignDialog
214
+ routes={routes}
215
+ lists={lists}
216
+ onClose={() => setCreating(false)}
217
+ onCreated={(created) => {
218
+ setCreating(false);
219
+ load();
220
+ openCampaign(created.id);
221
+ }}
222
+ />
223
+ )}
224
+ </div>
225
+ );
226
+ };
227
+
228
+ const CampaignList = ({ campaigns, onOpen }) =>
229
+ campaigns.length === 0 ? (
230
+ <div className={styles.empty}>
231
+ <Mail size={28} aria-hidden="true" />
232
+ <p>No campaigns yet. Press New campaign to write the first one.</p>
233
+ </div>
234
+ ) : (
235
+ <table className={styles.table}>
236
+ <thead>
237
+ <tr>
238
+ <th>Campaign</th>
239
+ <th>Status</th>
240
+ <th>List</th>
241
+ <th>When</th>
242
+ <th className={styles.num}>Sent to</th>
243
+ <th className={styles.num}>Opened</th>
244
+ <th className={styles.num}>Clicked</th>
245
+ </tr>
246
+ </thead>
247
+ <tbody>
248
+ {campaigns.map((campaign) => {
249
+ const status = STATUS_LABELS[campaign.status] || { label: campaign.status, tone: 'quiet' };
250
+
251
+ return (
252
+ <tr key={campaign.id} className={styles.clickRow} onClick={() => onOpen(campaign.id)}>
253
+ <td>
254
+ <button type="button" className={styles.rowLink} title={`Open ${campaign.name}`}>
255
+ {campaign.name}
256
+ </button>
257
+ {campaign.subject && <div className={styles.quiet}>{campaign.subject}</div>}
258
+ </td>
259
+ <td>
260
+ <span className={`${styles.pill} ${styles[`tone_${status.tone}`]}`}>{status.label}</span>
261
+ </td>
262
+ <td>{campaign.list?.name || '-'}</td>
263
+ <td>
264
+ {formatWhen(campaign.sent_at || campaign.scheduled_at) || (
265
+ <span className={styles.quiet}>Edited {formatWhen(campaign.updated_at)}</span>
266
+ )}
267
+ </td>
268
+ <td className={styles.num}>{campaign.recipients ?? '-'}</td>
269
+ <td className={styles.num}>{campaign.open_rate === null || campaign.open_rate === undefined ? '-' : `${campaign.open_rate}%`}</td>
270
+ <td className={styles.num}>{campaign.click_rate === null || campaign.click_rate === undefined ? '-' : `${campaign.click_rate}%`}</td>
271
+ </tr>
272
+ );
273
+ })}
274
+ </tbody>
275
+ </table>
276
+ );
277
+
278
+ const NewCampaignDialog = ({ routes, lists, onClose, onCreated }) => {
279
+ const [name, setName] = useState('');
280
+ const [listId, setListId] = useState('');
281
+ const [templates, setTemplates] = useState([]);
282
+ const [templateId, setTemplateId] = useState('');
283
+ const [busy, setBusy] = useState(false);
284
+ const [error, setError] = useState('');
285
+
286
+ useEffect(() => {
287
+ api(routes.templates).then((result) => setTemplates(result.data?.templates || []));
288
+ }, [routes]);
289
+
290
+ const create = async () => {
291
+ setBusy(true);
292
+ const result = await api(routes.campaigns, 'POST', {
293
+ name: name.trim(),
294
+ list_id: listId ? Number(listId) : null,
295
+ template_id: templateId ? Number(templateId) : null,
296
+ });
297
+ setBusy(false);
298
+
299
+ if (result.ok && result.data?.campaign) {
300
+ onCreated(result.data.campaign);
301
+ } else {
302
+ setError(result.message || 'The campaign could not be created.');
303
+ }
304
+ };
305
+
306
+ return (
307
+ <StandardModal isOpen onClose={onClose} title="New campaign" size="small" showHeader>
308
+ <div className={styles.dialog}>
309
+ {error && <p className={styles.fieldError}>{error}</p>}
310
+ <label className={styles.field}>
311
+ <span className={styles.fieldLabel}>Name</span>
312
+ <input
313
+ className={styles.input}
314
+ value={name}
315
+ maxLength={191}
316
+ placeholder="September newsletter"
317
+ autoFocus
318
+ onChange={(event) => setName(event.target.value)}
319
+ />
320
+ </label>
321
+ <label className={styles.field}>
322
+ <span className={styles.fieldLabel}>Send to</span>
323
+ <select className={styles.select} value={listId} onChange={(event) => setListId(event.target.value)}>
324
+ <option value="">Choose later</option>
325
+ {lists.map((list) => (
326
+ <option key={list.id} value={list.id}>
327
+ {list.name}
328
+ </option>
329
+ ))}
330
+ </select>
331
+ </label>
332
+ <label className={styles.field}>
333
+ <span className={styles.fieldLabel}>Start from</span>
334
+ <select className={styles.select} value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
335
+ <option value="">A simple starter layout</option>
336
+ {templates.map((template) => (
337
+ <option key={template.id} value={template.id}>
338
+ {template.name}
339
+ </option>
340
+ ))}
341
+ </select>
342
+ </label>
343
+ <div className={styles.dialogActions}>
344
+ <button type="button" className={styles.linkButton} onClick={onClose}>
345
+ Cancel
346
+ </button>
347
+ <button type="button" className={styles.buttonPrimary} disabled={busy || name.trim() === ''} onClick={create}>
348
+ Create and edit
349
+ </button>
350
+ </div>
351
+ </div>
352
+ </StandardModal>
353
+ );
354
+ };
355
+
356
+ const Templates = ({ routes, canManage }) => {
357
+ const [templates, setTemplates] = useState(null);
358
+
359
+ const load = useCallback(() => api(routes.templates).then((result) => setTemplates(result.data?.templates || [])), [routes]);
360
+
361
+ useEffect(() => {
362
+ load();
363
+ }, [load]);
364
+
365
+ const remove = (template) =>
366
+ showConfirmDialog({
367
+ title: 'Delete this template?',
368
+ message: 'Campaigns already made from it are not affected.',
369
+ confirmLabel: 'Delete',
370
+ cancelLabel: 'Keep it',
371
+ onConfirm: async () => {
372
+ const result = await api(routes.template(template.id), 'DELETE');
373
+ result.ok ? load() : toast.error(result.message || 'It could not be deleted.');
374
+ },
375
+ });
376
+
377
+ if (templates === null) return <p className={styles.state}>Loading…</p>;
378
+
379
+ return (
380
+ <div className={styles.tabBody}>
381
+ <p className={styles.quiet}>Save any campaign's content as a template from the campaign itself, then start new ones from it.</p>
382
+ {templates.length === 0 ? (
383
+ <p className={styles.state}>No templates yet.</p>
384
+ ) : (
385
+ <table className={styles.table}>
386
+ <thead>
387
+ <tr>
388
+ <th>Template</th>
389
+ <th className={styles.num}>Blocks</th>
390
+ <th>Updated</th>
391
+ <th aria-label="Actions" />
392
+ </tr>
393
+ </thead>
394
+ <tbody>
395
+ {templates.map((template) => (
396
+ <tr key={template.id}>
397
+ <td>{template.name}</td>
398
+ <td className={styles.num}>{template.blocks}</td>
399
+ <td>{formatWhen(template.updated_at)}</td>
400
+ <td className={styles.num}>
401
+ {canManage && (
402
+ <button
403
+ type="button"
404
+ className={`${styles.iconAction} ${styles.iconDanger}`}
405
+ title={`Delete the ${template.name} template`}
406
+ aria-label={`Delete ${template.name}`}
407
+ onClick={() => remove(template)}
408
+ >
409
+ <Trash2 size={14} aria-hidden="true" />
410
+ </button>
411
+ )}
412
+ </td>
413
+ </tr>
414
+ ))}
415
+ </tbody>
416
+ </table>
417
+ )}
418
+ </div>
419
+ );
420
+ };
421
+
422
+ export default EmailCampaigns;