@antelopejs/dms-mailing 0.4.1 → 0.5.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 (43) hide show
  1. package/README.md +1 -1
  2. package/frontend-vue/app/components/CategoriesInput.vue +7 -14
  3. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  4. package/frontend-vue/app/components/Editor.vue +3 -3
  5. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  6. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  7. package/frontend-vue/app/components/PageHeader.vue +8 -12
  8. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  9. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  10. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  11. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  12. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  13. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  14. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  15. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  16. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  17. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  18. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  19. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  20. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  21. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  22. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  23. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  24. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  25. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  26. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  27. package/frontend-vue/app/utils/gallery.ts +19 -7
  28. package/frontend-vue/app/utils/period.ts +0 -5
  29. package/frontend-vue/package.json +1 -1
  30. package/frontend-vue/tests/categories-input.test.ts +43 -13
  31. package/frontend-vue/tests/editor-session.test.ts +4 -0
  32. package/frontend-vue/tests/gallery.test.ts +16 -0
  33. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  34. package/frontend-vue/tests/new-template-modal.test.ts +17 -5
  35. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  36. package/frontend-vue/tests/overview-gate.test.ts +59 -21
  37. package/frontend-vue/tests/page-reads.test.ts +157 -0
  38. package/frontend-vue/tests/period.test.ts +0 -9
  39. package/frontend-vue/tests/retention-input.test.ts +30 -9
  40. package/frontend-vue/tests/send-modals.test.ts +8 -1
  41. package/frontend-vue/tests/template-drawer.test.ts +30 -14
  42. package/frontend-vue/tests/template-gallery.test.ts +45 -24
  43. package/package.json +2 -2
package/README.md CHANGED
@@ -12,7 +12,7 @@ dashboard and settings. It owns the data (tenant-scoped Mongo tables), renders
12
12
  e-mails through the DMS html-render chain and sends them through
13
13
  `@antelopejs/interface-email`.
14
14
 
15
- The module targets `@antelopejs/dms` 0.7 (0.7.21 or later) and `@antelopejs/dms-frontend` 0.5 (0.5.5 or later).
15
+ The module targets `@antelopejs/dms` 0.9, `@antelopejs/interface-dms` 0.7 and `@antelopejs/dms-frontend` 0.6. Every read goes through the DMS's `useApiData`: what a page renders is read by the server render and handed to the browser; what a drawer, a dialog, a preview frame or the editor shows is read when it opens or asks (`immediate: false`, `useMailingReads`).
16
16
  Like every DMS module, its pages live in the module's own sidebar (Mailing:
17
17
  Overview, Templates, Sends; Configure: Settings, Suppression list) and are
18
18
  reserved to the platform owner. The design decisions behind the v2 screens are recorded in
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import draggable from 'vuedraggable'
3
3
  import { useLocalizedDefaults } from '../composables/useLocalizedDefaults'
4
- import { useMailingApi } from '../composables/useMailingApi'
4
+ import { useCategoryUsage } from '../composables/useMailingReads'
5
5
  import { isMarketingCategory } from '../utils/system-variables'
