@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.
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
@@ -1,4 +1,8 @@
1
1
  <script setup lang="ts">
2
+ // Imported rather than resolved by name: every module component is a lazy
3
+ // chunk, and the grid would first render its "new template" tile alone,
4
+ // then move it as the cards arrive.
5
+ import TemplateCard from './TemplateCard.vue'
2
6
  import { useTemplateCategories } from '../composables/useTemplateCategories'
3
7
  import { useEventListener } from '@vueuse/core'
4
8
  import {
@@ -50,7 +54,8 @@ const props = defineProps<TemplateGalleryProps>()
50
54
  const { t } = useI18n()
51
55
  const overlays = useTemplateDrawer()
52
56
 
53
- const { categories } = useTemplateCategories()
57
+ const { categories, isAnswered: areCategoriesAnswered } =
58
+ useTemplateCategories()
54
59
  const overview = useTemplatesOverview()
55
60
  const total = useTemplateTotal()
56
61
 
@@ -121,8 +126,20 @@ const search = computed(() => searchTermOf(props.context.query))
121
126
  const narrowed = computed(() => isNarrowed(props.context.query))
122
127
  const hasNoItems = computed(() => !props.context.items.length)
123
128
 
129
+ /**
130
+ * The cards draw their category group and their figures from reads of their
131
+ * own: until both answered (a client navigation reads them in the browser),
132
+ * the cards would come under "Uncategorised", then move to their group.
133
+ */
134
+ const areCardsReady = computed(
135
+ () =>
136
+ (!isGrouped.value || areCategoriesAnswered.value) &&
137
+ Boolean(overview.data.value || overview.error.value),
138
+ )
139
+
124
140
  const STATE_RULES: StateRule[] = [
125
141
  ['loading', () => props.context.loading && hasNoItems.value],
142
+ ['loading', () => !hasNoItems.value && !areCardsReady.value],
126
143
  [
127
144
  'loading',
128
145
  () => hasNoItems.value && !narrowed.value && templateCount.value === null,
@@ -218,24 +235,47 @@ useEventListener(
218
235
 
219
236
  <template>
220
237
  <div class="flex flex-col gap-6 p-4">
221
- <div
238
+ <!-- Laid out as a group of cards: the heading, the thumbnail and the
239
+ card's three lines at their own heights (a zero-width space keeps a
240
+ text line's height), so the cards replace it without moving. -->
241
+ <section
222
242
  v-if="state === 'loading'"
223
- class="grid grid-cols-[repeat(auto-fill,minmax(272px,1fr))] gap-4"
243
+ class="flex flex-col gap-3"
224
244
  aria-busy="true"
245
+ data-gallery-skeleton
225
246
  >
226
- <DmsCard
227
- v-for="index in SKELETON_CARDS"
228
- :key="index"
229
- :padded="false"
230
- class="overflow-hidden"
231
- >
232
- <USkeleton class="h-[172px] w-full rounded-none" />
233
- <div class="flex flex-col gap-2 p-3.5">
234
- <USkeleton class="h-3 w-3/5" />
235
- <USkeleton class="h-2.5 w-2/5" />
236
- </div>
237
- </DmsCard>
238
- </div>
247
+ <div v-if="isGrouped" class="flex items-center gap-2 text-[13px]">
248
+ <USkeleton class="size-4" />
249
+ <USkeleton class="h-3 w-24" />
250
+ &#8203;
251
+ </div>
252
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(272px,1fr))] gap-4">
253
+ <DmsCard
254
+ v-for="index in SKELETON_CARDS"
255
+ :key="index"
256
+ :padded="false"
257
+ class="flex flex-col overflow-hidden"
258
+ >
259
+ <USkeleton class="h-[172px] w-full rounded-none" />
260
+ <div
261
+ class="border-default flex flex-1 flex-col gap-1.5 border-t px-3.5 pb-3 pt-3"
262
+ >
263
+ <div class="flex h-6 items-center">
264
+ <USkeleton class="h-3.5 w-3/5" />
265
+ </div>
266
+ <div class="flex h-[18px] items-center">
267
+ <USkeleton class="h-2.5 w-2/5" />
268
+ </div>
269
+ <div
270
+ class="border-default mt-1 flex items-center gap-3 border-t pt-2 font-mono text-[11.5px]"
271
+ >
272
+ <USkeleton class="h-2.5 w-1/2" />
273
+ &#8203;
274
+ </div>
275
+ </div>
276
+ </DmsCard>
277
+ </div>
278
+ </section>
239
279
 
240
280
  <MailingStarterGallery
241
281
  v-else-if="state === 'first_run'"
@@ -308,7 +348,7 @@ useEventListener(
308
348
  <span class="bg-(--ui-border) ml-2 h-px flex-1" />
309
349
  </div>
310
350
  <div class="grid grid-cols-[repeat(auto-fill,minmax(272px,1fr))] gap-4">
311
- <MailingTemplateCard
351
+ <TemplateCard
312
352
  v-for="template in group.items"
313
353
  :key="template._id"
314
354
  :template="template"
@@ -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>,