@antelopejs/dms-mailing 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/data/templates-table.d.ts +4 -1
- package/dist/data/templates-table.js +55 -13
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +5 -5
- package/dist/db/models/templates.model.d.ts +10 -1
- package/dist/db/models/templates.model.js +27 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/tables/templates.table.d.ts +7 -1
- package/dist/db/tables/templates.table.js +3 -1
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/formats.d.ts +13 -0
- package/dist/engine/formats.js +85 -0
- package/dist/engine/formats.js.map +1 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +14 -7
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js +3 -5
- package/dist/engine/tokens.js.map +1 -1
- package/dist/frontend-module.d.ts +2 -0
- package/dist/frontend-module.js +24 -0
- package/dist/frontend-module.js.map +1 -0
- package/dist/hooks/tenant-export.js +4 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +1 -0
- package/dist/implementations/dms-mailing/index.js +3 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +2 -19
- package/dist/index.js.map +1 -1
- package/dist/pages/overview.d.ts +1 -1
- package/dist/pages/overview.js +11 -13
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/sends.d.ts +1 -1
- package/dist/pages/sends.js +16 -8
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -8
- package/dist/pages/templates.js.map +1 -1
- package/dist/pages/toolbar.d.ts +12 -0
- package/dist/pages/toolbar.js +41 -0
- package/dist/pages/toolbar.js.map +1 -0
- package/dist/realtime.d.ts +2 -2
- package/dist/realtime.js +8 -8
- package/dist/realtime.js.map +1 -1
- package/dist/routes/metrics.d.ts +9 -2
- package/dist/routes/metrics.js +90 -35
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.d.ts +1 -0
- package/dist/routes/period.js +19 -0
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +6 -3
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -7
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.d.ts +8 -4
- package/dist/routes/templates.js +40 -15
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/categories.d.ts +2 -1
- package/dist/services/categories.js +9 -0
- package/dist/services/categories.js.map +1 -1
- package/dist/services/declarations.js +2 -2
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +1 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +7 -0
- package/dist/services/localized-text.js +28 -0
- package/dist/services/localized-text.js.map +1 -0
- package/dist/services/send-stat-items.js +34 -12
- package/dist/services/send-stat-items.js.map +1 -1
- package/dist/services/send-stats.d.ts +7 -0
- package/dist/services/send-stats.js +17 -5
- package/dist/services/send-stats.js.map +1 -1
- package/dist/services/send.d.ts +2 -0
- package/dist/services/send.js +11 -12
- package/dist/services/send.js.map +1 -1
- package/dist/services/sender-declaration.d.ts +2 -0
- package/dist/services/sender-declaration.js +18 -0
- package/dist/services/sender-declaration.js.map +1 -0
- package/dist/services/starters.d.ts +2 -2
- package/dist/services/starters.js +1 -10
- package/dist/services/starters.js.map +1 -1
- package/dist/services/templates.d.ts +11 -5
- package/dist/services/templates.js +24 -6
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -1
- package/dist/services/webhook-status.js +6 -5
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/settings.d.ts +6 -0
- package/dist/types/settings.js +25 -5
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +25 -7
- package/dist/validation/declarations.schema.js +16 -3
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/settings.schema.d.ts +25 -25
- package/dist/validation/settings.schema.js +30 -6
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +13 -0
- package/dist/validation/templates.schema.js +6 -1
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +15 -13
- package/frontend-vue/app/components/FunnelCard.vue +46 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
- package/frontend-vue/app/components/OverviewGate.vue +0 -8
- package/frontend-vue/app/components/ProviderPill.vue +50 -0
- package/frontend-vue/app/components/SendDrawer.vue +206 -183
- package/frontend-vue/app/components/StarterGallery.vue +1 -1
- package/frontend-vue/app/components/TemplateCard.vue +4 -1
- package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
- package/frontend-vue/app/components/TemplateGallery.vue +57 -17
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
- package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
- package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
- package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
- package/frontend-vue/app/composables/useMailingApi.ts +3 -3
- package/frontend-vue/app/composables/useMailingReads.ts +2 -2
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
- package/frontend-vue/app/types/mailing.ts +18 -0
- package/frontend-vue/app/utils/categories.ts +58 -49
- package/frontend-vue/app/utils/funnel.ts +25 -0
- package/frontend-vue/app/utils/period.ts +0 -7
- package/frontend-vue/app/utils/starters.ts +3 -1
- package/frontend-vue/app/utils/tokens.ts +6 -1
- package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/categories-input.test.ts +4 -10
- package/frontend-vue/tests/categories.test.ts +55 -17
- package/frontend-vue/tests/conditions.test.ts +1 -4
- package/frontend-vue/tests/funnel-card.test.ts +27 -0
- package/frontend-vue/tests/funnel.test.ts +25 -0
- package/frontend-vue/tests/localized-defaults.test.ts +0 -29
- package/frontend-vue/tests/new-template-modal.test.ts +1 -1
- package/frontend-vue/tests/overview-gate.test.ts +1 -24
- package/frontend-vue/tests/page-reads.test.ts +9 -25
- package/frontend-vue/tests/period.test.ts +0 -12
- package/frontend-vue/tests/replay-toast.test.ts +2 -1
- package/frontend-vue/tests/send-drawer.test.ts +123 -0
- package/frontend-vue/tests/template-drawer.test.ts +1 -1
- package/frontend-vue/tests/template-gallery.test.ts +90 -3
- package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
- package/package.json +6 -5
- package/skills/dms-mailing/SKILL.md +17 -4
- package/src/constants.ts +2 -0
- package/src/data/sends-table.ts +1 -1
- package/src/data/templates-table.ts +123 -19
- package/src/db/models/templates.model.ts +61 -3
- package/src/db/tables/templates.table.ts +10 -1
- package/src/engine/formats.ts +124 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/resolve.ts +18 -7
- package/src/engine/tokens.ts +7 -5
- package/src/frontend-module.ts +36 -0
- package/src/hooks/tenant-export.ts +8 -1
- package/src/implementations/dms-mailing/index.ts +2 -0
- package/src/index.ts +2 -23
- package/src/pages/overview.ts +20 -16
- package/src/pages/sends.ts +27 -11
- package/src/pages/templates.ts +4 -8
- package/src/pages/toolbar.ts +77 -0
- package/src/realtime.ts +17 -8
- package/src/routes/metrics.ts +158 -45
- package/src/routes/period.ts +32 -0
- package/src/routes/sends.ts +13 -4
- package/src/routes/settings.ts +11 -8
- package/src/routes/templates.ts +72 -26
- package/src/services/categories.ts +14 -1
- package/src/services/declarations.ts +4 -3
- package/src/services/events.ts +1 -1
- package/src/services/localized-text.ts +53 -0
- package/src/services/send-stat-items.ts +60 -13
- package/src/services/send-stats.ts +37 -5
- package/src/services/send.ts +19 -20
- package/src/services/sender-declaration.ts +27 -0
- package/src/services/starters.ts +3 -15
- package/src/services/templates.ts +50 -19
- package/src/services/webhook-status.ts +3 -3
- package/src/types/settings.ts +35 -5
- package/src/validation/declarations.schema.ts +29 -3
- package/src/validation/settings.schema.ts +56 -14
- package/src/validation/templates.schema.ts +10 -0
- package/frontend-vue/app/components/PageHeader.vue +0 -94
- package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
|
@@ -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 } =
|
|
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
|
-
|
|
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="
|
|
243
|
+
class="flex flex-col gap-3"
|
|
224
244
|
aria-busy="true"
|
|
245
|
+
data-gallery-skeleton
|
|
225
246
|
>
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
>
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
​
|
|
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
|
+
​
|
|
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
|
-
<
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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-
|
|
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
|
-
...
|
|
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
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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('
|
|
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<
|
|
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
|
|
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,
|
|
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,
|
|
6
|
-
* read the components showing them share (a table's category
|
|
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
|
-
|
|
13
|
+
error.value ? [] : (data.value?.categories ?? []),
|
|
14
14
|
)
|
|
15
|
-
|
|
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 {
|
|
1
|
+
import type {
|
|
2
|
+
LocalizedText,
|
|
3
|
+
StoredCategory,
|
|
4
|
+
TemplateCategory,
|
|
5
|
+
} from '../types/mailing'
|
|
2
6
|
|
|
3
7
|
const ID_SEPARATOR = '-'
|
|
4
8
|
|
|
5
|
-
|
|
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(
|
|
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
|
|
51
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
|
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[]):
|
|
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:
|
|
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
|
-
|
|
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`,
|
|
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>,
|