@vertebr/react 0.4.0 → 0.5.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/dist/components/admin/AdminContent.d.ts +36 -0
- package/dist/components/admin/AdminContent.d.ts.map +1 -0
- package/dist/components/admin/AdminContent.js +107 -0
- package/dist/components/admin/AdminHeader.d.ts +13 -0
- package/dist/components/admin/AdminHeader.d.ts.map +1 -0
- package/dist/components/admin/AdminHeader.js +32 -0
- package/dist/components/admin/AdminSidebar.d.ts +25 -0
- package/dist/components/admin/AdminSidebar.d.ts.map +1 -0
- package/dist/components/admin/AdminSidebar.js +31 -0
- package/dist/components/admin/VertebrAdmin.d.ts +3 -18
- package/dist/components/admin/VertebrAdmin.d.ts.map +1 -1
- package/dist/components/admin/VertebrAdmin.js +130 -120
- package/dist/components/admin/sidebar.d.ts +3 -0
- package/dist/components/admin/sidebar.d.ts.map +1 -0
- package/dist/components/admin/sidebar.js +13 -0
- package/dist/components/admin/storage.d.ts +4 -0
- package/dist/components/admin/storage.d.ts.map +1 -0
- package/dist/components/admin/storage.js +24 -0
- package/dist/components/admin/types.d.ts +53 -0
- package/dist/components/admin/types.d.ts.map +1 -0
- package/dist/components/admin/types.js +1 -0
- package/dist/components/config/form/ConfigForm.d.ts +1 -1
- package/dist/components/config/form/ConfigForm.d.ts.map +1 -1
- package/dist/components/config/form/ConfigForm.js +5 -1
- package/dist/components/config/form/RecordConfigForm.d.ts +1 -0
- package/dist/components/config/form/RecordConfigForm.d.ts.map +1 -1
- package/dist/components/config/form/SettingsConfigForm.d.ts +2 -1
- package/dist/components/config/form/SettingsConfigForm.d.ts.map +1 -1
- package/dist/components/config/form/SettingsConfigForm.js +2 -2
- package/dist/components/config/form/helpers/fields.js +1 -1
- package/dist/components/config/form/types.d.ts +4 -0
- package/dist/components/config/form/types.d.ts.map +1 -1
- package/dist/components/emails/EmailRecipientSelect.d.ts +16 -0
- package/dist/components/emails/EmailRecipientSelect.d.ts.map +1 -0
- package/dist/components/emails/EmailRecipientSelect.js +10 -0
- package/dist/components/emails/EmailsActivityPanel.d.ts +11 -0
- package/dist/components/emails/EmailsActivityPanel.d.ts.map +1 -0
- package/dist/components/emails/EmailsActivityPanel.js +139 -0
- package/dist/components/emails/EmailsBoard.d.ts +47 -0
- package/dist/components/emails/EmailsBoard.d.ts.map +1 -0
- package/dist/components/emails/EmailsBoard.js +594 -0
- package/dist/components/emails/EmailsLibraryPanel.d.ts +25 -0
- package/dist/components/emails/EmailsLibraryPanel.d.ts.map +1 -0
- package/dist/components/emails/EmailsLibraryPanel.js +379 -0
- package/dist/components/emails/EmailsNavSelect.d.ts +18 -0
- package/dist/components/emails/EmailsNavSelect.d.ts.map +1 -0
- package/dist/components/emails/EmailsNavSelect.js +37 -0
- package/dist/components/emails/MultiCheckSelect.d.ts +18 -0
- package/dist/components/emails/MultiCheckSelect.d.ts.map +1 -0
- package/dist/components/emails/MultiCheckSelect.js +74 -0
- package/dist/components/metrics/MetricsBoard.d.ts +8 -2
- package/dist/components/metrics/MetricsBoard.d.ts.map +1 -1
- package/dist/components/metrics/MetricsBoard.js +55 -2
- package/dist/components/metrics/charts.d.ts +14 -0
- package/dist/components/metrics/charts.d.ts.map +1 -0
- package/dist/components/metrics/charts.js +212 -0
- package/dist/components/resources/ResourceActionModal.d.ts +14 -0
- package/dist/components/resources/ResourceActionModal.d.ts.map +1 -0
- package/dist/components/resources/ResourceActionModal.js +64 -0
- package/dist/components/resources/ResourceCell.d.ts.map +1 -1
- package/dist/components/resources/ResourceCell.js +25 -9
- package/dist/components/resources/ResourceMailModal.d.ts +12 -0
- package/dist/components/resources/ResourceMailModal.d.ts.map +1 -0
- package/dist/components/resources/ResourceMailModal.js +196 -0
- package/dist/components/resources/ResourceTable.d.ts +7 -3
- package/dist/components/resources/ResourceTable.d.ts.map +1 -1
- package/dist/components/resources/ResourceTable.js +204 -34
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/lib/metricsRanges.d.ts +20 -0
- package/dist/lib/metricsRanges.d.ts.map +1 -0
- package/dist/lib/metricsRanges.js +14 -0
- package/dist/styles/cells.css +42 -0
- package/dist/styles/emails.css +632 -0
- package/dist/styles/index.css +3 -0
- package/dist/styles/mail.css +240 -0
- package/dist/styles/metrics.css +633 -0
- package/dist/styles/nav.css +103 -1
- package/dist/styles/panel.css +2 -68
- package/dist/styles/table.css +26 -3
- package/package.json +1 -1
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { EnumInput } from "../config/EnumInput";
|
|
4
|
+
import { actionInputDefaults } from "../resources/ResourceActionModal";
|
|
5
|
+
import { EmailRecipientSelect, } from "./EmailRecipientSelect";
|
|
6
|
+
import { EmailsActivityPanel } from "./EmailsActivityPanel";
|
|
7
|
+
import { EmailsLibraryPanel } from "./EmailsLibraryPanel";
|
|
8
|
+
import { MultiCheckSelect } from "./MultiCheckSelect";
|
|
9
|
+
const FREEFORM_PRESETS = new Set(["custom", "promo", "custom_discount"]);
|
|
10
|
+
const DISCOUNT_PRESETS = new Set([
|
|
11
|
+
"discount",
|
|
12
|
+
"welcome_offer",
|
|
13
|
+
"flash_sale",
|
|
14
|
+
"custom_discount",
|
|
15
|
+
]);
|
|
16
|
+
const DISCOUNT_FIELD_KEYS = new Set([
|
|
17
|
+
"discountId",
|
|
18
|
+
"discountCode",
|
|
19
|
+
"discountOffer",
|
|
20
|
+
"discountExpires",
|
|
21
|
+
]);
|
|
22
|
+
function fieldValue(value) {
|
|
23
|
+
return typeof value === "string" ? value : String(value ?? "");
|
|
24
|
+
}
|
|
25
|
+
function isLibraryPreset(preset) {
|
|
26
|
+
return preset.startsWith("library:");
|
|
27
|
+
}
|
|
28
|
+
function isFreeformPreset(preset) {
|
|
29
|
+
return FREEFORM_PRESETS.has(preset) || isLibraryPreset(preset);
|
|
30
|
+
}
|
|
31
|
+
function isDiscountPreset(preset, library) {
|
|
32
|
+
if (DISCOUNT_PRESETS.has(preset))
|
|
33
|
+
return true;
|
|
34
|
+
if (!isLibraryPreset(preset))
|
|
35
|
+
return false;
|
|
36
|
+
const id = preset.slice("library:".length);
|
|
37
|
+
return library.some((item) => item.id === id && item.kind === "discount");
|
|
38
|
+
}
|
|
39
|
+
function isMultiline(key, field) {
|
|
40
|
+
const hay = `${key} ${field.label ?? ""}`;
|
|
41
|
+
return field.type === "richtext" || /(body|message|html|notes)/i.test(hay);
|
|
42
|
+
}
|
|
43
|
+
function needsFreeform(schema, value) {
|
|
44
|
+
if (!schema.preset)
|
|
45
|
+
return true;
|
|
46
|
+
const preset = fieldValue(value.preset);
|
|
47
|
+
return !preset || isFreeformPreset(preset);
|
|
48
|
+
}
|
|
49
|
+
function fieldVisible(key, schema, value, library = []) {
|
|
50
|
+
if (key === "subject" || key === "body") {
|
|
51
|
+
return needsFreeform(schema, value);
|
|
52
|
+
}
|
|
53
|
+
if (DISCOUNT_FIELD_KEYS.has(key)) {
|
|
54
|
+
const preset = fieldValue(value.preset);
|
|
55
|
+
if (!isDiscountPreset(preset, library))
|
|
56
|
+
return false;
|
|
57
|
+
if (key === "discountId" && !(schema.discountId?.options?.length)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
function requiredFilled(schema, value, library = []) {
|
|
65
|
+
return Object.entries(schema).every(([key, field]) => {
|
|
66
|
+
if (!fieldVisible(key, schema, value, library))
|
|
67
|
+
return true;
|
|
68
|
+
const freeform = (key === "subject" || key === "body") && needsFreeform(schema, value);
|
|
69
|
+
const discountRequired = (key === "discountCode" || key === "discountOffer") &&
|
|
70
|
+
isDiscountPreset(fieldValue(value.preset), library);
|
|
71
|
+
if (field.required === false && !freeform && !discountRequired)
|
|
72
|
+
return true;
|
|
73
|
+
if (field.type === "enum[]") {
|
|
74
|
+
if (field.required === false)
|
|
75
|
+
return true;
|
|
76
|
+
return Array.isArray(value[key]) && value[key].length > 0;
|
|
77
|
+
}
|
|
78
|
+
if (field.type !== "string" &&
|
|
79
|
+
field.type !== "richtext" &&
|
|
80
|
+
field.type !== "enum") {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return Boolean(fieldValue(value[key]).trim());
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function normalizeInputSchema(input) {
|
|
87
|
+
if (!input)
|
|
88
|
+
return {};
|
|
89
|
+
return Object.fromEntries(Object.entries(input).map(([key, def]) => {
|
|
90
|
+
if (typeof def === "string") {
|
|
91
|
+
return [key, { type: def, label: key, required: true }];
|
|
92
|
+
}
|
|
93
|
+
return [key, def];
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
function enrichComposeSchema(base, library, products, discounts) {
|
|
97
|
+
const next = { ...base };
|
|
98
|
+
if (next.preset?.type === "enum" && library.length > 0) {
|
|
99
|
+
const libraryIds = library.map((item) => `library:${item.id}`);
|
|
100
|
+
const libraryLabels = Object.fromEntries(library.map((item) => [`library:${item.id}`, item.label]));
|
|
101
|
+
const options = [...(next.preset.options ?? [])];
|
|
102
|
+
for (const id of libraryIds) {
|
|
103
|
+
if (!options.includes(id))
|
|
104
|
+
options.push(id);
|
|
105
|
+
}
|
|
106
|
+
next.preset = {
|
|
107
|
+
...next.preset,
|
|
108
|
+
options,
|
|
109
|
+
optionLabels: {
|
|
110
|
+
...(next.preset.optionLabels ?? {}),
|
|
111
|
+
...libraryLabels,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (next.productIds && products && products.length > 0) {
|
|
116
|
+
const options = products.map((item) => item.id);
|
|
117
|
+
const optionLabels = Object.fromEntries(products.map((item) => [item.id, item.label]));
|
|
118
|
+
next.productIds = {
|
|
119
|
+
...next.productIds,
|
|
120
|
+
type: "enum[]",
|
|
121
|
+
options,
|
|
122
|
+
optionLabels: {
|
|
123
|
+
...(next.productIds.optionLabels ?? {}),
|
|
124
|
+
...optionLabels,
|
|
125
|
+
},
|
|
126
|
+
default: Array.isArray(next.productIds.default)
|
|
127
|
+
? next.productIds.default
|
|
128
|
+
: [],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (next.discountId && discounts && discounts.length > 0) {
|
|
132
|
+
const options = discounts.map((item) => item.id);
|
|
133
|
+
const optionLabels = Object.fromEntries(discounts.map((item) => [
|
|
134
|
+
item.id,
|
|
135
|
+
item.offerLabel
|
|
136
|
+
? `${item.label}${item.code ? ` (${item.code})` : ""}`
|
|
137
|
+
: item.label,
|
|
138
|
+
]));
|
|
139
|
+
next.discountId = {
|
|
140
|
+
...next.discountId,
|
|
141
|
+
type: "enum",
|
|
142
|
+
options,
|
|
143
|
+
optionLabels: {
|
|
144
|
+
...(next.discountId.optionLabels ?? {}),
|
|
145
|
+
...optionLabels,
|
|
146
|
+
},
|
|
147
|
+
required: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return next;
|
|
151
|
+
}
|
|
152
|
+
function composeDefaults(schema) {
|
|
153
|
+
const base = actionInputDefaults(schema);
|
|
154
|
+
for (const [key, field] of Object.entries(schema)) {
|
|
155
|
+
if (field.type === "enum[]" && !Array.isArray(base[key])) {
|
|
156
|
+
base[key] = Array.isArray(field.default) ? field.default : [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return base;
|
|
160
|
+
}
|
|
161
|
+
function applyLibraryPreset(draft, preset, library, schema) {
|
|
162
|
+
if (!isLibraryPreset(preset))
|
|
163
|
+
return draft;
|
|
164
|
+
const id = preset.slice("library:".length);
|
|
165
|
+
const item = library.find((entry) => entry.id === id);
|
|
166
|
+
if (!item)
|
|
167
|
+
return draft;
|
|
168
|
+
return {
|
|
169
|
+
...draft,
|
|
170
|
+
preset,
|
|
171
|
+
subject: item.subject,
|
|
172
|
+
body: item.body,
|
|
173
|
+
...(schema.productIds
|
|
174
|
+
? { productIds: item.productIds ?? [] }
|
|
175
|
+
: {}),
|
|
176
|
+
...(schema.discountId ? { discountId: item.discountId ?? "" } : {}),
|
|
177
|
+
...(schema.discountCode
|
|
178
|
+
? { discountCode: item.discountCode ?? "" }
|
|
179
|
+
: {}),
|
|
180
|
+
...(schema.discountOffer
|
|
181
|
+
? { discountOffer: item.discountOffer ?? "" }
|
|
182
|
+
: {}),
|
|
183
|
+
...(schema.discountExpires
|
|
184
|
+
? { discountExpires: item.discountExpires ?? "" }
|
|
185
|
+
: {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function formatRecipients(to) {
|
|
189
|
+
return Array.isArray(to) ? to.join(", ") : to;
|
|
190
|
+
}
|
|
191
|
+
function formatWhen(iso) {
|
|
192
|
+
const ms = Date.parse(iso);
|
|
193
|
+
if (!Number.isFinite(ms))
|
|
194
|
+
return iso;
|
|
195
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
196
|
+
dateStyle: "medium",
|
|
197
|
+
timeStyle: "short",
|
|
198
|
+
}).format(ms);
|
|
199
|
+
}
|
|
200
|
+
function defaultSendAt() {
|
|
201
|
+
const d = new Date(Date.now() + 60 * 60 * 1000);
|
|
202
|
+
d.setMinutes(0, 0, 0);
|
|
203
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
204
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
205
|
+
}
|
|
206
|
+
function asStringList(value) {
|
|
207
|
+
if (!Array.isArray(value))
|
|
208
|
+
return [];
|
|
209
|
+
return value.map((item) => String(item).trim()).filter(Boolean);
|
|
210
|
+
}
|
|
211
|
+
export function EmailsBoard({ data, loading, tab: tabProp, onRefresh, onLoadRecipients, onPreview, onSend, onSchedule, onCancel, onSaveLibrary, onDeleteLibrary, }) {
|
|
212
|
+
const audiences = data.audiences;
|
|
213
|
+
const library = data.library ?? [];
|
|
214
|
+
const products = data.products ?? [];
|
|
215
|
+
const discounts = data.discounts ?? [];
|
|
216
|
+
const history = data.history ?? [];
|
|
217
|
+
const [tabState] = useState("compose");
|
|
218
|
+
const tab = tabProp ?? tabState;
|
|
219
|
+
const audienceOptions = useMemo(() => audiences.map((item) => ({
|
|
220
|
+
id: item.id,
|
|
221
|
+
label: item.label,
|
|
222
|
+
})), [audiences]);
|
|
223
|
+
const [resourceIds, setResourceIds] = useState(() => audiences[0] ? [audiences[0].id] : []);
|
|
224
|
+
const selectedAudiences = useMemo(() => audiences.filter((item) => resourceIds.includes(item.id)), [audiences, resourceIds]);
|
|
225
|
+
const templates = useMemo(() => {
|
|
226
|
+
if (selectedAudiences.length === 0)
|
|
227
|
+
return [];
|
|
228
|
+
return data.templates.filter((template) => selectedAudiences.every((audience) => audience.templates.includes(template.id)));
|
|
229
|
+
}, [data.templates, selectedAudiences]);
|
|
230
|
+
const [templateId, setTemplateId] = useState(templates[0]?.id ?? "");
|
|
231
|
+
const template = templates.find((item) => item.id === templateId) ?? templates[0];
|
|
232
|
+
const schema = useMemo(() => enrichComposeSchema(normalizeInputSchema(template?.input), library, products, discounts), [discounts, library, products, template]);
|
|
233
|
+
const defaults = useMemo(() => composeDefaults(schema), [schema]);
|
|
234
|
+
const [draft, setDraft] = useState(defaults);
|
|
235
|
+
const [scope, setScope] = useState("all");
|
|
236
|
+
const [selectedEmails, setSelectedEmails] = useState([]);
|
|
237
|
+
const [recipients, setRecipients] = useState([]);
|
|
238
|
+
const [recipientsLoading, setRecipientsLoading] = useState(false);
|
|
239
|
+
const [when, setWhen] = useState("now");
|
|
240
|
+
const [sendAt, setSendAt] = useState(defaultSendAt);
|
|
241
|
+
const [preview, setPreview] = useState(null);
|
|
242
|
+
const [previewing, setPreviewing] = useState(false);
|
|
243
|
+
const [busy, setBusy] = useState(false);
|
|
244
|
+
const [error, setError] = useState(null);
|
|
245
|
+
const [notice, setNotice] = useState(null);
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
setResourceIds((current) => {
|
|
248
|
+
const valid = current.filter((id) => audiences.some((item) => item.id === id));
|
|
249
|
+
if (valid.length > 0)
|
|
250
|
+
return valid;
|
|
251
|
+
return audiences[0] ? [audiences[0].id] : [];
|
|
252
|
+
});
|
|
253
|
+
}, [audiences]);
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!template) {
|
|
256
|
+
setTemplateId("");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!templates.some((item) => item.id === templateId)) {
|
|
260
|
+
setTemplateId(template.id);
|
|
261
|
+
}
|
|
262
|
+
}, [template, templateId, templates]);
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
setDraft(defaults);
|
|
265
|
+
}, [defaults]);
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
setSelectedEmails([]);
|
|
268
|
+
}, [resourceIds]);
|
|
269
|
+
const audienceKey = selectedAudiences.map((item) => item.id).join("|");
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (selectedAudiences.length === 0) {
|
|
272
|
+
setRecipients([]);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
let cancelled = false;
|
|
276
|
+
setRecipientsLoading(true);
|
|
277
|
+
void Promise.all(selectedAudiences.map(async (audience) => {
|
|
278
|
+
const rows = await onLoadRecipients(audience.id);
|
|
279
|
+
return rows.map((row) => ({
|
|
280
|
+
...row,
|
|
281
|
+
resourceId: audience.id,
|
|
282
|
+
label: row.label
|
|
283
|
+
? `${row.label} · ${audience.label}`
|
|
284
|
+
: audience.label,
|
|
285
|
+
}));
|
|
286
|
+
}))
|
|
287
|
+
.then((groups) => {
|
|
288
|
+
if (cancelled)
|
|
289
|
+
return;
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
const merged = [];
|
|
292
|
+
for (const group of groups) {
|
|
293
|
+
for (const row of group) {
|
|
294
|
+
const email = row.email.trim().toLowerCase();
|
|
295
|
+
if (!email || seen.has(email))
|
|
296
|
+
continue;
|
|
297
|
+
seen.add(email);
|
|
298
|
+
merged.push({ ...row, email });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
merged.sort((a, b) => a.email.localeCompare(b.email));
|
|
302
|
+
setRecipients(merged);
|
|
303
|
+
})
|
|
304
|
+
.catch((err) => {
|
|
305
|
+
if (!cancelled) {
|
|
306
|
+
setRecipients([]);
|
|
307
|
+
setError(err instanceof Error ? err.message : "Failed to load emails");
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
.finally(() => {
|
|
311
|
+
if (!cancelled)
|
|
312
|
+
setRecipientsLoading(false);
|
|
313
|
+
});
|
|
314
|
+
return () => {
|
|
315
|
+
cancelled = true;
|
|
316
|
+
};
|
|
317
|
+
}, [audienceKey, onLoadRecipients, selectedAudiences]);
|
|
318
|
+
const primaryAudience = selectedAudiences[0];
|
|
319
|
+
const canSubmit = selectedAudiences.length > 0 &&
|
|
320
|
+
Boolean(template) &&
|
|
321
|
+
(!Object.keys(schema).length || requiredFilled(schema, draft, library)) &&
|
|
322
|
+
(scope === "all" || selectedEmails.length > 0) &&
|
|
323
|
+
(when === "now" || Boolean(sendAt)) &&
|
|
324
|
+
!busy;
|
|
325
|
+
const sampleRowId = useMemo(() => {
|
|
326
|
+
if (scope !== "emails" || selectedEmails.length === 0)
|
|
327
|
+
return undefined;
|
|
328
|
+
const hit = recipients.find((item) => item.email.trim().toLowerCase() === selectedEmails[0]?.toLowerCase());
|
|
329
|
+
return hit?.id ?? hit?.email ?? selectedEmails[0];
|
|
330
|
+
}, [recipients, scope, selectedEmails]);
|
|
331
|
+
const previewResourceId = useMemo(() => {
|
|
332
|
+
if (scope === "emails" && selectedEmails[0]) {
|
|
333
|
+
const hit = recipients.find((item) => item.email.trim().toLowerCase() === selectedEmails[0]?.toLowerCase());
|
|
334
|
+
if (hit?.resourceId)
|
|
335
|
+
return hit.resourceId;
|
|
336
|
+
}
|
|
337
|
+
return primaryAudience?.id;
|
|
338
|
+
}, [primaryAudience?.id, recipients, scope, selectedEmails]);
|
|
339
|
+
const loadPreview = useCallback(async () => {
|
|
340
|
+
if (tab !== "compose")
|
|
341
|
+
return;
|
|
342
|
+
if (!previewResourceId || !template) {
|
|
343
|
+
setPreview(null);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (Object.keys(schema).length && !requiredFilled(schema, draft, library)) {
|
|
347
|
+
setPreview(null);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
setPreviewing(true);
|
|
351
|
+
setError(null);
|
|
352
|
+
try {
|
|
353
|
+
const next = await onPreview({
|
|
354
|
+
resourceId: previewResourceId,
|
|
355
|
+
templateId: template.id,
|
|
356
|
+
rowId: sampleRowId,
|
|
357
|
+
input: Object.keys(schema).length ? draft : undefined,
|
|
358
|
+
});
|
|
359
|
+
setPreview(next);
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
setPreview(null);
|
|
363
|
+
setError(err instanceof Error ? err.message : "Preview failed");
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
setPreviewing(false);
|
|
367
|
+
}
|
|
368
|
+
}, [
|
|
369
|
+
draft,
|
|
370
|
+
onPreview,
|
|
371
|
+
previewResourceId,
|
|
372
|
+
sampleRowId,
|
|
373
|
+
schema,
|
|
374
|
+
tab,
|
|
375
|
+
template,
|
|
376
|
+
]);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
const timer = window.setTimeout(() => {
|
|
379
|
+
void loadPreview();
|
|
380
|
+
}, 280);
|
|
381
|
+
return () => window.clearTimeout(timer);
|
|
382
|
+
}, [loadPreview]);
|
|
383
|
+
const targetsForEmail = (email) => {
|
|
384
|
+
const hit = recipients.find((item) => item.email.trim().toLowerCase() === email.toLowerCase());
|
|
385
|
+
if (hit?.resourceId)
|
|
386
|
+
return [hit.resourceId];
|
|
387
|
+
return selectedAudiences.map((item) => item.id);
|
|
388
|
+
};
|
|
389
|
+
const submit = async () => {
|
|
390
|
+
if (!canSubmit || !template || selectedAudiences.length === 0)
|
|
391
|
+
return;
|
|
392
|
+
setBusy(true);
|
|
393
|
+
setError(null);
|
|
394
|
+
setNotice(null);
|
|
395
|
+
try {
|
|
396
|
+
const input = Object.keys(schema).length ? draft : undefined;
|
|
397
|
+
if (when === "later") {
|
|
398
|
+
if (!onSchedule)
|
|
399
|
+
throw new Error("Scheduling is not available.");
|
|
400
|
+
const iso = new Date(sendAt).toISOString();
|
|
401
|
+
if (scope === "all") {
|
|
402
|
+
for (const audience of selectedAudiences) {
|
|
403
|
+
await onSchedule({
|
|
404
|
+
resourceId: audience.id,
|
|
405
|
+
templateId: template.id,
|
|
406
|
+
input,
|
|
407
|
+
audience: { mode: "all" },
|
|
408
|
+
sendAt: iso,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
const byResource = new Map();
|
|
414
|
+
for (const email of selectedEmails) {
|
|
415
|
+
for (const resourceId of targetsForEmail(email)) {
|
|
416
|
+
const list = byResource.get(resourceId) ?? [];
|
|
417
|
+
list.push(email);
|
|
418
|
+
byResource.set(resourceId, list);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
for (const [resourceId, emails] of byResource) {
|
|
422
|
+
await onSchedule({
|
|
423
|
+
resourceId,
|
|
424
|
+
templateId: template.id,
|
|
425
|
+
input,
|
|
426
|
+
audience: { mode: "emails", emails },
|
|
427
|
+
sendAt: iso,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
setNotice(`Scheduled for ${formatWhen(iso)}.`);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
let sent = 0;
|
|
435
|
+
let failed = 0;
|
|
436
|
+
if (scope === "all") {
|
|
437
|
+
for (const audience of selectedAudiences) {
|
|
438
|
+
const result = await onSend({
|
|
439
|
+
resourceId: audience.id,
|
|
440
|
+
templateId: template.id,
|
|
441
|
+
input,
|
|
442
|
+
audience: { mode: "all" },
|
|
443
|
+
});
|
|
444
|
+
sent += result.sent;
|
|
445
|
+
failed += result.failed;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
const byResource = new Map();
|
|
450
|
+
for (const email of selectedEmails) {
|
|
451
|
+
for (const resourceId of targetsForEmail(email)) {
|
|
452
|
+
const list = byResource.get(resourceId) ?? [];
|
|
453
|
+
list.push(email);
|
|
454
|
+
byResource.set(resourceId, list);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const [resourceId, emails] of byResource) {
|
|
458
|
+
const result = await onSend({
|
|
459
|
+
resourceId,
|
|
460
|
+
templateId: template.id,
|
|
461
|
+
input,
|
|
462
|
+
audience: { mode: "emails", emails },
|
|
463
|
+
});
|
|
464
|
+
sent += result.sent;
|
|
465
|
+
failed += result.failed;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
setNotice(failed > 0 ? `Sent ${sent}. ${failed} failed.` : `Sent ${sent}.`);
|
|
469
|
+
}
|
|
470
|
+
await onRefresh();
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
setError(err instanceof Error ? err.message : "Request failed");
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
setBusy(false);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const cancelCampaign = async (id) => {
|
|
480
|
+
if (!onCancel)
|
|
481
|
+
return;
|
|
482
|
+
setBusy(true);
|
|
483
|
+
setError(null);
|
|
484
|
+
try {
|
|
485
|
+
await onCancel(id);
|
|
486
|
+
await onRefresh();
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
setError(err instanceof Error ? err.message : "Cancel failed");
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
setBusy(false);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const saveLibrary = async (template) => {
|
|
496
|
+
if (!onSaveLibrary)
|
|
497
|
+
return;
|
|
498
|
+
await onSaveLibrary(template);
|
|
499
|
+
await onRefresh();
|
|
500
|
+
};
|
|
501
|
+
const deleteLibrary = async (id) => {
|
|
502
|
+
if (!onDeleteLibrary)
|
|
503
|
+
return;
|
|
504
|
+
await onDeleteLibrary(id);
|
|
505
|
+
await onRefresh();
|
|
506
|
+
};
|
|
507
|
+
const visibleFields = Object.entries(schema).filter(([key]) => fieldVisible(key, schema, draft, library));
|
|
508
|
+
const updateDraftField = (key, next) => {
|
|
509
|
+
setDraft((current) => {
|
|
510
|
+
let updated = { ...current, [key]: next };
|
|
511
|
+
if (key === "preset" && typeof next === "string") {
|
|
512
|
+
updated = applyLibraryPreset(updated, next, library, schema);
|
|
513
|
+
const wantsProducts = next === "drop_live" ||
|
|
514
|
+
next === "restock" ||
|
|
515
|
+
next === "promo" ||
|
|
516
|
+
isDiscountPreset(next, library) ||
|
|
517
|
+
library.some((item) => `library:${item.id}` === next &&
|
|
518
|
+
(item.kind === "promo" || (item.productIds?.length ?? 0) > 0));
|
|
519
|
+
const currentProducts = Array.isArray(updated.productIds)
|
|
520
|
+
? updated.productIds
|
|
521
|
+
: [];
|
|
522
|
+
if (wantsProducts &&
|
|
523
|
+
currentProducts.length === 0 &&
|
|
524
|
+
products[0] &&
|
|
525
|
+
schema.productIds) {
|
|
526
|
+
updated = { ...updated, productIds: [products[0].id] };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (key === "discountId" && typeof next === "string") {
|
|
530
|
+
const hit = discounts.find((item) => item.id === next);
|
|
531
|
+
if (hit) {
|
|
532
|
+
updated = {
|
|
533
|
+
...updated,
|
|
534
|
+
discountCode: hit.code ?? updated.discountCode ?? "",
|
|
535
|
+
discountOffer: hit.offerLabel ?? hit.label ?? updated.discountOffer ?? "",
|
|
536
|
+
discountExpires: hit.expiresLabel ?? updated.discountExpires ?? "",
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return updated;
|
|
541
|
+
});
|
|
542
|
+
};
|
|
543
|
+
if (audiences.length === 0) {
|
|
544
|
+
return (_jsxs("div", { className: "vertebr-emails", children: [_jsx("div", { className: "vertebr-panel-head", children: _jsx("h2", { children: data.label }) }), _jsxs("div", { className: "vertebr-empty", children: ["No mail-enabled resources yet. Opt a resource in with", " ", _jsx("code", { children: "mail: [\"sendEmail\"]" }), "."] })] }));
|
|
545
|
+
}
|
|
546
|
+
return (_jsxs("div", { className: "vertebr-emails", children: [_jsxs("div", { className: "vertebr-panel-head", children: [_jsx("h2", { children: data.label }), _jsx("div", { className: "vertebr-panel-head-actions", children: _jsx("button", { type: "button", className: "vertebr-btn", onClick: () => void onRefresh(), disabled: loading || busy, children: loading ? "Refreshing…" : "Refresh" }) })] }), tab === "compose" ? (_jsxs("div", { className: "vertebr-emails-layout", children: [_jsxs("form", { className: "vertebr-emails-compose", onSubmit: (event) => {
|
|
547
|
+
event.preventDefault();
|
|
548
|
+
void submit();
|
|
549
|
+
}, children: [_jsxs("div", { className: "vertebr-emails-fields", children: [_jsxs("label", { className: "vertebr-mail-field", children: [_jsx("span", { children: "Audience" }), _jsx(MultiCheckSelect, { options: audienceOptions, value: resourceIds, disabled: busy, placeholder: "Select audiences\u2026", emptyLabel: "No audiences", onChange: (next) => {
|
|
550
|
+
if (next.length === 0 && audiences[0]) {
|
|
551
|
+
setResourceIds([audiences[0].id]);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
setResourceIds(next);
|
|
555
|
+
} })] }), _jsxs("div", { className: "vertebr-emails-segment", role: "group", "aria-label": "Scope", children: [_jsx("button", { type: "button", "data-active": scope === "all" ? "true" : undefined, onClick: () => setScope("all"), children: "Entire list" }), _jsx("button", { type: "button", "data-active": scope === "emails" ? "true" : undefined, onClick: () => setScope("emails"), children: "Specific emails" })] }), scope === "emails" ? (_jsxs("label", { className: "vertebr-mail-field", children: [_jsx("span", { children: "Emails" }), _jsx(EmailRecipientSelect, { options: recipients, value: selectedEmails, loading: recipientsLoading, disabled: busy, onChange: setSelectedEmails })] })) : null, _jsxs("label", { className: "vertebr-mail-field", children: [_jsx("span", { children: "Template" }), _jsx("select", { value: template?.id ?? "", onChange: (event) => setTemplateId(event.target.value), children: templates.map((item) => (_jsx("option", { value: item.id, children: item.label }, item.id))) })] }), visibleFields.map(([key, field]) => {
|
|
556
|
+
const multiline = isMultiline(key, field);
|
|
557
|
+
return (_jsxs("label", { className: "vertebr-mail-field", children: [_jsx("span", { children: field.label ?? key }), field.type === "enum" ? (_jsx(EnumInput, { id: `emails-${key}`, value: draft[key], field: field, onChange: (next) => updateDraftField(key, next) })) : field.type === "enum[]" ? (_jsx(MultiCheckSelect, { options: (field.options ?? []).map((id) => {
|
|
558
|
+
const product = key === "productIds"
|
|
559
|
+
? products.find((item) => item.id === id)
|
|
560
|
+
: undefined;
|
|
561
|
+
return {
|
|
562
|
+
id,
|
|
563
|
+
label: field.optionLabels?.[id] ?? id,
|
|
564
|
+
secondary: product?.priceLabel,
|
|
565
|
+
imageUrl: product?.imageUrl,
|
|
566
|
+
};
|
|
567
|
+
}), value: asStringList(draft[key]), disabled: busy, placeholder: key === "productIds"
|
|
568
|
+
? "Add products with photos…"
|
|
569
|
+
: `Select ${field.label ?? key}…`, emptyLabel: "Nothing available", onChange: (next) => updateDraftField(key, next) })) : multiline ? (_jsx("textarea", { rows: 6, value: fieldValue(draft[key]), onChange: (event) => updateDraftField(key, event.target.value) })) : (_jsx("input", { type: "text", value: fieldValue(draft[key]), onChange: (event) => updateDraftField(key, event.target.value) }))] }, key));
|
|
570
|
+
}), _jsxs("div", { className: "vertebr-emails-segment", role: "group", "aria-label": "When", children: [_jsx("button", { type: "button", "data-active": when === "now" ? "true" : undefined, onClick: () => setWhen("now"), children: "Send now" }), _jsx("button", { type: "button", "data-active": when === "later" ? "true" : undefined, disabled: !data.canSchedule || !onSchedule, onClick: () => setWhen("later"), children: "Schedule" })] }), when === "later" ? (_jsxs("label", { className: "vertebr-mail-field", children: [_jsx("span", { children: "Send at" }), _jsx("input", { type: "datetime-local", value: sendAt, onChange: (event) => setSendAt(event.target.value) })] })) : null, error ? _jsx("p", { className: "vertebr-mail-compose-error", children: error }) : null, notice ? _jsx("p", { className: "vertebr-emails-notice", children: notice }) : null] }), _jsx("div", { className: "vertebr-mail-compose-foot", children: _jsx("button", { type: "submit", className: "vertebr-btn vertebr-btn-primary", disabled: !canSubmit, children: busy
|
|
571
|
+
? when === "later"
|
|
572
|
+
? "Scheduling…"
|
|
573
|
+
: "Sending…"
|
|
574
|
+
: when === "later"
|
|
575
|
+
? "Schedule email"
|
|
576
|
+
: scope === "all"
|
|
577
|
+
? "Send to list"
|
|
578
|
+
: "Send" }) })] }), _jsx("div", { className: "vertebr-emails-side", children: _jsxs("div", { className: "vertebr-mail-preview", children: [_jsxs("div", { className: "vertebr-mail-preview-meta", children: [_jsxs("p", { children: [_jsx("span", { children: "To" }), _jsx("strong", { children: preview
|
|
579
|
+
? formatRecipients(preview.to)
|
|
580
|
+
: scope === "all"
|
|
581
|
+
? selectedAudiences.length === 0
|
|
582
|
+
? "—"
|
|
583
|
+
: selectedAudiences.length === 1
|
|
584
|
+
? `Everyone on ${selectedAudiences[0]?.label}`
|
|
585
|
+
: `Everyone on ${selectedAudiences.length} lists`
|
|
586
|
+
: selectedEmails.length > 0
|
|
587
|
+
? selectedEmails.length === 1
|
|
588
|
+
? selectedEmails[0]
|
|
589
|
+
: `${selectedEmails.length} selected`
|
|
590
|
+
: "—" })] }), _jsxs("p", { children: [_jsx("span", { children: "Subject" }), _jsx("strong", { children: preview?.subject ?? (previewing ? "Updating…" : "—") })] })] }), _jsx("div", { className: "vertebr-mail-preview-stage", children: _jsx("div", { className: "vertebr-mail-preview-frame", children: preview?.html ? (_jsx("iframe", { title: "Email preview", sandbox: "", srcDoc: preview.html })) : preview?.text ? (_jsx("pre", { children: preview.text })) : (_jsx("div", { className: "vertebr-mail-preview-empty", children: previewing
|
|
591
|
+
? "Rendering host template…"
|
|
592
|
+
: "Pick an audience and template to preview." })) }) })] }) })] })) : null, tab === "templates" ? (_jsx(EmailsLibraryPanel, { templates: data.templates, library: library, products: products, discounts: discounts, canLibrary: data.canLibrary, busy: busy, previewResourceId: data.audiences[0]?.id, previewTemplateId: data.templates.find((item) => item.id === "sendEmail")?.id ??
|
|
593
|
+
data.templates[0]?.id, onPreview: onPreview, onSaveLibrary: onSaveLibrary ? saveLibrary : undefined, onDeleteLibrary: onDeleteLibrary ? deleteLibrary : undefined })) : null, tab === "activity" ? (_jsx(EmailsActivityPanel, { campaigns: data.campaigns, history: history, canSchedule: data.canSchedule, canHistory: data.canHistory, busy: busy, onCancel: onCancel ? cancelCampaign : undefined })) : null] }));
|
|
594
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EmailsBoardResponse, MailDiscountOption, MailLibraryTemplate, MailPreviewResponse, MailProductOption } from "@vertebr/client";
|
|
2
|
+
export type SystemMailPreset = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
};
|
|
6
|
+
export type EmailsLibraryPanelProps = {
|
|
7
|
+
templates: EmailsBoardResponse["templates"];
|
|
8
|
+
library: MailLibraryTemplate[];
|
|
9
|
+
products: MailProductOption[];
|
|
10
|
+
discounts?: MailDiscountOption[];
|
|
11
|
+
canLibrary?: boolean;
|
|
12
|
+
busy?: boolean;
|
|
13
|
+
previewResourceId?: string;
|
|
14
|
+
previewTemplateId?: string;
|
|
15
|
+
onPreview?: (payload: {
|
|
16
|
+
resourceId: string;
|
|
17
|
+
templateId: string;
|
|
18
|
+
input?: Record<string, unknown>;
|
|
19
|
+
}) => Promise<MailPreviewResponse>;
|
|
20
|
+
onSaveLibrary?: (template: MailLibraryTemplate) => Promise<void>;
|
|
21
|
+
onDeleteLibrary?: (id: string) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export declare function systemPresetsFromTemplates(templates: EmailsBoardResponse["templates"]): SystemMailPreset[];
|
|
24
|
+
export declare function EmailsLibraryPanel({ templates, library, products, discounts, canLibrary, busy, previewResourceId, previewTemplateId, onPreview, onSaveLibrary, onDeleteLibrary, }: EmailsLibraryPanelProps): import("react").JSX.Element;
|
|
25
|
+
//# sourceMappingURL=EmailsLibraryPanel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EmailsLibraryPanel.d.ts","sourceRoot":"","sources":["../../../src/components/emails/EmailsLibraryPanel.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,EACnB,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAMzB,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,SAAS,EAAE,mBAAmB,CAAC,WAAW,CAAC,CAAC;IAC5C,OAAO,EAAE,mBAAmB,EAAE,CAAC;IAC/B,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,kBAAkB,EAAE,CAAC;IACjC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE;QACpB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACjC,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACnC,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,mBAAmB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,eAAe,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD,CAAC;AASF,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,mBAAmB,CAAC,WAAW,CAAC,GAC1C,gBAAgB,EAAE,CAoBpB;AAgGD,wBAAgB,kBAAkB,CAAC,EACjC,SAAS,EACT,OAAO,EACP,QAAQ,EACR,SAAc,EACd,UAAU,EACV,IAAI,EACJ,iBAAiB,EACjB,iBAAiB,EACjB,SAAS,EACT,aAAa,EACb,eAAe,GAChB,EAAE,uBAAuB,+BA8tBzB"}
|