@visns-studio/visns-components 6.34.0 → 6.35.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 +37 -1
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -7
- package/src/components/auth/Login.jsx +2 -2
- package/src/components/auth/Reset.jsx +1 -1
- package/src/components/auth/TwoFactorAuth.jsx +1 -1
- package/src/components/auth/Verify.jsx +2 -2
- package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
- package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
- package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
- package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
- package/src/components/emailCampaigns/EmailLists.jsx +505 -0
- package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
- package/src/components/sms/SmsCampaigns.jsx +34 -4
- package/src/components/sms/SmsInbox.jsx +2 -5
- package/src/components/sms/SmsLineSettings.jsx +2 -5
- package/src/components/styles/EmailCampaigns.module.scss +922 -0
- package/src/components/styles/Sms.module.scss +33 -4
- package/src/components/styles/Vault.module.scss +5 -3
- package/src/components/styles/global.css +14 -2
- package/src/index.js +4 -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;
|