@antelopejs/dms-mailing 0.6.1 → 0.7.1

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 (236) hide show
  1. package/README.md +8 -7
  2. package/dist/constants.d.ts +1 -2
  3. package/dist/constants.js +2 -3
  4. package/dist/constants.js.map +1 -1
  5. package/dist/data/templates-table.d.ts +4 -1
  6. package/dist/data/templates-table.js +55 -20
  7. package/dist/data/templates-table.js.map +1 -1
  8. package/dist/data-types/categories-type.d.ts +5 -5
  9. package/dist/db/models/index.d.ts +0 -1
  10. package/dist/db/models/index.js +0 -1
  11. package/dist/db/models/index.js.map +1 -1
  12. package/dist/db/models/send-events.model.js +2 -3
  13. package/dist/db/models/send-events.model.js.map +1 -1
  14. package/dist/db/models/sends.model.js +2 -3
  15. package/dist/db/models/sends.model.js.map +1 -1
  16. package/dist/db/models/templates.model.d.ts +10 -1
  17. package/dist/db/models/templates.model.js +29 -10
  18. package/dist/db/models/templates.model.js.map +1 -1
  19. package/dist/db/tables/index.d.ts +0 -1
  20. package/dist/db/tables/index.js +0 -1
  21. package/dist/db/tables/index.js.map +1 -1
  22. package/dist/db/tables/settings.table.d.ts +0 -1
  23. package/dist/db/tables/settings.table.js +0 -4
  24. package/dist/db/tables/settings.table.js.map +1 -1
  25. package/dist/db/tables/templates.table.d.ts +7 -4
  26. package/dist/db/tables/templates.table.js +4 -14
  27. package/dist/db/tables/templates.table.js.map +1 -1
  28. package/dist/engine/formats.d.ts +13 -0
  29. package/dist/engine/formats.js +85 -0
  30. package/dist/engine/formats.js.map +1 -0
  31. package/dist/engine/index.d.ts +1 -0
  32. package/dist/engine/index.js +1 -0
  33. package/dist/engine/index.js.map +1 -1
  34. package/dist/engine/resolve.d.ts +2 -1
  35. package/dist/engine/resolve.js +14 -7
  36. package/dist/engine/resolve.js.map +1 -1
  37. package/dist/engine/tokens.d.ts +2 -1
  38. package/dist/engine/tokens.js +3 -5
  39. package/dist/engine/tokens.js.map +1 -1
  40. package/dist/frontend-module.d.ts +2 -0
  41. package/dist/frontend-module.js +24 -0
  42. package/dist/frontend-module.js.map +1 -0
  43. package/dist/hooks/index.d.ts +0 -1
  44. package/dist/hooks/index.js +0 -1
  45. package/dist/hooks/index.js.map +1 -1
  46. package/dist/hooks/tenant-export.js +4 -1
  47. package/dist/hooks/tenant-export.js.map +1 -1
  48. package/dist/implementations/dms-mailing/index.d.ts +3 -3
  49. package/dist/implementations/dms-mailing/index.js +8 -7
  50. package/dist/implementations/dms-mailing/index.js.map +1 -1
  51. package/dist/index.js +2 -20
  52. package/dist/index.js.map +1 -1
  53. package/dist/pages/overview.d.ts +1 -1
  54. package/dist/pages/overview.js +11 -13
  55. package/dist/pages/overview.js.map +1 -1
  56. package/dist/pages/sends.d.ts +1 -1
  57. package/dist/pages/sends.js +16 -8
  58. package/dist/pages/sends.js.map +1 -1
  59. package/dist/pages/settings/form.js +8 -0
  60. package/dist/pages/settings/form.js.map +1 -1
  61. package/dist/pages/templates.js +3 -8
  62. package/dist/pages/templates.js.map +1 -1
  63. package/dist/pages/toolbar.d.ts +12 -0
  64. package/dist/pages/toolbar.js +41 -0
  65. package/dist/pages/toolbar.js.map +1 -0
  66. package/dist/realtime.d.ts +2 -2
  67. package/dist/realtime.js +8 -8
  68. package/dist/realtime.js.map +1 -1
  69. package/dist/routes/metrics.d.ts +9 -2
  70. package/dist/routes/metrics.js +90 -35
  71. package/dist/routes/metrics.js.map +1 -1
  72. package/dist/routes/period.d.ts +1 -0
  73. package/dist/routes/period.js +19 -0
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +6 -3
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.js +6 -7
  78. package/dist/routes/settings.js.map +1 -1
  79. package/dist/routes/templates.d.ts +8 -4
  80. package/dist/routes/templates.js +41 -20
  81. package/dist/routes/templates.js.map +1 -1
  82. package/dist/services/categories.d.ts +2 -1
  83. package/dist/services/categories.js +9 -0
  84. package/dist/services/categories.js.map +1 -1
  85. package/dist/services/declarations.js +42 -184
  86. package/dist/services/declarations.js.map +1 -1
  87. package/dist/services/events.js +1 -1
  88. package/dist/services/events.js.map +1 -1
  89. package/dist/services/localized-text.d.ts +7 -0
  90. package/dist/services/localized-text.js +28 -0
  91. package/dist/services/localized-text.js.map +1 -0
  92. package/dist/services/send-import.js +0 -2
  93. package/dist/services/send-import.js.map +1 -1
  94. package/dist/services/send-stat-items.js +34 -12
  95. package/dist/services/send-stat-items.js.map +1 -1
  96. package/dist/services/send-stats.d.ts +7 -0
  97. package/dist/services/send-stats.js +17 -5
  98. package/dist/services/send-stats.js.map +1 -1
  99. package/dist/services/send.d.ts +2 -0
  100. package/dist/services/send.js +11 -12
  101. package/dist/services/send.js.map +1 -1
  102. package/dist/services/sender-declaration.d.ts +2 -0
  103. package/dist/services/sender-declaration.js +18 -0
  104. package/dist/services/sender-declaration.js.map +1 -0
  105. package/dist/services/settings.d.ts +1 -1
  106. package/dist/services/settings.js +1 -5
  107. package/dist/services/settings.js.map +1 -1
  108. package/dist/services/starters.d.ts +2 -2
  109. package/dist/services/starters.js +1 -10
  110. package/dist/services/starters.js.map +1 -1
  111. package/dist/services/templates.d.ts +13 -7
  112. package/dist/services/templates.js +72 -33
  113. package/dist/services/templates.js.map +1 -1
  114. package/dist/services/webhook-status.d.ts +2 -1
  115. package/dist/services/webhook-status.js +6 -5
  116. package/dist/services/webhook-status.js.map +1 -1
  117. package/dist/types/settings.d.ts +8 -0
  118. package/dist/types/settings.js +33 -6
  119. package/dist/types/settings.js.map +1 -1
  120. package/dist/validation/declarations.schema.d.ts +25 -7
  121. package/dist/validation/declarations.schema.js +16 -3
  122. package/dist/validation/declarations.schema.js.map +1 -1
  123. package/dist/validation/settings.schema.d.ts +41 -41
  124. package/dist/validation/settings.schema.js +30 -6
  125. package/dist/validation/settings.schema.js.map +1 -1
  126. package/dist/validation/templates.schema.d.ts +13 -0
  127. package/dist/validation/templates.schema.js +6 -1
  128. package/dist/validation/templates.schema.js.map +1 -1
  129. package/frontend-vue/app/components/CategoriesInput.vue +15 -13
  130. package/frontend-vue/app/components/FunnelCard.vue +46 -3
  131. package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
  132. package/frontend-vue/app/components/OverviewGate.vue +0 -8
  133. package/frontend-vue/app/components/ProviderPill.vue +50 -0
  134. package/frontend-vue/app/components/SendDrawer.vue +206 -183
  135. package/frontend-vue/app/components/StarterGallery.vue +1 -1
  136. package/frontend-vue/app/components/TemplateCard.vue +4 -1
  137. package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
  138. package/frontend-vue/app/components/TemplateGallery.vue +57 -17
  139. package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
  140. package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
  141. package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
  142. package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
  143. package/frontend-vue/app/composables/useMailingApi.ts +3 -3
  144. package/frontend-vue/app/composables/useMailingReads.ts +2 -2
  145. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
  146. package/frontend-vue/app/types/mailing.ts +18 -0
  147. package/frontend-vue/app/utils/categories.ts +58 -49
  148. package/frontend-vue/app/utils/funnel.ts +25 -0
  149. package/frontend-vue/app/utils/period.ts +0 -7
  150. package/frontend-vue/app/utils/starters.ts +3 -1
  151. package/frontend-vue/app/utils/tokens.ts +6 -1
  152. package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
  153. package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
  154. package/frontend-vue/package.json +1 -1
  155. package/frontend-vue/tests/categories-input.test.ts +4 -10
  156. package/frontend-vue/tests/categories.test.ts +55 -17
  157. package/frontend-vue/tests/conditions.test.ts +1 -4
  158. package/frontend-vue/tests/funnel-card.test.ts +27 -0
  159. package/frontend-vue/tests/funnel.test.ts +25 -0
  160. package/frontend-vue/tests/localized-defaults.test.ts +0 -29
  161. package/frontend-vue/tests/new-template-modal.test.ts +1 -1
  162. package/frontend-vue/tests/overview-gate.test.ts +1 -24
  163. package/frontend-vue/tests/page-reads.test.ts +9 -25
  164. package/frontend-vue/tests/period.test.ts +0 -12
  165. package/frontend-vue/tests/replay-toast.test.ts +2 -1
  166. package/frontend-vue/tests/send-drawer.test.ts +123 -0
  167. package/frontend-vue/tests/template-drawer.test.ts +1 -1
  168. package/frontend-vue/tests/template-gallery.test.ts +90 -3
  169. package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
  170. package/package.json +8 -7
  171. package/skills/dms-mailing/SKILL.md +25 -18
  172. package/src/constants.ts +2 -2
  173. package/src/data/sends-table.ts +1 -1
  174. package/src/data/templates-table.ts +125 -33
  175. package/src/db/models/index.ts +0 -1
  176. package/src/db/models/send-events.model.ts +2 -2
  177. package/src/db/models/sends.model.ts +7 -8
  178. package/src/db/models/templates.model.ts +64 -16
  179. package/src/db/tables/index.ts +0 -1
  180. package/src/db/tables/settings.table.ts +0 -8
  181. package/src/db/tables/templates.table.ts +12 -17
  182. package/src/engine/formats.ts +124 -0
  183. package/src/engine/index.ts +1 -0
  184. package/src/engine/resolve.ts +18 -7
  185. package/src/engine/tokens.ts +7 -5
  186. package/src/frontend-module.ts +36 -0
  187. package/src/hooks/index.ts +0 -1
  188. package/src/hooks/tenant-export.ts +8 -1
  189. package/src/implementations/dms-mailing/index.ts +5 -16
  190. package/src/index.ts +3 -29
  191. package/src/pages/overview.ts +20 -16
  192. package/src/pages/sends.ts +27 -11
  193. package/src/pages/settings/form.ts +16 -0
  194. package/src/pages/templates.ts +4 -8
  195. package/src/pages/toolbar.ts +77 -0
  196. package/src/realtime.ts +17 -8
  197. package/src/routes/metrics.ts +158 -45
  198. package/src/routes/period.ts +32 -0
  199. package/src/routes/sends.ts +13 -4
  200. package/src/routes/settings.ts +11 -8
  201. package/src/routes/templates.ts +81 -37
  202. package/src/services/categories.ts +14 -1
  203. package/src/services/declarations.ts +64 -317
  204. package/src/services/events.ts +1 -1
  205. package/src/services/localized-text.ts +53 -0
  206. package/src/services/send-import.ts +2 -2
  207. package/src/services/send-stat-items.ts +60 -13
  208. package/src/services/send-stats.ts +37 -5
  209. package/src/services/send.ts +19 -20
  210. package/src/services/sender-declaration.ts +27 -0
  211. package/src/services/settings.ts +3 -10
  212. package/src/services/starters.ts +3 -15
  213. package/src/services/templates.ts +109 -63
  214. package/src/services/webhook-status.ts +3 -3
  215. package/src/types/settings.ts +55 -5
  216. package/src/validation/declarations.schema.ts +29 -3
  217. package/src/validation/settings.schema.ts +56 -14
  218. package/src/validation/templates.schema.ts +10 -0
  219. package/dist/db/models/template-slugs.model.d.ts +0 -27
  220. package/dist/db/models/template-slugs.model.js +0 -43
  221. package/dist/db/models/template-slugs.model.js.map +0 -1
  222. package/dist/db/tables/template-slugs.table.d.ts +0 -7
  223. package/dist/db/tables/template-slugs.table.js +0 -35
  224. package/dist/db/tables/template-slugs.table.js.map +0 -1
  225. package/dist/hooks/database-ready.d.ts +0 -7
  226. package/dist/hooks/database-ready.js +0 -26
  227. package/dist/hooks/database-ready.js.map +0 -1
  228. package/dist/services/fingerprint.d.ts +0 -1
  229. package/dist/services/fingerprint.js +0 -20
  230. package/dist/services/fingerprint.js.map +0 -1
  231. package/frontend-vue/app/components/PageHeader.vue +0 -94
  232. package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
  233. package/src/db/models/template-slugs.model.ts +0 -61
  234. package/src/db/tables/template-slugs.table.ts +0 -29
  235. package/src/hooks/database-ready.ts +0 -47
  236. package/src/services/fingerprint.ts +0 -22