6
6
  import {
7
7
  CATEGORY_ICONS,
@@ -53,7 +53,6 @@ const MOVE_FIELD_COMPONENT = 'DmsSelect'
53
53
  const LABEL_PART = 'label'
54
54
  const ROW_FIELD_UI = { error: 'mt-1 ps-9 text-[12px]' }
55
55
 
56
- const api = useMailingApi()
57
56
  const { t } = useI18n()
58
57
  const localized = useLocalizedDefaults()
59
58
  const { confirm } = useConfirm()
@@ -65,7 +64,11 @@ const root = ref<HTMLElement | null>(null)
65
64
  const source = computed(() => props.modelValue ?? props.initialValue ?? [])
66
65
  const rows = ref<EditableCategory[]>(toRows(source.value))
67
66
  const removed = ref<CategoryEntry[]>(movesOf(source.value))
68
- const usage = ref<Record<string, number>>({})
67
+ const usageRead = useCategoryUsage()
68
+ /** Templates per category; none known while the read is missing or failed. */
69
+ const usage = computed<Record<string, number>>(() =>
70
+ usageRead.error.value ? {} : (usageRead.data.value?.counts ?? {}),
71
+ )
69
72
  const pickerOpen = ref<string | null>(null)
70
73
  let lastEmitted = serializeCategories(currentEntries())
71
74
 
@@ -216,14 +219,6 @@ async function moveBy(index: number, offset: number): Promise<void> {
216
219
  handles?.[moved.index]?.focus()
217
220
  }
218
221
 
219
- async function loadUsage(): Promise<void> {
220
- try {
221
- usage.value = (await api.categoryUsage()).counts
222
- } catch {
223
- usage.value = {}
224
- }
225
- }
226
-
227
222
  watch(source, (value) => {
228
223
  if (serializeCategories(value) === lastEmitted) return
229
224
  rows.value = toRows(value)
@@ -238,11 +233,9 @@ watch(
238
233
  rows.value
239
234
  .filter((row) => row.isNew && saved.has(row.id))
240
235
  .forEach((row) => (row.isNew = false))
241
- void loadUsage()
236
+ void usageRead.refresh()
242
237
  },
243
238
  )
244
-
245
- onMounted(loadUsage)
246
239
  </script>
247
240
 
248
241
  <template>
@@ -2,6 +2,10 @@
2
2
  import FormFieldRow from '../build/components/FormFieldRow.vue'
3
3
  import FormRows from '../build/components/FormRows.vue'
4
4
  import { useMailingApi } from '../composables/useMailingApi'
5
+ import {
6
+ useTemplateContentRead,
7
+ useTemplateListRead,
8
+ } from '../composables/useMailingReads'
5
9
  import {
6
10
  HTTP_CONFLICT,
7
11
  apiErrorMessage,
@@ -32,6 +36,8 @@ const { t } = useI18n()
32
36
  const { processApiMessage } = useTranslation()
33
37
 
34
38
  const templateId = computed(() => props.rowData?._id ?? props.templateId ?? '')
39
+ const readTemplates = useTemplateListRead()
40
+ const readContent = useTemplateContentRead(templateId)
35
41
  const source = ref<TemplateRow | null>(props.rowData ?? null)
36
42
  const takenSlugs = ref(new Set<string>())
37
43
  const name = ref('')
@@ -49,13 +55,13 @@ function prefill(template: TemplateRow): void {
49
55
  }
50
56
 
51
57
  onMounted(async () => {
52
- const list = await api.listTemplates().catch(() => ({ results: [] }))
58
+ const list = await readTemplates().catch(() => ({ results: [] }))
53
59
  takenSlugs.value = new Set(list.results.map((row: TemplateRow) => row.slug))
54
60
  source.value ??=
55
61
  list.results.find((row: TemplateRow) => row._id === templateId.value) ??
56
62
  null
57
63
  if (!source.value && templateId.value) {
58
- const detail = await api.templateContent(templateId.value).catch(() => null)
64
+ const detail = await readContent().catch(() => null)
59
65
  source.value = detail?.template ?? null
60
66
  }
61
67
  if (source.value) prefill(source.value)
@@ -174,7 +180,7 @@ async function submit(): Promise<void> {
174
180
  :ui="{ base: 'font-mono' }"
175
181
  :disabled="loading"
176
182
  :trailing-icon="slugIssue ? undefined : 'i-ph-check-circle'"
177
- @update:model-value="(value) => onSlugInput(String(value))"
183
+ @update:model-value="(value: unknown) => onSlugInput(String(value))"
178
184
  />
179
185
  </template>
180
186
  </FormFieldRow>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref } from 'vue'
3
3
  import type { TemplateContentResponse } from '../types/mailing'
4
- import { useMailingApi } from '../composables/useMailingApi'
4
+ import { useTemplateContentRead } from '../composables/useMailingReads'
5
5
  import { useRecordLabel } from '../composables/useEditorContext'
6
6
  import { templateIdFrom } from '../utils/editor-route'
7
7
  import { TEMPLATES_PATH } from '../utils/pages'
@@ -15,10 +15,10 @@ const props = defineProps<Props>()
15
15
 
16
16
  const { t } = useI18n()
17
17
  const route = useDmsRoute()
18
- const api = useMailingApi()
19
18
  const setRecordLabel = useRecordLabel()
20
19
 
21
20
  const templateId = computed(() => templateIdFrom(props.routeParams, route.path))
21
+ const readContent = useTemplateContentRead(templateId)
22
22
  const locale = ref<string | null>(
23
23
  route.query.locale ? String(route.query.locale) : null,
24
24
  )
@@ -31,7 +31,7 @@ const generation = ref(0)
31
31
  async function load(): Promise<void> {
32
32
  failed.value = false
33
33
  try {
34
- loaded.value = await api.templateContent(templateId.value)
34
+ loaded.value = await readContent()
35
35
  setRecordLabel(loaded.value.template.name)
36
36
  generation.value += 1
37
37
  } catch {
@@ -5,6 +5,10 @@ import FormRows from '../build/components/FormRows.vue'
5
5
  import NewTemplateSourceTile from './NewTemplateSourceTile.vue'
6
6
  import { useLocalizedDefaults } from '../composables/useLocalizedDefaults'
7
7
  import { useMailingApi } from '../composables/useMailingApi'
8
+ import {
9
+ useStartersRead,
10
+ useTemplateListRead,
11
+ } from '../composables/useMailingReads'
8
12
  import {
9
13
  HTTP_CONFLICT,
10
14
  apiErrorMessage,
@@ -85,6 +89,8 @@ const source = ref<SourceChoice>(
85
89
  const isSubmitted = ref(false)
86
90
  const saving = ref(false)
87
91
  const templates = ref<TemplateRow[]>([])
92
+ const readTemplates = useTemplateListRead()
93
+ const readStarters = useStartersRead()
88
94
  const localized = useLocalizedDefaults()
89
95
  const storedStarters = ref<StarterSummary[]>([])
90
96
  const starters = computed(() => localized.starters(storedStarters.value))
@@ -94,8 +100,8 @@ const templateSearch = ref('')
94
100
 
95
101
  onMounted(async () => {
96
102
  const [templateList, starterList] = await Promise.all([
97
- api.listTemplates().catch(() => ({ results: [] as TemplateRow[] })),
98
- api.starters().catch(() => ({ starters: [] as StarterSummary[] })),
103
+ readTemplates().catch(() => ({ results: [] as TemplateRow[] })),
104
+ readStarters().catch(() => ({ starters: [] as StarterSummary[] })),
99
105
  ])
100
106
  templates.value = templateList.results
101
107
  storedStarters.value = starterList.starters
@@ -324,7 +330,7 @@ async function submit(): Promise<void> {
324
330
  :ui="{ base: 'font-mono' }"
325
331
  :trailing-icon="slugIssue ? undefined : 'i-ph-check-circle'"
326
332
  :aria-invalid="Boolean(slugError)"
327
- @update:model-value="(value) => onSlugInput(String(value))"
333
+ @update:model-value="(value: unknown) => onSlugInput(String(value))"
328
334
  />
329
335
  <UBadge
330
336
  color="neutral"
@@ -1,11 +1,9 @@
1
1
  <script setup lang="ts">
2
- import { useMailingApi } from '../composables/useMailingApi'
3
- import { useSetupHidesPeriod } from '../composables/useOverviewSetup'
2
+ import { useSetupFacts } from '../composables/useMailingReads'
4
3
  import {
5
4
  isSetupComplete,
6
5
  needsSetup,
7
6
  setupSteps,
8
- type SetupFacts,
9
7
  type SetupStep,
10
8
  type SetupStepState,
11
9
  } from '../utils/overview-setup'
@@ -13,10 +11,10 @@ import type { Component } from 'vue'
13
11
  import type { MailingComponentProps } from '../types/component'
14
12
 
15
13
  defineProps<MailingComponentProps>()
14
+ defineOptions({ inheritAttrs: false })
16
15
 
17
16
  type GateState = 'loading' | 'error' | 'ready'
18
17
 
19
- const OVERVIEW_PERIOD_SCOPE = 'mailing-overview'
20
18
  const NEW_TEMPLATE_MODAL = 'MailingNewTemplateModal'
21
19
  const NEW_TEMPLATE_MODAL_SIZE = 'lg'
22
20
  const KEY_PREFIX = 'dms_mailing.setup.'
@@ -34,15 +32,25 @@ const STEP_BUTTON_VARIANTS: Record<SetupStepState, 'solid' | 'outline'> = {
34
32
  todo: 'outline',
35
33
  }
36
34
 
37
- const api = useMailingApi()
38
35
  const { t } = useI18n()
39
36
  const { open: openModal } = useModal()
40
- const hidesPeriod = useSetupHidesPeriod(OVERVIEW_PERIOD_SCOPE)
41
37
 
42
- const facts = ref<SetupFacts | null>(null)
43
- const state = ref<GateState>('loading')
38
+ const { data: facts, error, status, refresh } = useSetupFacts()
39
+ /**
40
+ * The header's period selector sets up once the dashboard has: a server
41
+ * render, which renders the dashboard once the facts are in, would otherwise
42
+ * set the selector up first and render the cards with a period the browser's
43
+ * cards do not have yet as they hydrate.
44
+ */
45
+ const DashboardRendered = holdPageHeaderActions()
44
46
  const isResumed = ref(false)
45
47
 
48
+ /** A failure shows once its retry is over; the facts read last stay meanwhile. */
49
+ const state = computed<GateState>(() => {
50
+ if (facts.value) return 'ready'
51
+ return error.value && status.value !== 'pending' ? 'error' : 'loading'
52
+ })
53
+
46
54
  const steps = computed<SetupStep[]>(() =>
47
55
  facts.value ? setupSteps(facts.value) : [],
48
56
  )
@@ -63,40 +71,22 @@ const errorActions = computed(() => [
63
71
  {
64
72
  label: key('retry'),
65
73
  icon: 'i-ph-arrows-clockwise',
66
- onClick: () => void load(),
74
+ onClick: () => void refresh(),
67
75
  },
68
76
  ])
69
77
 
70
78
  const stepDescription = (step: SetupStep): string =>
71
79
  key(`steps.${step.id}.${step.state === 'done' ? 'done' : 'description'}`)
72
80
 
73
- async function load(): Promise<void> {
74
- state.value = 'loading'
75
- try {
76
- facts.value = await api.setupFacts()
77
- state.value = 'ready'
78
- } catch {
79
- state.value = 'error'
80
- }
81
- }
82
-
83
81
  function createTemplate(): void {
84
82
  const modal = openModal({
85
83
  title: t('dms_mailing.setup.create_template'),
86
84
  component: resolveComponent(NEW_TEMPLATE_MODAL) as Component,
87
85
  size: NEW_TEMPLATE_MODAL_SIZE,
88
86
  })
89
- void modal.result.then(load, load)
87
+ const reread = () => refresh()
88
+ void modal.result.then(reread, reread)
90
89
  }
91
-
92
- watch(isBlocking, (blocking) => {
93
- hidesPeriod.value = blocking
94
- })
95
-
96
- onMounted(load)
97
- onBeforeUnmount(() => {
98
- hidesPeriod.value = false
99
- })
100
90
  </script>
101
91
 
102
92
  <template>
@@ -217,4 +207,5 @@ onBeforeUnmount(() => {
217
207
 
218
208
  <slot v-if="showsDashboard" />
219
209
  </div>
210
+ <DashboardRendered />
220
211
  </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useMailingApi } from '../composables/useMailingApi'
2
+ import { useProviderInfo } from '../composables/useMailingReads'
3
3
  import { useSetupHidesPeriod } from '../composables/useOverviewSetup'
4
4
  import { presetShortLabelKey } from '../utils/period'
5
5
  import type { Component } from 'vue'
@@ -26,13 +26,18 @@ const props = withDefaults(defineProps<PageHeaderProps>(), {
26
26
  showProvider: true,
27
27
  })
28
28
 
29
- const api = useMailingApi()
30
29
  const { t } = useI18n()
31
30
 
32
31
  const StatusPill = resolveComponent('DmsStatusPill') as Component
33
32
  const PeriodSelector = resolveComponent('DmsPeriodSelector') as Component
34
33
 
35
- const provider = ref<ProviderInfo | null>(null)
34
+ /** Shown as "no provider" when the read fails. */
35
+ const NO_PROVIDER: ProviderInfo = { name: '', connected: false, features: null }
36
+
37
+ const providerRead = useProviderInfo(() => props.showProvider)
38
+ const provider = computed<ProviderInfo | null>(() =>
39
+ providerRead.error.value ? NO_PROVIDER : providerRead.data.value,
40
+ )
36
41
  const hidesPeriod = useSetupHidesPeriod(props.periodScope)
37
42
 
38
43
  const presetLabels = computed(() =>
@@ -82,15 +87,6 @@ function renderPeriod() {
82
87
  }
83
88
 
84
89
  usePageHeaderActions(() => [renderProvider(), renderPeriod()])
85
-
86
- onMounted(async () => {
87
- if (!props.showProvider) return
88
- try {
89
- provider.value = await api.provider()
90
- } catch {
91
- provider.value = { name: '', connected: false, features: null }
92
- }
93
- })
94
90
  </script>
95
91
 
96
92
  <template>
@@ -4,6 +4,7 @@ import { onBeforeUnmount } from 'vue'
4
4
  import FormFieldRow from '../build/components/FormFieldRow.vue'
5
5
  import FormRows from '../build/components/FormRows.vue'
6
6
  import { useMailingApi } from '../composables/useMailingApi'
7
+ import { useTemplateContentRead } from '../composables/useMailingReads'
7
8
  import type { SendResult } from '../composables/useMailingApi'
8
9
  import { useTemplateDrawer } from '../composables/useTemplateDrawer'
9
10
  import { apiErrorMessage } from '../utils/api-error'
@@ -64,6 +65,7 @@ const { uniqueLocales } = useUniqueLocales()
64
65
  const overlays = useTemplateDrawer()
65
66
 
66
67
  const templateId = computed(() => props.rowData?._id ?? props.templateId ?? '')
68
+ const readContent = useTemplateContentRead(templateId)
67
69
  const detail = ref<TemplateContentResponse | null>(null)
68
70
  const loading = ref(true)
69
71
  const recipients = ref<string[]>([])
@@ -104,7 +106,7 @@ const missingRequired = computed(() =>
104
106
  onMounted(async () => {
105
107
  if (!templateId.value) return
106
108
  try {
107
- detail.value = await api.templateContent(templateId.value)
109
+ detail.value = await readContent()
108
110
  values.value = initialSendValues(fields.value, detail.value.testData)
109
111
  locale.value = detail.value.fallbackLocale
110
112
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useMailingApi } from '../composables/useMailingApi'
2
+ import { useSendHtmlRead, useSendRead } from '../composables/useMailingReads'
3
3
  import type { SendHtmlResponse, SendRow } from '../types/mailing'
4
4
 
5
5
  type RenderingView = 'desktop' | 'mobile' | 'html'
@@ -44,7 +44,6 @@ const FRAME_WIDTHS: Record<RenderingView, string> = {
44
44
  html: '100%',
45
45
  }
46
46
 
47
- const api = useMailingApi()
48
47
  const { t, locale } = useI18n()
49
48
 
50
49
  const view = ref<RenderingView>('desktop')
@@ -54,6 +53,8 @@ const loading = ref(false)
54
53
  const failed = ref(false)
55
54
 
56
55
  const sendIdValue = computed(() => props.rowData?._id ?? props.sendId ?? '')
56
+ const readSend = useSendRead(sendIdValue)
57
+ const readHtml = useSendHtmlRead(sendIdValue)
57
58
  const key = (path: string, params: Record<string, unknown> = {}): string =>
58
59
  t(`${KEY_PREFIX}${path}`, params)
59
60
 
@@ -85,7 +86,7 @@ const meta = computed(() => {
85
86
 
86
87
  async function loadSend(): Promise<void> {
87
88
  if (send.value) return
88
- const detail = await api.send(sendIdValue.value)
89
+ const detail = await readSend()
89
90
  send.value = detail.send
90
91
  }
91
92
 
@@ -94,10 +95,7 @@ async function load(): Promise<void> {
94
95
  loading.value = true
95
96
  failed.value = false
96
97
  try {
97
- const [html] = await Promise.all([
98
- api.sendHtml(sendIdValue.value),
99
- loadSend(),
100
- ])
98
+ const [html] = await Promise.all([readHtml(), loadSend()])
101
99
  rendering.value = html
102
100
  } catch (error) {
103
101
  failed.value = true
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
- import { useDebounceFn } from '@vueuse/core'
3
- import { useMailingApi } from '../composables/useMailingApi'
2
+ import { refDebounced } from '@vueuse/core'
3
+ import { useRetentionPreview } from '../composables/useMailingReads'
4
4
  import {
5
5
  clampRetention,
6
- formatClock,
6
+ CLOCK_FORMAT,
7
7
  isWithinADay,
8
8
  MAX_RETENTION_DAYS,
9
9
  MIN_RETENTION_DAYS,
@@ -35,8 +35,8 @@ const DATE_FORMAT: Intl.DateTimeFormatOptions = {
35
35
  day: 'numeric',
36
36
  }
37
37
 
38
- const api = useMailingApi()
39
38
  const { t, locale } = useI18n()
39
+ const { dateTimeFormat } = useRegionalFormat()
40
40
  const { confirm } = useConfirm()
41
41
  const form = useEnclosingForm()
42
42
 
@@ -56,7 +56,13 @@ const isCustom = computed(
56
56
  isCustomPicked.value ||
57
57
  (days.value !== null && retentionChoice(days.value) === RETENTION_CUSTOM),
58
58
  )
59
- const preview = ref<RetentionPreview | null>(null)
59
+ /** The days the preview reads, once the choice holds still. */
60
+ const previewDays = refDebounced(days, PREVIEW_DEBOUNCE_MS)
61
+ const previewRead = useRetentionPreview(previewDays)
62
+ /** A failed read shows no preview. */
63
+ const preview = computed<RetentionPreview | null>(() =>
64
+ previewRead.error.value ? null : previewRead.data.value,
65
+ )
60
66
 
61
67
  const key = (
62
68
  path: string,
@@ -99,8 +105,8 @@ function previewParams(value: RetentionPreview): Record<string, unknown> {
99
105
  return {
100
106
  when: isWithinADay(value.nextRunAt, new Date())
101
107
  ? key('tonight')
102
- : new Date(value.nextRunAt).toLocaleDateString(locale.value, DATE_FORMAT),
103
- time: formatClock(value.nextRunAt, locale.value),
108
+ : dateTimeFormat(DATE_FORMAT).format(new Date(value.nextRunAt)),
109
+ time: dateTimeFormat(CLOCK_FORMAT).format(new Date(value.nextRunAt)),
104
110
  count: new Intl.NumberFormat(locale.value).format(value.count),
105
111
  duration: key('days', { count: value.days }, value.days),
106
112
  }
@@ -148,22 +154,6 @@ function guardSubmit(): boolean {
148
154
  return false
149
155
  }
150
156
 
151
- const loadPreview = useDebounceFn(async (value: number) => {
152
- try {
153
- preview.value = await api.retentionPreview(value)
154
- } catch {
155
- preview.value = null
156
- }
157
- }, PREVIEW_DEBOUNCE_MS)
158
-
159
- watch(
160
- days,
161
- (value) => {
162
- if (value) void loadPreview(value)
163
- },
164
- { immediate: true },
165
- )
166
-
167
157
  watch(
168
158
  () => props.modelValue,
169
159
  (value) => {
@@ -2,6 +2,7 @@
2
2
  import { TEMPLATES_PATH } from '../utils/pages'
3
3
  import { useClipboard } from '@vueuse/core'
4
4
  import { useMailingApi } from '../composables/useMailingApi'
5
+ import { useProviderRead, useSendRead } from '../composables/useMailingReads'
5
6
  import { useReplayToast } from '../composables/useReplayToast'
6
7
  import {
7
8
  describeProblem,
@@ -90,6 +91,8 @@ const { copy } = useClipboard()
90
91
  const KeyValueList = resolveComponent('DmsKeyValueList') as Component
91
92
 
92
93
  const sendId = computed(() => props.rowData?._id ?? props.sendId ?? '')
94
+ const readSend = useSendRead(sendId)
95
+ const readProvider = useProviderRead()
93
96
  const detail = ref<SendDetailResponse | null>(null)
94
97
  const tracking = ref<TimelineTracking | null>(null)
95
98
  const loading = ref(false)
@@ -231,7 +234,7 @@ async function load(): Promise<void> {
231
234
  if (!sendId.value) return
232
235
  loading.value = true
233
236
  try {
234
- detail.value = await api.send(sendId.value)
237
+ detail.value = await readSend()
235
238
  } catch (error) {
236
239
  useApiError(error)
237
240
  } finally {
@@ -242,7 +245,7 @@ async function load(): Promise<void> {
242
245
  /** The provider's tracking, so the timeline waits only for what it reports. */
243
246
  async function loadTracking(): Promise<void> {
244
247
  try {
245
- const provider = await api.provider()
248
+ const provider = await readProvider()
246
249
  tracking.value = provider.features
247
250
  ? {
248
251
  openTracking: !!provider.features.openTracking,
@@ -2,6 +2,7 @@
2
2
  import { useTemplateCategories } from '../composables/useTemplateCategories'
3
3
  import { useLocalizedDefaults } from '../composables/useLocalizedDefaults'
4
4
  import { useMailingApi } from '../composables/useMailingApi'
5
+ import { useStarters } from '../composables/useMailingReads'
5
6
  import { useTemplateDrawer } from '../composables/useTemplateDrawer'
6
7
  import {
7
8
  HTTP_CONFLICT,
@@ -24,18 +25,19 @@ const { processApiMessage } = useTranslation()
24
25
  const overlays = useTemplateDrawer()
25
26
 
26
27
  const localized = useLocalizedDefaults()
27
- const storedStarters = ref<StarterSummary[]>([])
28
- const starters = computed(() => localized.starters(storedStarters.value))
28
+ const starterList = useStarters()
29
+ /** The starters, none when the read failed. */
30
+ const starters = computed(() =>
31
+ localized.starters(
32
+ starterList.error.value ? [] : (starterList.data.value?.starters ?? []),
33
+ ),
34
+ )
29
35
  const { categories } = useTemplateCategories()
30
- const loading = ref(true)
36
+ const loading = computed(
37
+ () => !starterList.data.value && !starterList.error.value,
38
+ )
31
39
  const creatingId = ref<string | null>(null)
32
40
 
33
- onMounted(async () => {
34
- const starterList = await api.starters().catch(() => ({ starters: [] }))
35
- storedStarters.value = starterList.starters
36
- loading.value = false
37
- })
38
-
39
41
  function knownCategory(id: string): string | undefined {
40
42
  return categories.value.some((category) => category.id === id)
41
43
  ? id
@@ -1,5 +1,10 @@
1
1
  <script setup lang="ts">
2
- import { formatCardDate, formatCardSince, firstName } from '../utils/gallery'
2
+ import {
3
+ CARD_DATE_FORMAT,
4
+ formatCardDate,
5
+ formatCardSince,
6
+ firstName,
7
+ } from '../utils/gallery'
3
8
  import { localeChipClass, localeChips } from '../utils/locales'
4
9
  import {
5
10
  cardFooter,
@@ -47,6 +52,7 @@ const emit = defineEmits<{
47
52
  }>()
48
53
 
49
54
  const { t, locale } = useI18n()
55
+ const { dateTimeFormat } = useRegionalFormat()
50
56
  const { uniqueLocales } = useUniqueLocales()
51
57
 
52
58
  const isLive = computed(() => props.template.status === 'live')
@@ -85,8 +91,12 @@ const isNeverSent = computed(() => props.attention.includes('never_sent'))
85
91
  const footer = computed(() => cardFooter(props.template, props.stats))
86
92
 
87
93
  const footerDate = computed(() =>
88
- formatCardDate(footer.value.at, new Date(), locale.value, (date) =>
89
- t('dms_mailing.common.on_date', { date }),
94
+ formatCardDate(
95
+ footer.value.at,
96
+ new Date(),
97
+ locale.value,
98
+ (date) => t('dms_mailing.common.on_date', { date }),
99
+ dateTimeFormat(CARD_DATE_FORMAT),
90
100
  ),
91
101
  )
92
102
 
@@ -107,7 +117,12 @@ const openRateLabel = computed(() =>
107
117
 
108
118
  /** "live for 1 hr" within a day, "live since Sep 27" after. */
109
119
  const liveSince = computed(() =>
110
- formatCardSince(footer.value.at, new Date(), locale.value),
120
+ formatCardSince(
121
+ footer.value.at,
122
+ new Date(),
123
+ locale.value,
124
+ dateTimeFormat(CARD_DATE_FORMAT),
125
+ ),
111
126
  )
112
127
 
113
128
  const FOOTER_TEXTS: Partial<Record<CardFooterKind, () => string>> = {
@@ -5,6 +5,10 @@ import {
5
5
  useMailingApi,
6
6
  type TemplateDetails,
7
7
  } from '../composables/useMailingApi'
8
+ import {
9
+ useTemplateContentRead,
10
+ useTemplatePerformanceRead,
11
+ } from '../composables/useMailingReads'
8
12
  import { useTemplateDrawer } from '../composables/useTemplateDrawer'
9
13
  import { apiErrorMessage } from '../utils/api-error'
10
14
  import { formatCardDate } from '../utils/gallery'
@@ -106,6 +110,8 @@ const LIFECYCLE: Record<LifecycleAction, LifecycleSpec> = {
106
110
  }
107
111
 
108
112
  const templateId = computed(() => props.rowData?._id ?? props.templateId ?? '')
113
+ const readContent = useTemplateContentRead(templateId)
114
+ const readPerformance = useTemplatePerformanceRead(templateId)
109
115
  const detail = ref<TemplateContentResponse | null>(null)
110
116
  const performance = ref<TemplatePerformance | null>(null)
111
117
  const loading = ref(true)
@@ -129,8 +135,8 @@ async function load(): Promise<void> {
129
135
  loading.value = true
130
136
  try {
131
137
  const [content, stats] = await Promise.all([
132
- api.templateContent(templateId.value),
133
- api.performance(templateId.value).catch(() => null),
138
+ readContent(),
139
+ readPerformance().catch(() => null),
134
140
  ])
135
141
  detail.value = content
136
142
  performance.value = stats