@antelopejs/dms-mailing 0.6.1 → 0.7.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/constants.d.ts +1 -0
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/data/templates-table.d.ts +4 -1
- package/dist/data/templates-table.js +55 -13
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +5 -5
- package/dist/db/models/templates.model.d.ts +10 -1
- package/dist/db/models/templates.model.js +27 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/tables/templates.table.d.ts +7 -1
- package/dist/db/tables/templates.table.js +3 -1
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/formats.d.ts +13 -0
- package/dist/engine/formats.js +85 -0
- package/dist/engine/formats.js.map +1 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +14 -7
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js +3 -5
- package/dist/engine/tokens.js.map +1 -1
- package/dist/frontend-module.d.ts +2 -0
- package/dist/frontend-module.js +24 -0
- package/dist/frontend-module.js.map +1 -0
- package/dist/hooks/tenant-export.js +4 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +1 -0
- package/dist/implementations/dms-mailing/index.js +3 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +2 -19
- package/dist/index.js.map +1 -1
- package/dist/pages/overview.d.ts +1 -1
- package/dist/pages/overview.js +11 -13
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/sends.d.ts +1 -1
- package/dist/pages/sends.js +16 -8
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -8
- package/dist/pages/templates.js.map +1 -1
- package/dist/pages/toolbar.d.ts +12 -0
- package/dist/pages/toolbar.js +41 -0
- package/dist/pages/toolbar.js.map +1 -0
- package/dist/realtime.d.ts +2 -2
- package/dist/realtime.js +8 -8
- package/dist/realtime.js.map +1 -1
- package/dist/routes/metrics.d.ts +9 -2
- package/dist/routes/metrics.js +90 -35
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.d.ts +1 -0
- package/dist/routes/period.js +19 -0
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +6 -3
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -7
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.d.ts +8 -4
- package/dist/routes/templates.js +40 -15
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/categories.d.ts +2 -1
- package/dist/services/categories.js +9 -0
- package/dist/services/categories.js.map +1 -1
- package/dist/services/declarations.js +2 -2
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +1 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +7 -0
- package/dist/services/localized-text.js +28 -0
- package/dist/services/localized-text.js.map +1 -0
- package/dist/services/send-stat-items.js +34 -12
- package/dist/services/send-stat-items.js.map +1 -1
- package/dist/services/send-stats.d.ts +7 -0
- package/dist/services/send-stats.js +17 -5
- package/dist/services/send-stats.js.map +1 -1
- package/dist/services/send.d.ts +2 -0
- package/dist/services/send.js +11 -12
- package/dist/services/send.js.map +1 -1
- package/dist/services/sender-declaration.d.ts +2 -0
- package/dist/services/sender-declaration.js +18 -0
- package/dist/services/sender-declaration.js.map +1 -0
- package/dist/services/starters.d.ts +2 -2
- package/dist/services/starters.js +1 -10
- package/dist/services/starters.js.map +1 -1
- package/dist/services/templates.d.ts +11 -5
- package/dist/services/templates.js +24 -6
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -1
- package/dist/services/webhook-status.js +6 -5
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/settings.d.ts +6 -0
- package/dist/types/settings.js +25 -5
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +25 -7
- package/dist/validation/declarations.schema.js +16 -3
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/settings.schema.d.ts +25 -25
- package/dist/validation/settings.schema.js +30 -6
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +13 -0
- package/dist/validation/templates.schema.js +6 -1
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +15 -13
- package/frontend-vue/app/components/FunnelCard.vue +46 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
- package/frontend-vue/app/components/OverviewGate.vue +0 -8
- package/frontend-vue/app/components/ProviderPill.vue +50 -0
- package/frontend-vue/app/components/SendDrawer.vue +206 -183
- package/frontend-vue/app/components/StarterGallery.vue +1 -1
- package/frontend-vue/app/components/TemplateCard.vue +4 -1
- package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
- package/frontend-vue/app/components/TemplateGallery.vue +57 -17
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
- package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
- package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
- package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
- package/frontend-vue/app/composables/useMailingApi.ts +3 -3
- package/frontend-vue/app/composables/useMailingReads.ts +2 -2
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
- package/frontend-vue/app/types/mailing.ts +18 -0
- package/frontend-vue/app/utils/categories.ts +58 -49
- package/frontend-vue/app/utils/funnel.ts +25 -0
- package/frontend-vue/app/utils/period.ts +0 -7
- package/frontend-vue/app/utils/starters.ts +3 -1
- package/frontend-vue/app/utils/tokens.ts +6 -1
- package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/categories-input.test.ts +4 -10
- package/frontend-vue/tests/categories.test.ts +55 -17
- package/frontend-vue/tests/conditions.test.ts +1 -4
- package/frontend-vue/tests/funnel-card.test.ts +27 -0
- package/frontend-vue/tests/funnel.test.ts +25 -0
- package/frontend-vue/tests/localized-defaults.test.ts +0 -29
- package/frontend-vue/tests/new-template-modal.test.ts +1 -1
- package/frontend-vue/tests/overview-gate.test.ts +1 -24
- package/frontend-vue/tests/page-reads.test.ts +9 -25
- package/frontend-vue/tests/period.test.ts +0 -12
- package/frontend-vue/tests/replay-toast.test.ts +2 -1
- package/frontend-vue/tests/send-drawer.test.ts +123 -0
- package/frontend-vue/tests/template-drawer.test.ts +1 -1
- package/frontend-vue/tests/template-gallery.test.ts +90 -3
- package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
- package/package.json +6 -5
- package/skills/dms-mailing/SKILL.md +17 -4
- package/src/constants.ts +2 -0
- package/src/data/sends-table.ts +1 -1
- package/src/data/templates-table.ts +123 -19
- package/src/db/models/templates.model.ts +61 -3
- package/src/db/tables/templates.table.ts +10 -1
- package/src/engine/formats.ts +124 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/resolve.ts +18 -7
- package/src/engine/tokens.ts +7 -5
- package/src/frontend-module.ts +36 -0
- package/src/hooks/tenant-export.ts +8 -1
- package/src/implementations/dms-mailing/index.ts +2 -0
- package/src/index.ts +2 -23
- package/src/pages/overview.ts +20 -16
- package/src/pages/sends.ts +27 -11
- package/src/pages/templates.ts +4 -8
- package/src/pages/toolbar.ts +77 -0
- package/src/realtime.ts +17 -8
- package/src/routes/metrics.ts +158 -45
- package/src/routes/period.ts +32 -0
- package/src/routes/sends.ts +13 -4
- package/src/routes/settings.ts +11 -8
- package/src/routes/templates.ts +72 -26
- package/src/services/categories.ts +14 -1
- package/src/services/declarations.ts +4 -3
- package/src/services/events.ts +1 -1
- package/src/services/localized-text.ts +53 -0
- package/src/services/send-stat-items.ts +60 -13
- package/src/services/send-stats.ts +37 -5
- package/src/services/send.ts +19 -20
- package/src/services/sender-declaration.ts +27 -0
- package/src/services/starters.ts +3 -15
- package/src/services/templates.ts +50 -19
- package/src/services/webhook-status.ts +3 -3
- package/src/types/settings.ts +35 -5
- package/src/validation/declarations.schema.ts +29 -3
- package/src/validation/settings.schema.ts +56 -14
- package/src/validation/templates.schema.ts +10 -0
- package/frontend-vue/app/components/PageHeader.vue +0 -94
- package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SenderDeclaration } from "@antelopejs/interface-dms-mailing";
|
|
2
|
+
import { senderDeclarationSchema } from "../validation/declarations.schema";
|
|
3
|
+
import { parseDeclared } from "./declaration-error";
|
|
4
|
+
import { getSettings, saveSettings } from "./settings";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Writes the workspace's sender (from name, address, reply-to) a project
|
|
8
|
+
* sets up when it provisions the workspace. A plain write: the declared
|
|
9
|
+
* sender replaces the stored one, and later edits in the settings are the
|
|
10
|
+
* user's.
|
|
11
|
+
*/
|
|
12
|
+
export async function declareSender(input: SenderDeclaration): Promise<void> {
|
|
13
|
+
const declaration = parseDeclared(
|
|
14
|
+
senderDeclarationSchema,
|
|
15
|
+
input,
|
|
16
|
+
"invalid_sender",
|
|
17
|
+
"sender declaration",
|
|
18
|
+
);
|
|
19
|
+
const { tenantId } = declaration;
|
|
20
|
+
const settings = await getSettings(tenantId);
|
|
21
|
+
await saveSettings(tenantId, {
|
|
22
|
+
...settings,
|
|
23
|
+
senderName: declaration.name,
|
|
24
|
+
senderEmail: declaration.email,
|
|
25
|
+
replyTo: declaration.replyTo,
|
|
26
|
+
});
|
|
27
|
+
}
|
package/src/services/starters.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TemplateFields } from "../db";
|
|
2
2
|
import type {
|
|
3
3
|
Block,
|
|
4
4
|
LocaleContent,
|
|
5
5
|
TemplateContent,
|
|
6
6
|
VariableDefinition,
|
|
7
7
|
} from "../types";
|
|
8
|
-
import { footerForCategory } from "../engine";
|
|
8
|
+
import { footerForCategory, formattingLocale } from "../engine";
|
|
9
9
|
import { blankFields, footerBlock } from "./templates";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -31,18 +31,6 @@ type StarterSource = Omit<StarterTemplate, "testData">;
|
|
|
31
31
|
const DEFAULT_TEST_DATA_LOCALE = "en";
|
|
32
32
|
const STARTER_CURRENCY = "EUR";
|
|
33
33
|
|
|
34
|
-
/** `locale` when the runtime can format for it, English otherwise. */
|
|
35
|
-
function formattingLocale(locale: string): string {
|
|
36
|
-
try {
|
|
37
|
-
return (
|
|
38
|
-
Intl.NumberFormat.supportedLocalesOf(locale)[0] ??
|
|
39
|
-
DEFAULT_TEST_DATA_LOCALE
|
|
40
|
-
);
|
|
41
|
-
} catch {
|
|
42
|
-
return DEFAULT_TEST_DATA_LOCALE;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
34
|
/** The ordered item of the order starter's test data, by language. */
|
|
47
35
|
const ORDER_LINE_NAMES: Record<string, string> = {
|
|
48
36
|
en: "Ergo chair · Graphite × 4",
|
|
@@ -374,7 +362,7 @@ export function findStarter(id: string): StarterTemplate | undefined {
|
|
|
374
362
|
export function starterFields(
|
|
375
363
|
starter: StarterTemplate,
|
|
376
364
|
locale: string = DEFAULT_TEST_DATA_LOCALE,
|
|
377
|
-
):
|
|
365
|
+
): TemplateFields {
|
|
378
366
|
return {
|
|
379
367
|
...blankFields(starter.content),
|
|
380
368
|
json_variables: JSON.stringify(starter.variables),
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
type MailingTemplate,
|
|
12
12
|
type MailingTemplateVersion,
|
|
13
|
+
type TemplateFields,
|
|
13
14
|
TemplateModel,
|
|
14
15
|
TemplateSlugModel,
|
|
15
16
|
TemplateVersionModel,
|
|
@@ -153,6 +154,25 @@ export interface TemplateDraft {
|
|
|
153
154
|
variables: VariableDefinition[];
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/** A snapshot and the version it belongs to; 0 for a draft. */
|
|
158
|
+
export interface VersionedSnapshot extends TemplateDraft {
|
|
159
|
+
version: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What a publish would ship: the draft's content and declarations. */
|
|
163
|
+
export const workingSnapshot = (template: MailingTemplate): TemplateDraft => ({
|
|
164
|
+
content: workingContent(template),
|
|
165
|
+
variables: workingVariables(template),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** What customers receive: the published content and declarations. */
|
|
169
|
+
export const publishedSnapshot = (
|
|
170
|
+
template: MailingTemplate,
|
|
171
|
+
): TemplateDraft => ({
|
|
172
|
+
content: parseContent(template),
|
|
173
|
+
variables: parseVariables(template),
|
|
174
|
+
});
|
|
175
|
+
|
|
156
176
|
/**
|
|
157
177
|
* What `draft` would change for customers and for the callers' contract. A
|
|
158
178
|
* template never published has nothing to compare against: every locale and
|
|
@@ -240,7 +260,7 @@ export function isStaleRevision(
|
|
|
240
260
|
function draftWriteFields(
|
|
241
261
|
template: MailingTemplate,
|
|
242
262
|
author: string,
|
|
243
|
-
):
|
|
263
|
+
): TemplateFields {
|
|
244
264
|
return {
|
|
245
265
|
draftRevision: template.draftRevision + 1,
|
|
246
266
|
draftUpdatedAt: new Date(),
|
|
@@ -274,7 +294,7 @@ export async function saveDraft(
|
|
|
274
294
|
const locales = isPublished(template) ? {} : { locales: localesOf(content) };
|
|
275
295
|
const isClean = isPublishedDraft(template, draft);
|
|
276
296
|
const fields = draftWriteFields(template, author);
|
|
277
|
-
await GetModel(TemplateModel, tenantId).
|
|
297
|
+
await GetModel(TemplateModel, tenantId).updateFields(template._id, {
|
|
278
298
|
json_draft: isClean ? "" : JSON.stringify(content),
|
|
279
299
|
json_variables_draft: isClean ? "" : JSON.stringify(variables),
|
|
280
300
|
isDraftPending: !isClean,
|
|
@@ -290,7 +310,7 @@ export async function discardDraft(
|
|
|
290
310
|
template: MailingTemplate,
|
|
291
311
|
author: string,
|
|
292
312
|
): Promise<void> {
|
|
293
|
-
await GetModel(TemplateModel, tenantId).
|
|
313
|
+
await GetModel(TemplateModel, tenantId).updateFields(template._id, {
|
|
294
314
|
json_draft: "",
|
|
295
315
|
json_variables_draft: "",
|
|
296
316
|
isDraftPending: false,
|
|
@@ -353,7 +373,7 @@ export async function publishSnapshot(
|
|
|
353
373
|
template: MailingTemplate,
|
|
354
374
|
draft: TemplateDraft,
|
|
355
375
|
author: string,
|
|
356
|
-
metadata:
|
|
376
|
+
metadata: TemplateFields = {},
|
|
357
377
|
): Promise<PublishOutcome | undefined> {
|
|
358
378
|
const previous = publishedVersionOf(template);
|
|
359
379
|
const changes = changesOf(template, draft);
|
|
@@ -361,7 +381,7 @@ export async function publishSnapshot(
|
|
|
361
381
|
const version = isUnchanged ? previous : previous + 1;
|
|
362
382
|
const versions = GetModel(TemplateVersionModel, tenantId);
|
|
363
383
|
await versions.materialize(template);
|
|
364
|
-
const shipped:
|
|
384
|
+
const shipped: TemplateFields = isUnchanged
|
|
365
385
|
? {}
|
|
366
386
|
: {
|
|
367
387
|
json_content: JSON.stringify(draft.content),
|
|
@@ -381,7 +401,7 @@ export async function publishSnapshot(
|
|
|
381
401
|
json_changes: JSON.stringify(changes),
|
|
382
402
|
}),
|
|
383
403
|
};
|
|
384
|
-
const patch:
|
|
404
|
+
const patch: TemplateFields = {
|
|
385
405
|
...metadata,
|
|
386
406
|
...shipped,
|
|
387
407
|
...history,
|
|
@@ -431,19 +451,34 @@ export async function publishTemplate(
|
|
|
431
451
|
return outcome;
|
|
432
452
|
}
|
|
433
453
|
|
|
434
|
-
/**
|
|
435
|
-
|
|
454
|
+
/**
|
|
455
|
+
* The content and declarations a send of `version` carried: the draft's for
|
|
456
|
+
* a test of one.
|
|
457
|
+
*/
|
|
458
|
+
export async function snapshotOfVersion(
|
|
436
459
|
tenantId: string,
|
|
437
460
|
template: MailingTemplate,
|
|
438
461
|
version: number,
|
|
439
|
-
): Promise<
|
|
440
|
-
if (!version) return
|
|
462
|
+
): Promise<TemplateDraft> {
|
|
463
|
+
if (!version) return workingSnapshot(template);
|
|
441
464
|
const row = await GetModel(TemplateVersionModel, tenantId).getVersion(
|
|
442
465
|
template._id,
|
|
443
466
|
version,
|
|
444
467
|
);
|
|
445
468
|
if (!row) throw new HTTPResult(HTTP_NOT_FOUND, VERSION_NOT_FOUND);
|
|
446
|
-
return
|
|
469
|
+
return {
|
|
470
|
+
content: parseVersionContent(row),
|
|
471
|
+
variables: parseVersionVariables(row),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The content a send of `version` carried: the draft's for a test of one. */
|
|
476
|
+
export async function contentOfVersion(
|
|
477
|
+
tenantId: string,
|
|
478
|
+
template: MailingTemplate,
|
|
479
|
+
version: number,
|
|
480
|
+
): Promise<TemplateContent> {
|
|
481
|
+
return (await snapshotOfVersion(tenantId, template, version)).content;
|
|
447
482
|
}
|
|
448
483
|
|
|
449
484
|
export function parseVersionContent(
|
|
@@ -457,9 +492,7 @@ export function parseVersionContent(
|
|
|
457
492
|
* copy starts as a draft of the source's working content: it was never
|
|
458
493
|
* published, so customers receive nothing from it yet.
|
|
459
494
|
*/
|
|
460
|
-
export function contentFieldsOf(
|
|
461
|
-
source: MailingTemplate,
|
|
462
|
-
): Partial<MailingTemplate> {
|
|
495
|
+
export function contentFieldsOf(source: MailingTemplate): TemplateFields {
|
|
463
496
|
const content = workingContent(source);
|
|
464
497
|
return {
|
|
465
498
|
json_content: "",
|
|
@@ -483,9 +516,9 @@ export async function initializeTemplate(
|
|
|
483
516
|
tenantId: string,
|
|
484
517
|
templateId: string,
|
|
485
518
|
author: string,
|
|
486
|
-
fields:
|
|
519
|
+
fields: TemplateFields,
|
|
487
520
|
): Promise<void> {
|
|
488
|
-
await GetModel(TemplateModel, tenantId).
|
|
521
|
+
await GetModel(TemplateModel, tenantId).updateFields(templateId, {
|
|
489
522
|
status: "draft",
|
|
490
523
|
json_variables: "[]",
|
|
491
524
|
json_test_data: "{}",
|
|
@@ -495,9 +528,7 @@ export async function initializeTemplate(
|
|
|
495
528
|
await refreshTemplateAttention(tenantId);
|
|
496
529
|
}
|
|
497
530
|
|
|
498
|
-
export function blankFields(
|
|
499
|
-
content: TemplateContent,
|
|
500
|
-
): Partial<MailingTemplate> {
|
|
531
|
+
export function blankFields(content: TemplateContent): TemplateFields {
|
|
501
532
|
return {
|
|
502
533
|
json_content: "",
|
|
503
534
|
json_draft: JSON.stringify(content),
|
|
@@ -8,13 +8,13 @@ import type {
|
|
|
8
8
|
WebhookStatus,
|
|
9
9
|
} from "../types";
|
|
10
10
|
import { webhookSecretMatches } from "./events";
|
|
11
|
+
import { WEBHOOK_SECRET_HEADER } from "../constants";
|
|
12
|
+
|
|
13
|
+
export { WEBHOOK_SECRET_HEADER };
|
|
11
14
|
|
|
12
15
|
/** The message id of the event the settings' "Send a test event" posts. */
|
|
13
16
|
export const WEBHOOK_TEST_MESSAGE_ID = "dms-mailing-webhook-test";
|
|
14
17
|
|
|
15
|
-
/** The header a provider (or its relay) proves it knows the secret with. */
|
|
16
|
-
export const WEBHOOK_SECRET_HEADER = "x-mailing-webhook-secret";
|
|
17
|
-
|
|
18
18
|
const WEBHOOK_TEST_EVENT_TYPE: SendEventType = "delivered";
|
|
19
19
|
const WEBHOOK_TEST_TIMEOUT_MS = 10_000;
|
|
20
20
|
const JSON_CONTENT_TYPE = "application/json";
|
package/src/types/settings.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
+
import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
|
|
2
|
+
|
|
3
|
+
/** A template category as stored: its label written once per language. */
|
|
1
4
|
export interface TemplateCategory {
|
|
5
|
+
id: string;
|
|
6
|
+
label: LocalizedText;
|
|
7
|
+
icon: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A template category as one reader sees it: the label in their language. */
|
|
11
|
+
export interface CategoryInLocale {
|
|
2
12
|
id: string;
|
|
3
13
|
label: string;
|
|
4
14
|
icon: string;
|
|
@@ -16,9 +26,29 @@ export interface MailingSettingsValues {
|
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
export const DEFAULT_CATEGORIES: TemplateCategory[] = [
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
{
|
|
30
|
+
id: "orders",
|
|
31
|
+
label: { en: "Orders", fr: "Commandes" },
|
|
32
|
+
icon: "i-ph-package",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "account",
|
|
36
|
+
label: { en: "Account & access", fr: "Compte et accès" },
|
|
37
|
+
icon: "i-ph-lock",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "billing",
|
|
41
|
+
label: { en: "Billing", fr: "Facturation" },
|
|
42
|
+
icon: "i-ph-file-text",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "marketing",
|
|
46
|
+
label: { en: "Marketing", fr: "Marketing" },
|
|
47
|
+
icon: "i-ph-sparkle",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "forms",
|
|
51
|
+
label: { en: "Forms", fr: "Formulaires" },
|
|
52
|
+
icon: "i-ph-textbox",
|
|
53
|
+
},
|
|
24
54
|
];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { MAX_NAME_LENGTH } from "../constants";
|
|
3
4
|
import { SEND_EVENT_TYPES } from "../types";
|
|
4
|
-
import { categorySchema } from "./settings.schema";
|
|
5
|
+
import { categorySchema, settingsSchema } from "./settings.schema";
|
|
5
6
|
import {
|
|
6
7
|
slugSchema,
|
|
7
8
|
templateContentSchema,
|
|
@@ -13,6 +14,17 @@ const MAX_IMPORT_ID_LENGTH = 200;
|
|
|
13
14
|
|
|
14
15
|
const tenantIdSchema = z.string().trim().min(1);
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A name or a label declared from code: one non-blank text per language, the
|
|
19
|
+
* fallback language's required, since every reader without a text of their
|
|
20
|
+
* own reads it.
|
|
21
|
+
*/
|
|
22
|
+
export const localizedTextSchema = z
|
|
23
|
+
.record(z.string().trim().min(1).max(MAX_NAME_LENGTH))
|
|
24
|
+
.refine((text) => Boolean(text[LOCALIZED_TEXT_FALLBACK_LOCALE]), {
|
|
25
|
+
message: `a text in "${LOCALIZED_TEXT_FALLBACK_LOCALE}" is required`,
|
|
26
|
+
});
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* A template declared from code: the slug and name rules of the dashboard's
|
|
18
30
|
* creation, and the content and declarations its editor saves.
|
|
@@ -20,7 +32,7 @@ const tenantIdSchema = z.string().trim().min(1);
|
|
|
20
32
|
export const templateDeclarationSchema = z.object({
|
|
21
33
|
tenantId: tenantIdSchema,
|
|
22
34
|
slug: slugSchema,
|
|
23
|
-
name:
|
|
35
|
+
name: localizedTextSchema,
|
|
24
36
|
category: z.string().trim().default(""),
|
|
25
37
|
content: templateContentSchema,
|
|
26
38
|
variables: variableListSchema.default([]),
|
|
@@ -34,13 +46,27 @@ export type ParsedTemplateDeclaration = z.infer<
|
|
|
34
46
|
|
|
35
47
|
export const categoryDeclarationSchema = categorySchema.extend({
|
|
36
48
|
tenantId: tenantIdSchema,
|
|
37
|
-
label:
|
|
49
|
+
label: localizedTextSchema,
|
|
38
50
|
});
|
|
39
51
|
|
|
40
52
|
export type ParsedCategoryDeclaration = z.infer<
|
|
41
53
|
typeof categoryDeclarationSchema
|
|
42
54
|
>;
|
|
43
55
|
|
|
56
|
+
const SENDER_REQUIRED = "a sender address is required";
|
|
57
|
+
|
|
58
|
+
/** A sender declared from code: the settings form's rules, an address required. */
|
|
59
|
+
export const senderDeclarationSchema = z.object({
|
|
60
|
+
tenantId: tenantIdSchema,
|
|
61
|
+
email: settingsSchema.shape.senderEmail.pipe(
|
|
62
|
+
z.string().min(1, SENDER_REQUIRED),
|
|
63
|
+
),
|
|
64
|
+
name: settingsSchema.shape.senderName.default(""),
|
|
65
|
+
replyTo: settingsSchema.shape.replyTo.default(""),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export type ParsedSenderDeclaration = z.infer<typeof senderDeclarationSchema>;
|
|
69
|
+
|
|
44
70
|
const recipientSchema = z.union([
|
|
45
71
|
z.string().trim().email(),
|
|
46
72
|
z.object({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { domainToASCII } from "node:url";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { MAX_NAME_LENGTH, SLUG_PATTERN } from "../constants";
|
|
4
|
+
import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
|
|
4
5
|
import { MARKETING_CATEGORY } from "../engine";
|
|
5
6
|
|
|
6
7
|
export const MIN_RETENTION_DAYS = 1;
|
|
@@ -31,7 +32,8 @@ const MISSING_VARIABLES_POLICIES = ["refuse", "send"] as const;
|
|
|
31
32
|
|
|
32
33
|
export const categorySchema = z.object({
|
|
33
34
|
id: z.string().max(MAX_NAME_LENGTH).regex(SLUG_PATTERN),
|
|
34
|
-
label:
|
|
35
|
+
/** One label per language (`{ en: "Orders", fr: "Commandes" }`). */
|
|
36
|
+
label: z.record(z.string().trim().max(MAX_NAME_LENGTH)),
|
|
35
37
|
icon: z.string().regex(CATEGORY_ICON_PATTERN),
|
|
36
38
|
});
|
|
37
39
|
|
|
@@ -45,19 +47,44 @@ const categoryEntrySchema = categorySchema.extend({
|
|
|
45
47
|
|
|
46
48
|
export type CategoryEntry = z.infer<typeof categoryEntrySchema>;
|
|
47
49
|
|
|
50
|
+
/** A category entry as a reader of one language sees its label. */
|
|
51
|
+
interface EntryInLocale {
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
moveTo?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every language a list labels its categories in, the fallback one first:
|
|
59
|
+
* the rules below hold in each of them.
|
|
60
|
+
*/
|
|
61
|
+
function languagesOf(entries: CategoryEntry[]): string[] {
|
|
62
|
+
const codes = entries.flatMap((entry) => Object.keys(entry.label));
|
|
63
|
+
return [...new Set([LOCALIZED_TEXT_FALLBACK_LOCALE, ...codes])];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The entries as a reader of `locale` reads them, the fallback filling in. */
|
|
67
|
+
function entriesIn(entries: CategoryEntry[], locale: string): EntryInLocale[] {
|
|
68
|
+
return entries.map(({ id, label, moveTo }) => ({
|
|
69
|
+
id,
|
|
70
|
+
label: label[locale] || label[LOCALIZED_TEXT_FALLBACK_LOCALE] || "",
|
|
71
|
+
moveTo,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
/** Why a category list is refused, and the part of it at fault. */
|
|
49
76
|
interface CategoryIssue {
|
|
50
77
|
message: string;
|
|
51
78
|
path: Array<string | number>;
|
|
52
79
|
}
|
|
53
80
|
|
|
54
|
-
const isKept = (entry:
|
|
81
|
+
const isKept = (entry: EntryInLocale): boolean => entry.moveTo === undefined;
|
|
55
82
|
|
|
56
83
|
/**
|
|
57
84
|
* A kept entry repeating the label (whatever its case) or the id of an
|
|
58
85
|
* earlier one, under the row it belongs to.
|
|
59
86
|
*/
|
|
60
|
-
function duplicateIssues(entries:
|
|
87
|
+
function duplicateIssues(entries: EntryInLocale[]): CategoryIssue[] {
|
|
61
88
|
const labels = new Set<string>();
|
|
62
89
|
const ids = new Set<string>();
|
|
63
90
|
const issues: CategoryIssue[] = [];
|
|
@@ -79,7 +106,7 @@ function duplicateIssues(entries: CategoryEntry[]): CategoryIssue[] {
|
|
|
79
106
|
* label's own minimum, which the form would turn into a browser-side check:
|
|
80
107
|
* the server words it, under the row, together with the other row issues.
|
|
81
108
|
*/
|
|
82
|
-
function blankLabelIssues(entries:
|
|
109
|
+
function blankLabelIssues(entries: EntryInLocale[]): CategoryIssue[] {
|
|
83
110
|
return entries.flatMap((entry, index) =>
|
|
84
111
|
entry.label ? [] : [{ message: LABEL_REQUIRED, path: [index, LABEL_PART] }],
|
|
85
112
|
);
|
|
@@ -92,7 +119,7 @@ function marketingIssues(kept: Set<string>): CategoryIssue[] {
|
|
|
92
119
|
}
|
|
93
120
|
|
|
94
121
|
function moveIssues(
|
|
95
|
-
entries:
|
|
122
|
+
entries: EntryInLocale[],
|
|
96
123
|
kept: Set<string>,
|
|
97
124
|
): CategoryIssue[] {
|
|
98
125
|
return entries.flatMap((entry, index) =>
|
|
@@ -106,20 +133,35 @@ function refineCategoryEntries(
|
|
|
106
133
|
entries: CategoryEntry[],
|
|
107
134
|
context: z.RefinementCtx,
|
|
108
135
|
): void {
|
|
109
|
-
const kept = new Set(
|
|
136
|
+
const kept = new Set(
|
|
137
|
+
entries.filter((entry) => entry.moveTo === undefined).map(({ id }) => id),
|
|
138
|
+
);
|
|
139
|
+
const labelIssues = languagesOf(entries).flatMap((locale) => {
|
|
140
|
+
const inLocale = entriesIn(entries, locale);
|
|
141
|
+
return [...blankLabelIssues(inLocale), ...duplicateIssues(inLocale)];
|
|
142
|
+
});
|
|
143
|
+
const seen = new Set<string>();
|
|
110
144
|
[
|
|
111
|
-
...
|
|
112
|
-
...duplicateIssues(entries),
|
|
145
|
+
...labelIssues,
|
|
113
146
|
...marketingIssues(kept),
|
|
114
|
-
...moveIssues(entries, kept),
|
|
115
|
-
]
|
|
116
|
-
|
|
117
|
-
|
|
147
|
+
...moveIssues(entriesIn(entries, LOCALIZED_TEXT_FALLBACK_LOCALE), kept),
|
|
148
|
+
]
|
|
149
|
+
.filter((issue) => {
|
|
150
|
+
// One issue per row and part, whichever language shows it first.
|
|
151
|
+
const id = JSON.stringify(issue);
|
|
152
|
+
if (seen.has(id)) return false;
|
|
153
|
+
seen.add(id);
|
|
154
|
+
return true;
|
|
155
|
+
})
|
|
156
|
+
.forEach((issue) =>
|
|
157
|
+
context.addIssue({ code: z.ZodIssueCode.custom, ...issue }),
|
|
158
|
+
);
|
|
118
159
|
}
|
|
119
160
|
|
|
120
161
|
/**
|
|
121
|
-
* The category list of the settings:
|
|
122
|
-
* move names a kept one, and the
|
|
162
|
+
* The category list of the settings: in every language, each kept label is
|
|
163
|
+
* set and unique; ids are unique, every move names a kept one, and the
|
|
164
|
+
* marketing category stays.
|
|
123
165
|
*/
|
|
124
166
|
export const categoriesSchema = z
|
|
125
167
|
.array(categoryEntrySchema)
|
|
@@ -182,6 +182,16 @@ export const testSendSchema = previewSchema.extend({
|
|
|
182
182
|
to: z.array(z.string().email()).min(1).max(MAX_TEST_RECIPIENT_COUNT),
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The details a template's drawer or the editor saves: the name typed in the
|
|
187
|
+
* reader's language, the slug it was read with, the category (`null` clears).
|
|
188
|
+
*/
|
|
189
|
+
export const templateDetailsSchema = z.object({
|
|
190
|
+
name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
|
|
191
|
+
slug: z.string().optional(),
|
|
192
|
+
category: z.string().nullable().optional(),
|
|
193
|
+
});
|
|
194
|
+
|
|
185
195
|
export const duplicateSchema = z.object({
|
|
186
196
|
slug: slugSchema,
|
|
187
197
|
name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { useProviderInfo } from '../composables/useMailingReads'
|
|
3
|
-
import { useSetupHidesPeriod } from '../composables/useOverviewSetup'
|
|
4
|
-
import { presetShortLabelKey } from '../utils/period'
|
|
5
|
-
import type { Component } from 'vue'
|
|
6
|
-
import type { MailingComponentProps } from '../types/component'
|
|
7
|
-
import type { ProviderInfo } from '../types/mailing'
|
|
8
|
-
|
|
9
|
-
interface PageHeaderProps extends MailingComponentProps {
|
|
10
|
-
periodScope?: string
|
|
11
|
-
presets?: string[]
|
|
12
|
-
defaultPreset?: string
|
|
13
|
-
defaultComparison?: string
|
|
14
|
-
showProvider?: boolean
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
defineOptions({ inheritAttrs: false })
|
|
18
|
-
|
|
19
|
-
const PERIOD_COMPONENT_SUFFIX = ':period'
|
|
20
|
-
|
|
21
|
-
const props = withDefaults(defineProps<PageHeaderProps>(), {
|
|
22
|
-
periodScope: undefined,
|
|
23
|
-
presets: () => [],
|
|
24
|
-
defaultPreset: undefined,
|
|
25
|
-
defaultComparison: undefined,
|
|
26
|
-
showProvider: true,
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
const { t } = useI18n()
|
|
30
|
-
|
|
31
|
-
const StatusPill = resolveComponent('DmsStatusPill') as Component
|
|
32
|
-
const PeriodSelector = resolveComponent('DmsPeriodSelector') as Component
|
|
33
|
-
|
|
34
|
-
/** Shown as "no provider" when the read fails. */
|
|
35
|
-
const NO_PROVIDER: ProviderInfo = { name: '', connected: false, features: null }
|
|
36
|
-
|
|
37
|
-
const providerRead = useProviderInfo(() => props.showProvider)
|
|
38
|
-
const provider = computed<ProviderInfo | null>(() =>
|
|
39
|
-
providerRead.error.value ? NO_PROVIDER : providerRead.data.value,
|
|
40
|
-
)
|
|
41
|
-
const hidesPeriod = useSetupHidesPeriod(props.periodScope)
|
|
42
|
-
|
|
43
|
-
const presetLabels = computed(() =>
|
|
44
|
-
Object.fromEntries(
|
|
45
|
-
props.presets.map((preset) => [preset, t(presetShortLabelKey(preset))]),
|
|
46
|
-
),
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
const providerPill = computed(() =>
|
|
50
|
-
provider.value?.connected
|
|
51
|
-
? {
|
|
52
|
-
tone: 'success',
|
|
53
|
-
dot: 'live',
|
|
54
|
-
label: t('dms_mailing.page_header.provider_connected', {
|
|
55
|
-
name: provider.value.name,
|
|
56
|
-
}),
|
|
57
|
-
}
|
|
58
|
-
: {
|
|
59
|
-
tone: 'warning',
|
|
60
|
-
dot: 'static',
|
|
61
|
-
label: t('dms_mailing.page_header.no_provider'),
|
|
62
|
-
},
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
function renderProvider() {
|
|
66
|
-
if (!props.showProvider || !provider.value) return null
|
|
67
|
-
return h(StatusPill, {
|
|
68
|
-
...providerPill.value,
|
|
69
|
-
title: t('dms_mailing.page_header.provider_hint'),
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function renderPeriod() {
|
|
74
|
-
if (!props.periodScope || hidesPeriod.value) return null
|
|
75
|
-
return h(PeriodSelector, {
|
|
76
|
-
id: props.periodScope,
|
|
77
|
-
componentId: `${props.componentId ?? props.periodScope}${PERIOD_COMPONENT_SUFFIX}`,
|
|
78
|
-
pageId: props.pageId ?? '',
|
|
79
|
-
variant: 'segmented',
|
|
80
|
-
size: 'sm',
|
|
81
|
-
showRangeLabel: false,
|
|
82
|
-
presets: props.presets.length ? props.presets : undefined,
|
|
83
|
-
presetLabels: presetLabels.value,
|
|
84
|
-
defaultPreset: props.defaultPreset,
|
|
85
|
-
defaultComparison: props.defaultComparison,
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
usePageHeaderActions(() => [renderProvider(), renderPeriod()])
|
|
90
|
-
</script>
|
|
91
|
-
|
|
92
|
-
<template>
|
|
93
|
-
<span hidden />
|
|
94
|
-
</template>
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { computed, type ComputedRef } from 'vue'
|
|
2
|
-
import { useSetupFacts } from './useMailingReads'
|
|
3
|
-
import { needsSetup } from '../utils/overview-setup'
|
|
4
|
-
|
|
5
|
-
/** The period scope of the Overview page, whose dashboard the setup gates. */
|
|
6
|
-
const OVERVIEW_PERIOD_SCOPE = 'mailing-overview'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Whether the page header should hold its period selector back for `scope`:
|
|
10
|
-
* on the Overview, while the setup card stands in for the dashboard, there is
|
|
11
|
-
* no figure for a period to filter. Read from the setup facts the Overview
|
|
12
|
-
* gate shows (one request for both), so a server render hides it as the
|
|
13
|
-
* browser does.
|
|
14
|
-
*/
|
|
15
|
-
export function useSetupHidesPeriod(
|
|
16
|
-
scope: string | undefined,
|
|
17
|
-
): ComputedRef<boolean> {
|
|
18
|
-
if (scope !== OVERVIEW_PERIOD_SCOPE) return computed(() => false)
|
|
19
|
-
const { data } = useSetupFacts()
|
|
20
|
-
return computed(() => !!data.value && needsSetup(data.value))
|
|
21
|
-
}
|