@antelopejs/dms-mailing 0.6.0 → 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.
Files changed (187) hide show
  1. package/dist/constants.d.ts +1 -0
  2. package/dist/constants.js +2 -1
  3. package/dist/constants.js.map +1 -1
  4. package/dist/data/templates-table.d.ts +4 -1
  5. package/dist/data/templates-table.js +55 -13
  6. package/dist/data/templates-table.js.map +1 -1
  7. package/dist/data-types/categories-type.d.ts +5 -5
  8. package/dist/db/models/templates.model.d.ts +10 -1
  9. package/dist/db/models/templates.model.js +27 -1
  10. package/dist/db/models/templates.model.js.map +1 -1
  11. package/dist/db/tables/templates.table.d.ts +7 -1
  12. package/dist/db/tables/templates.table.js +3 -1
  13. package/dist/db/tables/templates.table.js.map +1 -1
  14. package/dist/engine/formats.d.ts +13 -0
  15. package/dist/engine/formats.js +85 -0
  16. package/dist/engine/formats.js.map +1 -0
  17. package/dist/engine/index.d.ts +1 -0
  18. package/dist/engine/index.js +1 -0
  19. package/dist/engine/index.js.map +1 -1
  20. package/dist/engine/resolve.d.ts +2 -1
  21. package/dist/engine/resolve.js +14 -7
  22. package/dist/engine/resolve.js.map +1 -1
  23. package/dist/engine/tokens.d.ts +2 -1
  24. package/dist/engine/tokens.js +3 -5
  25. package/dist/engine/tokens.js.map +1 -1
  26. package/dist/frontend-module.d.ts +2 -0
  27. package/dist/frontend-module.js +24 -0
  28. package/dist/frontend-module.js.map +1 -0
  29. package/dist/hooks/tenant-export.js +4 -1
  30. package/dist/hooks/tenant-export.js.map +1 -1
  31. package/dist/implementations/dms-mailing/index.d.ts +1 -0
  32. package/dist/implementations/dms-mailing/index.js +3 -1
  33. package/dist/implementations/dms-mailing/index.js.map +1 -1
  34. package/dist/index.js +2 -19
  35. package/dist/index.js.map +1 -1
  36. package/dist/pages/overview.d.ts +1 -1
  37. package/dist/pages/overview.js +11 -13
  38. package/dist/pages/overview.js.map +1 -1
  39. package/dist/pages/sends.d.ts +1 -1
  40. package/dist/pages/sends.js +16 -8
  41. package/dist/pages/sends.js.map +1 -1
  42. package/dist/pages/templates.js +3 -8
  43. package/dist/pages/templates.js.map +1 -1
  44. package/dist/pages/toolbar.d.ts +12 -0
  45. package/dist/pages/toolbar.js +41 -0
  46. package/dist/pages/toolbar.js.map +1 -0
  47. package/dist/realtime.d.ts +2 -2
  48. package/dist/realtime.js +8 -8
  49. package/dist/realtime.js.map +1 -1
  50. package/dist/routes/metrics.d.ts +9 -2
  51. package/dist/routes/metrics.js +90 -35
  52. package/dist/routes/metrics.js.map +1 -1
  53. package/dist/routes/period.d.ts +1 -0
  54. package/dist/routes/period.js +19 -0
  55. package/dist/routes/period.js.map +1 -1
  56. package/dist/routes/sends.js +6 -3
  57. package/dist/routes/sends.js.map +1 -1
  58. package/dist/routes/settings.js +6 -7
  59. package/dist/routes/settings.js.map +1 -1
  60. package/dist/routes/templates.d.ts +8 -4
  61. package/dist/routes/templates.js +40 -15
  62. package/dist/routes/templates.js.map +1 -1
  63. package/dist/services/categories.d.ts +2 -1
  64. package/dist/services/categories.js +9 -0
  65. package/dist/services/categories.js.map +1 -1
  66. package/dist/services/declarations.js +2 -2
  67. package/dist/services/declarations.js.map +1 -1
  68. package/dist/services/events.js +1 -1
  69. package/dist/services/events.js.map +1 -1
  70. package/dist/services/localized-text.d.ts +7 -0
  71. package/dist/services/localized-text.js +28 -0
  72. package/dist/services/localized-text.js.map +1 -0
  73. package/dist/services/send-stat-items.js +34 -12
  74. package/dist/services/send-stat-items.js.map +1 -1
  75. package/dist/services/send-stats.d.ts +7 -0
  76. package/dist/services/send-stats.js +17 -5
  77. package/dist/services/send-stats.js.map +1 -1
  78. package/dist/services/send.d.ts +2 -0
  79. package/dist/services/send.js +11 -12
  80. package/dist/services/send.js.map +1 -1
  81. package/dist/services/sender-declaration.d.ts +2 -0
  82. package/dist/services/sender-declaration.js +18 -0
  83. package/dist/services/sender-declaration.js.map +1 -0
  84. package/dist/services/starters.d.ts +2 -2
  85. package/dist/services/starters.js +1 -10
  86. package/dist/services/starters.js.map +1 -1
  87. package/dist/services/templates.d.ts +11 -5
  88. package/dist/services/templates.js +24 -6
  89. package/dist/services/templates.js.map +1 -1
  90. package/dist/services/webhook-status.d.ts +2 -1
  91. package/dist/services/webhook-status.js +6 -5
  92. package/dist/services/webhook-status.js.map +1 -1
  93. package/dist/types/settings.d.ts +6 -0
  94. package/dist/types/settings.js +25 -5
  95. package/dist/types/settings.js.map +1 -1
  96. package/dist/validation/declarations.schema.d.ts +25 -7
  97. package/dist/validation/declarations.schema.js +16 -3
  98. package/dist/validation/declarations.schema.js.map +1 -1
  99. package/dist/validation/settings.schema.d.ts +25 -25
  100. package/dist/validation/settings.schema.js +30 -6
  101. package/dist/validation/settings.schema.js.map +1 -1
  102. package/dist/validation/templates.schema.d.ts +13 -0
  103. package/dist/validation/templates.schema.js +6 -1
  104. package/dist/validation/templates.schema.js.map +1 -1
  105. package/frontend-vue/app/components/CategoriesInput.vue +15 -13
  106. package/frontend-vue/app/components/FunnelCard.vue +46 -3
  107. package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
  108. package/frontend-vue/app/components/OverviewGate.vue +0 -8
  109. package/frontend-vue/app/components/ProviderPill.vue +50 -0
  110. package/frontend-vue/app/components/SendDrawer.vue +206 -183
  111. package/frontend-vue/app/components/StarterGallery.vue +1 -1
  112. package/frontend-vue/app/components/TemplateCard.vue +4 -1
  113. package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
  114. package/frontend-vue/app/components/TemplateGallery.vue +57 -17
  115. package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
  116. package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
  117. package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
  118. package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
  119. package/frontend-vue/app/composables/useMailingApi.ts +3 -3
  120. package/frontend-vue/app/composables/useMailingReads.ts +2 -2
  121. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
  122. package/frontend-vue/app/types/mailing.ts +18 -0
  123. package/frontend-vue/app/utils/categories.ts +58 -49
  124. package/frontend-vue/app/utils/funnel.ts +25 -0
  125. package/frontend-vue/app/utils/period.ts +0 -7
  126. package/frontend-vue/app/utils/starters.ts +3 -1
  127. package/frontend-vue/app/utils/tokens.ts +6 -1
  128. package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
  129. package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
  130. package/frontend-vue/package.json +1 -1
  131. package/frontend-vue/tests/categories-input.test.ts +4 -10
  132. package/frontend-vue/tests/categories.test.ts +55 -17
  133. package/frontend-vue/tests/conditions.test.ts +1 -4
  134. package/frontend-vue/tests/funnel-card.test.ts +27 -0
  135. package/frontend-vue/tests/funnel.test.ts +25 -0
  136. package/frontend-vue/tests/localized-defaults.test.ts +0 -29
  137. package/frontend-vue/tests/new-template-modal.test.ts +1 -1
  138. package/frontend-vue/tests/overview-gate.test.ts +1 -24
  139. package/frontend-vue/tests/page-reads.test.ts +9 -25
  140. package/frontend-vue/tests/period.test.ts +0 -12
  141. package/frontend-vue/tests/replay-toast.test.ts +2 -1
  142. package/frontend-vue/tests/send-drawer.test.ts +123 -0
  143. package/frontend-vue/tests/template-drawer.test.ts +1 -1
  144. package/frontend-vue/tests/template-gallery.test.ts +90 -3
  145. package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
  146. package/package.json +6 -5
  147. package/skills/dms-mailing/SKILL.md +17 -4
  148. package/src/constants.ts +2 -0
  149. package/src/data/sends-table.ts +1 -1
  150. package/src/data/templates-table.ts +123 -19
  151. package/src/db/models/templates.model.ts +61 -3
  152. package/src/db/tables/templates.table.ts +10 -1
  153. package/src/engine/formats.ts +124 -0
  154. package/src/engine/index.ts +1 -0
  155. package/src/engine/resolve.ts +18 -7
  156. package/src/engine/tokens.ts +7 -5
  157. package/src/frontend-module.ts +36 -0
  158. package/src/hooks/tenant-export.ts +8 -1
  159. package/src/implementations/dms-mailing/index.ts +2 -0
  160. package/src/index.ts +2 -23
  161. package/src/pages/overview.ts +20 -16
  162. package/src/pages/sends.ts +27 -11
  163. package/src/pages/templates.ts +4 -8
  164. package/src/pages/toolbar.ts +77 -0
  165. package/src/realtime.ts +17 -8
  166. package/src/routes/metrics.ts +158 -45
  167. package/src/routes/period.ts +32 -0
  168. package/src/routes/sends.ts +13 -4
  169. package/src/routes/settings.ts +11 -8
  170. package/src/routes/templates.ts +72 -26
  171. package/src/services/categories.ts +14 -1
  172. package/src/services/declarations.ts +4 -3
  173. package/src/services/events.ts +1 -1
  174. package/src/services/localized-text.ts +53 -0
  175. package/src/services/send-stat-items.ts +60 -13
  176. package/src/services/send-stats.ts +37 -5
  177. package/src/services/send.ts +19 -20
  178. package/src/services/sender-declaration.ts +27 -0
  179. package/src/services/starters.ts +3 -15
  180. package/src/services/templates.ts +50 -19
  181. package/src/services/webhook-status.ts +3 -3
  182. package/src/types/settings.ts +35 -5
  183. package/src/validation/declarations.schema.ts +29 -3
  184. package/src/validation/settings.schema.ts +56 -14
  185. package/src/validation/templates.schema.ts +10 -0
  186. package/frontend-vue/app/components/PageHeader.vue +0 -94
  187. 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