@@ -27,7 +27,12 @@ interface TemplatePreviewFrameProps {
27
27
  width: number
28
28
  scale?: number
29
29
  refreshKey?: number
30
- /** Fetches only once the frame scrolls into view (galleries). */
30
+ /**
31
+ * Fetches only once the frame scrolls into view (galleries). The frame then
32
+ * waits on a blank sheet rather than a skeleton: the gallery's own skeleton
33
+ * covers its first load, and a thumbnail starting to load as it scrolls into
34
+ * view would bring a skeleton back.
35
+ */
31
36
  lazy?: boolean
32
37
  /** Accessible name of the frame. */
33
38
  label?: string
@@ -61,14 +66,15 @@ const props = withDefaults(defineProps<TemplatePreviewFrameProps>(), {
61
66
  const emit = defineEmits<{ resolved: [PreviewResponse] }>()
62
67
 
63
68
  const api = useMailingApi()
64
- const { locale: uiLocale } = useI18n()
69
+ const { t, locale: uiLocale } = useI18n()
65
70
 
66
71
  const readStarterPreview = useStarterPreviewRead(
67
72
  () => props.starterId,
68
73
  () => props.locale ?? uiLocale.value,
69
74
  )
70
75
  const fetchedHtml = ref('')
71
- const loading = ref(false)
76
+ /** The last read failed: the frame shows its fallback, not a skeleton for ever. */
77
+ const hasFailed = ref(false)
72
78
  const height = ref(FALLBACK_HEIGHT)
73
79
  const frame = ref<HTMLIFrameElement | null>(null)
74
80
  const box = ref<HTMLElement | null>(null)
@@ -87,6 +93,19 @@ function withoutScroll(html: string): string {
87
93
 
88
94
  const shownHtml = computed(() => withoutScroll(props.html ?? fetchedHtml.value))
89
95
 
96
+ const hasSource = computed(
97
+ () =>
98
+ props.html !== undefined || Boolean(props.starterId || props.templateId),
99
+ )
100
+ /**
101
+ * Waiting for the HTML: from the first render (a server render, the debounce
102
+ * and a lazy frame not yet in view included), so the placeholder holds its
103
+ * place without a gap between it and the content.
104
+ */
105
+ const isWaiting = computed(
106
+ () => !shownHtml.value && hasSource.value && !hasFailed.value,
107
+ )
108
+
90
109
  useIntersectionObserver(box, ([entry]) => {
91
110
  if (entry?.isIntersecting) isVisible.value = true
92
111
  })
@@ -110,15 +129,14 @@ async function fetchTemplate(templateId: string): Promise<void> {
110
129
  async function load(): Promise<void> {
111
130
  if (props.html !== undefined || !isVisible.value) return
112
131
  if (!props.starterId && !props.templateId) return
113
- loading.value = true
114
132
  try {
115
133
  await (props.starterId
116
134
  ? fetchStarter()
117
135
  : fetchTemplate(props.templateId as string))
136
+ hasFailed.value = false
118
137
  } catch {
119
138
  fetchedHtml.value = ''
120
- } finally {
121
- loading.value = false
139
+ hasFailed.value = true
122
140
  }
123
141
  }
124
142
 
@@ -172,12 +190,10 @@ const frameStyle = computed(() => ({
172
190
 
173
191
  <template>
174
192
  <div ref="box" class="relative overflow-hidden" :style="boxStyle">
175
- <USkeleton
176
- v-if="!shownHtml && (loading || !isVisible)"
177
- class="absolute inset-0 size-full"
178
- />
193
+ <!-- Rendered only with HTML: an empty frame loads too, and measuring it
194
+ would shrink the box to a few pixels. -->
179
195
  <iframe
180
- v-show="shownHtml"
196
+ v-if="shownHtml"
181
197
  ref="frame"
182
198
  :srcdoc="shownHtml"
183
199
  sandbox="allow-same-origin"
@@ -191,5 +207,19 @@ const frameStyle = computed(() => ({
191
207
  :style="frameStyle"
192
208
  @load="measure"
193
209
  />
210
+ <div
211
+ v-else-if="isWaiting && lazy"
212
+ class="absolute inset-0 bg-white"
213
+ data-preview-placeholder
214
+ />
215
+ <USkeleton v-else-if="isWaiting" class="absolute inset-0 size-full" />
216
+ <div
217
+ v-else-if="hasFailed"
218
+ class="bg-elevated text-dimmed absolute inset-0 flex flex-col items-center justify-center gap-1.5 text-[12px]"
219
+ data-preview-fallback
220
+ >
221
+ <UIcon name="i-ph-image-broken" class="size-5" aria-hidden="true" />
222
+ {{ t('dms_mailing.common.preview_unavailable') }}
223
+ </div>
194
224
  </div>
195
225
  </template>
@@ -2,7 +2,6 @@
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import type { TemplateDetails } from '../../composables/useMailingApi'
4
4
  import { useEditorContext } from '../../composables/useEditorContext'
5
- import { useLocalizedDefaults } from '../../composables/useLocalizedDefaults'
6
5
 
7
6
  const KEY = 'dms_mailing.editor.template_tab'
8
7
  const NO_CATEGORY = '__none__'
@@ -10,7 +9,6 @@ const NO_CATEGORY = '__none__'
10
9
  const { t } = useI18n()
11
10
  const toast = useToast()
12
11
  const { editor, session, actions, templateId } = useEditorContext()
13
- const localized = useLocalizedDefaults()
14
12
 
15
13
  const template = computed(() => session.state.template)
16
14
  const name = ref(template.value.name)
@@ -22,7 +20,7 @@ watch(
22
20
 
23
21
  const categoryItems = computed(() => [
24
22
  { value: NO_CATEGORY, label: t(`${KEY}.no_category`), icon: 'i-ph-minus' },
25
- ...localized.categories(session.state.categories).map((category) => ({
23
+ ...session.state.categories.map((category) => ({
26
24
  value: category.id,
27
25
  label: category.label,
28
26
  icon: category.icon,
@@ -1,7 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import { useEditorContext } from '../../composables/useEditorContext'
4
- import { useLocalizedDefaults } from '../../composables/useLocalizedDefaults'
5
4
  import { TEMPLATES_PATH } from '../../utils/pages'
6
5
  import type { StatusKey } from '../../utils/lifecycle'
7
6
 
@@ -14,7 +13,6 @@ const STATUS_TONES: Record<StatusKey, string> = {
14
13
  const SEPARATOR = ' · '
15
14
 
16
15
  const { t } = useI18n()
17
- const localized = useLocalizedDefaults()
18
16
  const { editor, session } = useEditorContext()
19
17
 
20
18
  const template = computed(() => session.state.template)
@@ -38,11 +36,7 @@ const statusLabel = computed(() => {
38
36
  const categoryLabel = computed(() => {
39
37
  const id = template.value.category
40
38
  if (!id) return t('dms_mailing.editor.toolbar.uncategorised')
41
- return (
42
- localized
43
- .categories(session.state.categories)
44
- .find((entry) => entry.id === id)?.label ?? id
45
- )
39
+ return session.state.categories.find((entry) => entry.id === id)?.label ?? id
46
40
  })
47
41
  </script>
48
42
 
@@ -1,17 +1,15 @@
1
- import type { StarterSummary, TemplateCategory } from '../types/mailing'
2
- import { localizeCategories, type OptionalTranslate } from '../utils/categories'
3
- import { localizeStarters } from '../utils/starters'
1
+ import type { StarterSummary } from '../types/mailing'
2
+ import { localizeStarters, type OptionalTranslate } from '../utils/starters'
4
3
 
5
4
  /**
6
- * What the module ships by default (starters, the first categories) named in
7
- * the interface's language, without touching what is stored.
5
+ * The starters the module ships, named in the interface's language. Template
6
+ * names and category labels are localized fields the server reads in the
7
+ * interface's language.
8
8
  */
9
9
  export function useLocalizedDefaults() {
10
10
  const { t, te } = useI18n()
11
11
  const translate: OptionalTranslate = (key) => (te(key) ? t(key) : undefined)
12
12
  return {
13
- categories: (list: TemplateCategory[]) =>
14
- localizeCategories(list, translate),
15
13
  starters: (list: StarterSummary[]) => localizeStarters(list, translate),
16
14
  }
17
15
  }
@@ -192,14 +192,14 @@ export function useMailingApi() {
192
192
  return {
193
193
  webhookTest: () =>
194
194
  post<WebhookTestResult>(mailingPath('settings', 'webhook-test'), {}),
195
+ /** Renames in the interface's language, the other languages kept. */
195
196
  saveTemplateDetails: (id: string, details: TemplateDetails) =>
196
- $authFetch<unknown>(mailingPath('tables', 'templates', 'edit'), {
197
+ $authFetch<unknown>(mailingPath('templates', id, 'details'), {
197
198
  method: 'PUT',
198
- query: { id },
199
199
  body: details,
200
200
  }),
201
201
  createTemplate: (input: CreateTemplateInput) =>
202
- post<string[]>(mailingPath('tables', 'templates', 'new'), input),
202
+ post<TemplateRow>(mailingPath('tables', 'templates', 'new'), input),
203
203
  saveContent: (id: string, input: SaveContentInput) =>
204
204
  $authFetch<SavedResponse>(mailingPath('templates', id, 'content'), {
205
205
  method: 'POST',
@@ -30,7 +30,7 @@ import type {
30
30
  * `useMailingApi`.
31
31
  */
32
32
 
33
- /** The e-mail provider the page header names; nothing read while `enabled` is false. */
33
+ /** The e-mail provider the page toolbar names; nothing read while `enabled` is false. */
34
34
  export function useProviderInfo(
35
35
  enabled: MaybeRefOrGetter<boolean>,
36
36
  ): ApiDataResult<ProviderInfo> {
@@ -66,7 +66,7 @@ export function useCategoryUsage(): ApiDataResult<CategoryUsageResponse> {
66
66
  )
67
67
  }
68
68
 
69
- /** The tenant's template categories, as stored. */
69
+ /** The tenant's template categories, labelled in the interface's language. */
70
70
  export function useStoredCategories(): ApiDataResult<CategoriesResponse> {
71
71
  return useApiData<CategoriesResponse>(mailingPath('templates', 'categories'))
72
72
  }
@@ -1,16 +1,17 @@
1
- import { useLocalizedDefaults } from './useLocalizedDefaults'
2
1
  import { useStoredCategories } from './useMailingReads'
3
2
 
4
3
  /**
5
- * The tenant's template categories, named in the interface's language: one
6
- * read the components showing them share (a table's category cells, the
7
- * gallery), none when it failed.
4
+ * The tenant's template categories, labelled in the interface's language by
5
+ * the server: one read the components showing them share (a table's category
6
+ * cells, the gallery), none when it failed.
7
+ * `isAnswered` turns true once the read has answered, with the categories or
8
+ * a failure.
8
9
  */
9
10
  export function useTemplateCategories() {
10
- const localized = useLocalizedDefaults()
11
11
  const { data, error } = useStoredCategories()
12
12
  const categories = computed(() =>
13
- localized.categories(error.value ? [] : (data.value?.categories ?? [])),
13
+ error.value ? [] : (data.value?.categories ?? []),
14
14
  )
15
- return { categories }
15
+ const isAnswered = computed(() => Boolean(data.value || error.value))
16
+ return { categories, isAnswered }
16
17
  }
@@ -271,12 +271,26 @@ export const SEND_STATUSES: SendStatus[] = [
271
271
  /** What a provider reports: a suppressed send never reached one. */
272
272
  export type SendEventType = Exclude<SendStatus, 'suppressed'>
273
273
 
274
+ /** A text written once per language: `{ en: 'Orders', fr: 'Commandes' }`. */
275
+ export type LocalizedText = Record<string, string>
276
+
277
+ /** A template category as one reader sees it: its label in their language. */
274
278
  export interface TemplateCategory {
275
279
  id: string
276
280
  label: string
277
281
  icon: string
278
282
  }
279
283
 
284
+ /**
285
+ * A template category as the settings store it, read and written whole by
286
+ * the settings form: its label in every language.
287
+ */
288
+ export interface StoredCategory {
289
+ id: string
290
+ label: LocalizedText
291
+ icon: string
292
+ }
293
+
280
294
  export interface TemplateRow {
281
295
  _id: string
282
296
  slug: string
@@ -521,6 +535,10 @@ export interface FunnelStep {
521
535
  rate: number | null
522
536
  /** `false` when nothing reports the events this step counts. */
523
537
  isMeasured?: boolean
538
+ /** The count over the comparison window; absent with the comparison off. */
539
+ previousCount?: number
540
+ /** The change of the count, in percent; absent from an empty comparison. */
541
+ delta?: number
524
542
  }
525
543
 
526
544
  export interface ProviderInfo {
@@ -1,8 +1,33 @@
1
- import type { TemplateCategory } from '../types/mailing'
1
+ import type {
2
+ LocalizedText,
3
+ StoredCategory,
4
+ TemplateCategory,
5
+ } from '../types/mailing'
2
6
 
3
7
  const ID_SEPARATOR = '-'
4
8
 
5
- export function serializeCategories(categories: TemplateCategory[]): string {
9
+ /** The language a localized label falls back to, as the server reads it. */
10
+ export const FALLBACK_LOCALE = 'en'
11
+
12
+ /** A label in `locale`, else in the fallback language. */
13
+ export function labelIn(label: LocalizedText, locale: string): string {
14
+ return label[locale] ?? label[FALLBACK_LOCALE] ?? ''
15
+ }
16
+
17
+ /**
18
+ * `label` with `value` written in `locale`: the other languages are kept,
19
+ * and the fallback one gets the value while it has none, so a category
20
+ * added in another language still reads in every language.
21
+ */
22
+ export function withLabelIn(
23
+ label: LocalizedText,
24
+ locale: string,
25
+ value: string,
26
+ ): LocalizedText {
27
+ return { [FALLBACK_LOCALE]: value, ...label, [locale]: value }
28
+ }
29
+
30
+ export function serializeCategories(categories: StoredCategory[]): string {
6
31
  return JSON.stringify(categories)
7
32
  }
8
33
 
@@ -34,7 +59,9 @@ const ICON_PREFIX = 'i-ph-'
34
59
  * The icon a new category starts with: the first one of the picker no
35
60
  * category uses yet, so a new row never looks like an existing one.
36
61
  */
37
- export function newCategoryIcon(categories: TemplateCategory[]): string {
62
+ export function newCategoryIcon(
63
+ categories: Pick<TemplateCategory, 'icon'>[],
64
+ ): string {
38
65
  const used = new Set(categories.map((category) => category.icon))
39
66
  return CATEGORY_ICONS.find((icon) => !used.has(icon)) ?? DEFAULT_CATEGORY_ICON
40
67
  }
@@ -47,12 +74,30 @@ export function iconName(icon: string): string {
47
74
  }
48
75
 
49
76
  /**
50
- * A row of the categories editor. `isNew` rows were added in this edit: their
51
- * id follows the label until the form is saved, then stays fixed for good.
77
+ * A row of the categories editor: `label` is the text of the language being
78
+ * edited, `labels` what the row stores in every language. `isNew` rows were
79
+ * added in this edit: their id follows the label until the form is saved,
80
+ * then stays fixed for good.
52
81
  */
53
82
  export interface EditableCategory extends TemplateCategory {
54
83
  key: string
55
84
  isNew: boolean
85
+ labels: LocalizedText
86
+ }
87
+
88
+ /** The rows of `categories`, each label shown in `locale`. */
89
+ export function toEditableRows(
90
+ categories: StoredCategory[],
91
+ locale: string,
92
+ keyOf: () => string,
93
+ ): EditableCategory[] {
94
+ return categories.map((category) => ({
95
+ ...category,
96
+ label: labelIn(category.label, locale),
97
+ labels: category.label,
98
+ key: keyOf(),
99
+ isNew: false,
100
+ }))
56
101
  }
57
102
 
58
103
  const UNIQUE_SUFFIX_START = 2
@@ -69,11 +114,13 @@ export function uniqueId(base: string, taken: Set<string>): string {
69
114
  /**
70
115
  * The categories an edit stores, one per row in its order: saved rows keep
71
116
  * their id, new rows get one derived from their label, unique among the
72
- * others. A row without a label is sent too, for the server to name it.
117
+ * others. Each label is written in `locale`, its other languages kept. A row
118
+ * without a label is sent too, for the server to name it.
73
119
  */
74
120
  export function toStoredCategories(
75
121
  rows: EditableCategory[],
76
- ): TemplateCategory[] {
122
+ locale: string,
123
+ ): StoredCategory[] {
77
124
  const taken = new Set(rows.filter((row) => !row.isNew).map((row) => row.id))
78
125
  return rows.map((row) => {
79
126
  const id = row.isNew
@@ -82,7 +129,7 @@ export function toStoredCategories(
82
129
  taken.add(id)
83
130
  return {
84
131
  id,
85
- label: row.label.trim(),
132
+ label: withLabelIn(row.labels, locale, row.label.trim()),
86
133
  icon: row.icon || DEFAULT_CATEGORY_ICON,
87
134
  }
88
135
  })
@@ -92,12 +139,12 @@ export function toStoredCategories(
92
139
  * A category as the editor hands it to the form: a kept one, or one removed
93
140
  * whose templates move to `moveTo` once the form is saved.
94
141
  */
95
- export interface CategoryEntry extends TemplateCategory {
142
+ export interface CategoryEntry extends StoredCategory {
96
143
  moveTo?: string
97
144
  }
98
145
 
99
146
  /** The kept categories of a list, without the removed ones it moves away. */
100
- export function keptCategories(entries: CategoryEntry[]): TemplateCategory[] {
147
+ export function keptCategories(entries: CategoryEntry[]): StoredCategory[] {
101
148
  return entries.filter((entry) => entry.moveTo === undefined)
102
149
  }
103
150
 
@@ -115,7 +162,7 @@ export function categoryMoves(entries: CategoryEntry[]): Map<string, string> {
115
162
  * whose templates move elsewhere, still under its own id.
116
163
  */
117
164
  export function withMoves(
118
- categories: TemplateCategory[],
165
+ categories: StoredCategory[],
119
166
  removed: CategoryEntry[],
120
167
  ): CategoryEntry[] {
121
168
  return [...categories, ...removed.filter((entry) => entry.moveTo)]
@@ -200,41 +247,3 @@ export function toStoredCategory(
200
247
  ): string | null {
201
248
  return value || null
202
249
  }
203
-
204
- /** Answers the text of an i18n key, or nothing when the catalog lacks it. */
205
- export type OptionalTranslate = (key: string) => string | undefined
206
-
207
- /** The labels the backend's `DEFAULT_CATEGORIES` (`src/types/settings.ts`) ship with. */
208
- const DEFAULT_CATEGORY_LABELS: Record<string, string> = {
209
- orders: 'Orders',
210
- account: 'Account & access',
211
- billing: 'Billing',
212
- marketing: 'Marketing',
213
- forms: 'Forms',
214
- }
215
- const DEFAULT_CATEGORY_KEY_PREFIX = 'dms_mailing.categories.defaults'
216
-
217
- /** The i18n key of a shipped category nobody renamed; absent otherwise. */
218
- export function defaultCategoryLabelKey(
219
- category: TemplateCategory,
220
- ): string | undefined {
221
- return DEFAULT_CATEGORY_LABELS[category.id] === category.label
222
- ? `${DEFAULT_CATEGORY_KEY_PREFIX}.${category.id}`
223
- : undefined
224
- }
225
-
226
- /**
227
- * The categories as the interface shows them: a shipped one still under its
228
- * original English label reads in the interface's language, a label someone
229
- * wrote stays as written. Nothing stored changes.
230
- */
231
- export function localizeCategories(
232
- categories: TemplateCategory[],
233
- translate: OptionalTranslate,
234
- ): TemplateCategory[] {
235
- return categories.map((category) => {
236
- const key = defaultCategoryLabelKey(category)
237
- const label = key ? translate(key) : undefined
238
- return label ? { ...category, label } : category
239
- })
240
- }
@@ -72,3 +72,28 @@ export function formatRate(rate: number | null, locale: string): string {
72
72
  maximumFractionDigits: RATE_DECIMALS,
73
73
  }).format(rate / PERCENT)
74
74
  }
75
+
76
+ /** Whether the steps carry a comparison window: none does with it off. */
77
+ export const isCompared = (steps: FunnelStepReading[]): boolean =>
78
+ steps.some((step) => step.previousCount !== undefined)
79
+
80
+ const DELTA_ARROWS: Record<number, string> = { 1: '▲ ', 0: '', [-1]: '▼ ' }
81
+
82
+ /**
83
+ * A step's change as the card prints it ("▲ 12 %"); a dash when none can be
84
+ * stated, against a step the comparison window never reached.
85
+ */
86
+ export function formatDelta(delta: number | undefined, locale: string): string {
87
+ if (delta === undefined) return ABSENT_RATE
88
+ return `${DELTA_ARROWS[Math.sign(delta)] ?? ''}${formatRate(Math.abs(delta), locale)}`
89
+ }
90
+
91
+ type DeltaTone = 'success' | 'error' | 'neutral'
92
+
93
+ /** Growth is good down the funnel, bad for the leak out of it. */
94
+ export function deltaTone(step: FunnelStepReading): DeltaTone {
95
+ if (!step.delta) return 'neutral'
96
+ const isGrowing = step.delta > 0
97
+ const isLeak = step.id === FUNNEL_LEAK_STEP
98
+ return isGrowing !== isLeak ? 'success' : 'error'
99
+ }
@@ -1,7 +1,5 @@
1
1
  const MINUTE_MS = 60_000
2
2
  const DAY_MS = 86_400_000
3
- const PRESET_KEY_SEPARATOR = /-/g
4
- const KEY_SEPARATOR = '_'
5
3
 
6
4
  /** Hour and minute on a 24-hour clock, the way the cards stamp "updated 14:32". */
7
5
  export const CLOCK_FORMAT: Intl.DateTimeFormatOptions = {
@@ -10,11 +8,6 @@ export const CLOCK_FORMAT: Intl.DateTimeFormatOptions = {
10
8
  hourCycle: 'h23',
11
9
  }
12
10
 
13
- /** The i18n key of a preset's short segmented label ("24H", "7D"…). */
14
- export function presetShortLabelKey(preset: string): string {
15
- return `dms_mailing.period.short.${preset.replace(PRESET_KEY_SEPARATOR, KEY_SEPARATOR)}`
16
- }
17
-
18
11
  /** Whole minutes elapsed since `iso`; 0 for a missing or future date. */
19
12
  export function minutesSince(
20
13
  iso: string | null | undefined,
@@ -1,5 +1,7 @@
1
1
  import type { StarterSummary } from '../types/mailing'
2
- import type { OptionalTranslate } from './categories'
2
+
3
+ /** Answers the text of an i18n key, or nothing when the catalog lacks it. */
4
+ export type OptionalTranslate = (key: string) => string | undefined
3
5
 
4
6
  const STARTER_NAME_KEY_PREFIX = 'dms_mailing.starters.names'
5
7
 
@@ -93,11 +93,16 @@ export function splitTokens(text: string): TextPart[] {
93
93
  return splitRawSegments(text).map(({ kind, value }) => ({ kind, value }))
94
94
  }
95
95
 
96
+ /**
97
+ * A test value as the data holds it. Amounts and dates are written for the
98
+ * recipient's locale by the engine alone (`src/engine/formats.ts`): the
99
+ * preview and the sends show them formatted, the paper shows them raw.
100
+ */
96
101
  function formatValue(value: unknown): string {
97
102
  return value instanceof Date ? value.toISOString() : String(value)
98
103
  }
99
104
 
100
- /** Reads one token path in `data`, formatted like the engine prints it. */
105
+ /** Reads one token path in `data`, as the paper shows it. */
101
106
  export function resolveToken(
102
107
  path: string,
103
108
  data: Record<string, unknown>,
@@ -437,11 +437,13 @@
437
437
  "metrics": {
438
438
  "bounces": "Bounces",
439
439
  "click_rate": "Click rate",
440
+ "compared": "Compared period",
440
441
  "deliverability": "Deliverability",
441
442
  "domain_sends": "Domains with 5 or more deliveries",
442
443
  "domains": "Open rate by domain",
443
444
  "open_rate": "Open rate",
444
445
  "previous": "Previous period",
446
+ "previous_year": "Previous year",
445
447
  "ranking": "Best opened templates",
446
448
  "sends": "Sends",
447
449
  "unsubscribes": "Unsubscribes",
@@ -532,7 +534,8 @@
532
534
  "send_again": "Send again…",
533
535
  "copy_address": "Copy the address"
534
536
  },
535
- "position": "Send {index} of {total} shown",
537
+ "heading": "{recipientEmail} · {templateSlug}",
538
+ "position": "Send {index} of {total}",
536
539
  "replayed_by": "Sent again on {date}",
537
540
  "replay_of": "This send repeats an earlier one",
538
541
  "see_send": "See the send",
@@ -619,8 +622,9 @@
619
622
  "latency_detail": "API call to {provider}",
620
623
  "latency_detail_generic": "API call to the provider",
621
624
  "joined": "{left} · {right}",
622
- "delta_up": "▲ {delta}",
623
- "delta_down": "▼ {delta}",
625
+ "versus": "vs {previous}",
626
+ "delta_up_versus": "▲ {delta} vs {previous}",
627
+ "delta_down_versus": "▼ {delta} vs {previous}",
624
628
  "rate": "{rate}",
625
629
  "latency_ms": "{value} ms",
626
630
  "latency_s": "{value} s",
@@ -999,9 +1003,6 @@
999
1003
  "archived": "Archived",
1000
1004
  "attention": "Needs attention"
1001
1005
  },
1002
- "filters": {
1003
- "category": "Category"
1004
- },
1005
1006
  "footer": {
1006
1007
  "count": "{count} template | {count} templates",
1007
1008
  "hint": "Open a template to edit it · ↵ open · T test send"
@@ -1075,8 +1076,12 @@
1075
1076
  "description": "The overview dashboard, or the setup steps until the workspace can send."
1076
1077
  },
1077
1078
  "page_header": {
1078
- "name": "Provider and period",
1079
- "description": "The provider state and the period switch in the page header."
1079
+ "name": "Page toolbar",
1080
+ "description": "The provider state, the last update, live mode and the period switch at the top of the page."
1081
+ },
1082
+ "provider_pill": {
1083
+ "name": "Provider state",
1084
+ "description": "The pill in the page toolbar saying whether a provider carries the mail."
1080
1085
  },
1081
1086
  "settings_form": {
1082
1087
  "name": "Mailing settings",
@@ -1194,15 +1199,8 @@
1194
1199
  "current_sender": "Sender as set in the settings today",
1195
1200
  "meant_for": "The e-mail meant for {name}"
1196
1201
  },
1197
- "period": {
1198
- "short": {
1199
- "last_hour": "1H",
1200
- "last_24h": "24H",
1201
- "today": "1D",
1202
- "last_7_days": "7D",
1203
- "last_30_days": "30D",
1204
- "last_90_days": "90D"
1205
- }
1202
+ "live": {
1203
+ "new_sends": "{count} new send | {count} new sends"
1206
1204
  },
1207
1205
  "page_header": {
1208
1206
  "provider_connected": "{name} connected",
@@ -1266,11 +1264,13 @@
1266
1264
  "error_title": "The funnel could not be loaded",
1267
1265
  "retry": "Try again",
1268
1266
  "untracked": "Delivery is not measured: connect the provider’s webhook to follow the funnel.",
1269
- "no_loss": "No e-mail was lost between the steps."
1267
+ "no_loss": "No e-mail was lost between the steps.",
1268
+ "compared_count": "vs {value} in the compared period"
1270
1269
  },
1271
1270
  "common": {
1272
1271
  "cancel": "Cancel",
1273
- "on_date": "{date}"
1272
+ "on_date": "{date}",
1273
+ "preview_unavailable": "Preview unavailable"
1274
1274
  },
1275
1275
  "duplicate": {
1276
1276
  "title": "Duplicate template",
@@ -1389,15 +1389,6 @@
1389
1389
  "boolean": "Yes / no",
1390
1390
  "array": "List"
1391
1391
  },
1392
- "categories": {
1393
- "defaults": {
1394
- "orders": "Orders",
1395
- "account": "Account & access",
1396
- "billing": "Billing",
1397
- "marketing": "Marketing",
1398
- "forms": "Forms"
1399
- }
1400
- },
1401
1392
  "starters": {
1402
1393
  "names": {
1403
1394
  "order-confirmed": "Order confirmation",