+ }
@@ -1,11 +1,11 @@
1
- import type { MailingTemplate } from "../db";
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
- ): Partial<MailingTemplate> {
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
- ): Partial<MailingTemplate> {
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).update(template._id, {
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).update(template._id, {
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: Partial<MailingTemplate> = {},
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: Partial<MailingTemplate> = isUnchanged
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: Partial<MailingTemplate> = {
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
- /** The content a send of `version` carried: the draft's for a test of one. */
435
- export async function contentOfVersion(
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<TemplateContent> {
440
- if (!version) return workingContent(template);
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 parseVersionContent(row);
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: Partial<MailingTemplate>,
519
+ fields: TemplateFields,
487
520
  ): Promise<void> {
488
- await GetModel(TemplateModel, tenantId).update(templateId, {
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";
@@ -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
- { id: "orders", label: "Orders", icon: "i-ph-package" },
20
- { id: "account", label: "Account & access", icon: "i-ph-lock" },
21
- { id: "billing", label: "Billing", icon: "i-ph-file-text" },
22
- { id: "marketing", label: "Marketing", icon: "i-ph-sparkle" },
23
- { id: "forms", label: "Forms", icon: "i-ph-textbox" },
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: z.string().trim().min(1).max(MAX_NAME_LENGTH),
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: z.string().trim().min(1).max(MAX_NAME_LENGTH),
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: z.string().trim().max(MAX_NAME_LENGTH),
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: CategoryEntry): boolean => entry.moveTo === undefined;
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: CategoryEntry[]): CategoryIssue[] {
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: CategoryEntry[]): CategoryIssue[] {
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: CategoryEntry[],
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(entries.filter(isKept).map((entry) => entry.id));
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
- ...blankLabelIssues(entries),
112
- ...duplicateIssues(entries),
145
+ ...labelIssues,
113
146
  ...marketingIssues(kept),
114
- ...moveIssues(entries, kept),
115
- ].forEach((issue) =>
116
- context.addIssue({ code: z.ZodIssueCode.custom, ...issue }),
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: kept labels and ids are unique, every
122
- * move names a kept one, and the marketing category stays.
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
- }