@antelopejs/dms-mailing 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +61 -339
  2. package/dist/data/templates-table.js +2 -10
  3. package/dist/data/templates-table.js.map +1 -1
  4. package/dist/db/models/index.d.ts +1 -0
  5. package/dist/db/models/index.js +1 -0
  6. package/dist/db/models/index.js.map +1 -1
  7. package/dist/db/models/send-events.model.d.ts +1 -0
  8. package/dist/db/models/sends.model.d.ts +1 -0
  9. package/dist/db/models/settings.model.d.ts +1 -0
  10. package/dist/db/models/suppressions.model.d.ts +1 -0
  11. package/dist/db/models/template-slugs.model.d.ts +27 -0
  12. package/dist/db/models/template-slugs.model.js +43 -0
  13. package/dist/db/models/template-slugs.model.js.map +1 -0
  14. package/dist/db/models/template-versions.model.d.ts +1 -0
  15. package/dist/db/models/templates.model.d.ts +1 -0
  16. package/dist/db/models/webhook-status.model.d.ts +1 -0
  17. package/dist/db/tables/index.d.ts +1 -0
  18. package/dist/db/tables/index.js +1 -0
  19. package/dist/db/tables/index.js.map +1 -1
  20. package/dist/db/tables/template-slugs.table.d.ts +7 -0
  21. package/dist/db/tables/template-slugs.table.js +35 -0
  22. package/dist/db/tables/template-slugs.table.js.map +1 -0
  23. package/dist/routes/templates.js +1 -0
  24. package/dist/routes/templates.js.map +1 -1
  25. package/dist/services/templates.d.ts +2 -0
  26. package/dist/services/templates.js +26 -0
  27. package/dist/services/templates.js.map +1 -1
  28. package/frontend-vue/app/components/CategoriesInput.vue +18 -24
  29. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  30. package/frontend-vue/app/components/Editor.vue +3 -3
  31. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  32. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  33. package/frontend-vue/app/components/PageHeader.vue +8 -12
  34. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  35. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  36. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  37. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  38. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  39. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  40. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  41. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  42. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  43. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  44. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  45. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  46. package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
  47. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
  48. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  49. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  50. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  51. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  52. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  53. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  54. package/frontend-vue/app/composables/useSortableList.ts +159 -0
  55. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  56. package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
  57. package/frontend-vue/app/utils/gallery.ts +19 -7
  58. package/frontend-vue/app/utils/period.ts +0 -5
  59. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
  60. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
  61. package/frontend-vue/package.json +4 -3
  62. package/frontend-vue/pnpm-lock.yaml +6 -13
  63. package/frontend-vue/tests/categories-input.test.ts +43 -13
  64. package/frontend-vue/tests/editor-block-list.test.ts +46 -6
  65. package/frontend-vue/tests/editor-session.test.ts +4 -0
  66. package/frontend-vue/tests/gallery.test.ts +16 -0
  67. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  68. package/frontend-vue/tests/new-template-modal.test.ts +17 -5
  69. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  70. package/frontend-vue/tests/overview-gate.test.ts +59 -21
  71. package/frontend-vue/tests/page-reads.test.ts +157 -0
  72. package/frontend-vue/tests/period.test.ts +0 -9
  73. package/frontend-vue/tests/retention-input.test.ts +30 -9
  74. package/frontend-vue/tests/send-modals.test.ts +8 -1
  75. package/frontend-vue/tests/sortable-list.test.ts +253 -0
  76. package/frontend-vue/tests/template-drawer.test.ts +30 -14
  77. package/frontend-vue/tests/template-gallery.test.ts +45 -24
  78. package/package.json +31 -14
  79. package/skills/dms-mailing/SKILL.md +63 -0
  80. package/src/data/templates-table.ts +7 -27
  81. package/src/db/models/index.ts +1 -0
  82. package/src/db/models/template-slugs.model.ts +61 -0
  83. package/src/db/tables/index.ts +1 -0
  84. package/src/db/tables/template-slugs.table.ts +29 -0
  85. package/src/routes/templates.ts +2 -0
  86. package/src/services/templates.ts +49 -0
@@ -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
@@ -1,7 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { useTemplateCategories } from '../composables/useTemplateCategories'
3
3
  import { useEventListener } from '@vueuse/core'
4
- import { useMailingApi } from '../composables/useMailingApi'
4
+ import {
5
+ useTemplatesOverview,
6
+ useTemplateTotal,
7
+ } from '../composables/useMailingReads'
5
8
  import { useTemplateDrawer } from '../composables/useTemplateDrawer'
6
9
  import {
7
10
  groupByCategory,
@@ -44,29 +47,33 @@ const ALL_TAB = 'all'
44
47
 
45
48
  const props = defineProps<TemplateGalleryProps>()
46
49
 
47
- const api = useMailingApi()
48
50
  const { t } = useI18n()
49
51
  const overlays = useTemplateDrawer()
50
52
 
51
53
  const { categories } = useTemplateCategories()
52
- const statsById = ref(new Map<string, TemplateStats>())
53
- const templateCount = ref<number | null>(null)
54
+ const overview = useTemplatesOverview()
55
+ const total = useTemplateTotal()
54
56
 
55
- async function loadStats(): Promise<void> {
56
- const response = await api.templatesOverview().catch(() => ({ items: [] }))
57
- statsById.value = new Map(response.items.map((entry) => [entry.id, entry]))
58
- }
57
+ /** Each template's figures; none for a failed read. */
58
+ const statsById = computed(
59
+ () =>
60
+ new Map<string, TemplateStats>(
61
+ (overview.error.value ? [] : (overview.data.value?.items ?? [])).map(
62
+ (entry) => [entry.id, entry],
63
+ ),
64
+ ),
65
+ )
66
+ /** How many templates the tenant has; unknown while read or once failed. */
67
+ const templateCount = computed<number | null>(() =>
68
+ total.error.value ? null : (total.data.value?.total ?? null),
69
+ )
59
70
 
60
- async function loadTemplates(): Promise<void> {
61
- const response = await api.listTemplates().catch(() => null)
62
- templateCount.value = response ? response.total : null
71
+ /** The template count and the figures, read again with the rows. */
72
+ function refreshFigures(): void {
73
+ void overview.refresh()
74
+ void total.refresh()
63
75
  }
64
76
 
65
- onMounted(() => {
66
- void loadStats()
67
- void loadTemplates()
68
- })
69
-
70
77
  /** The page of the table on screen: its query and its page index. */
71
78
  const viewKeyOf = (): string =>
72
79
  JSON.stringify([props.context.query, props.context.pagination.pageIndex])
@@ -86,8 +93,7 @@ watch(
86
93
  const isRefresh = viewKey === shownViewKey
87
94
  shownViewKey = viewKey
88
95
  if (!isRefresh) return
89
- void loadStats()
90
- void loadTemplates()
96
+ refreshFigures()
91
97
  },
92
98
  )
93
99
 
@@ -157,10 +163,9 @@ const emptyDescription = computed(() =>
157
163
  : t('dms_mailing.templates.empty.view_description'),
158
164
  )
159
165
 
166
+ /** The rows, and with them the template count and the figures. */
160
167
  function refreshAll(): void {
161
168
  void props.context.refresh()
162
- void loadStats()
163
- void loadTemplates()
164
169
  }
165
170
 
166
171
  function openRow(row: TemplateRow): void {
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useDebounceFn, useIntersectionObserver } from '@vueuse/core'
3
3
  import { useMailingApi } from '../composables/useMailingApi'
4
+ import { useStarterPreviewRead } from '../composables/useMailingReads'
4
5
  import type {
5
6
  PreviewResponse,
6
7
  PreviewVersion,
@@ -62,6 +63,10 @@ const emit = defineEmits<{ resolved: [PreviewResponse] }>()
62
63
  const api = useMailingApi()
63
64
  const { locale: uiLocale } = useI18n()
64
65
 
66
+ const readStarterPreview = useStarterPreviewRead(
67
+ () => props.starterId,
68
+ () => props.locale ?? uiLocale.value,
69
+ )
65
70
  const fetchedHtml = ref('')
66
71
  const loading = ref(false)
67
72
  const height = ref(FALLBACK_HEIGHT)
@@ -86,11 +91,8 @@ useIntersectionObserver(box, ([entry]) => {
86
91
  if (entry?.isIntersecting) isVisible.value = true
87
92
  })
88
93
 
89
- async function fetchStarter(starterId: string): Promise<void> {
90
- const preview = await api.starterPreview(
91
- starterId,
92
- props.locale ?? uiLocale.value,
93
- )
94
+ async function fetchStarter(): Promise<void> {
95
+ const preview = await readStarterPreview()
94
96
  fetchedHtml.value = preview.html
95
97
  }
96
98
 
@@ -111,7 +113,7 @@ async function load(): Promise<void> {
111
113
  loading.value = true
112
114
  try {
113
115
  await (props.starterId
114
- ? fetchStarter(props.starterId)
116
+ ? fetchStarter()
115
117
  : fetchTemplate(props.templateId as string))
116
118
  } catch {
117
119
  fetchedHtml.value = ''
@@ -2,6 +2,7 @@
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 { useTemplateContentRead } from '../composables/useMailingReads'
5
6
  import type { SendResult, TestSendInput } from '../composables/useMailingApi'
6
7
  import { apiErrorMessage } from '../utils/api-error'
7
8
  import { sendLogRoute } from '../utils/send-link'
@@ -54,6 +55,7 @@ const { user } = useCurrentUser()
54
55
  const { uniqueLocales } = useUniqueLocales()
55
56
 
56
57
  const templateId = computed(() => props.rowData?._id ?? props.templateId ?? '')
58
+ const readContent = useTemplateContentRead(templateId)
57
59
  const detail = ref<TemplateContentResponse | null>(null)
58
60
  const ownEmail = (user.value as SignedInUser | null)?.email
59
61
  const recipients = ref<string[]>(ownEmail ? [ownEmail] : [])
@@ -65,7 +67,7 @@ const sending = ref(false)
65
67
  onMounted(async () => {
66
68
  if (!templateId.value) return
67
69
  try {
68
- detail.value = await api.templateContent(templateId.value)
70
+ detail.value = await readContent()
69
71
  locale.value ||= detail.value.fallbackLocale
70
72
  } catch (error) {
71
73
  toast.add({
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Component } from 'vue'
3
- import { useMailingApi } from '../composables/useMailingApi'
3
+ import { useTemplateVersionsRead } from '../composables/useMailingReads'
4
4
  import type { TemplateStatus, TemplateVersionSummary } from '../types/mailing'
5
5
  import { apiErrorMessage } from '../utils/api-error'
6
6
  import {
@@ -31,7 +31,6 @@ const props = withDefaults(defineProps<VersionHistoryProps>(), {
31
31
  locale: undefined,
32
32
  })
33
33
 
34
- const api = useMailingApi()
35
34
  const modal = useModal()
36
35
  const toast = useToast()
37
36
  const { confirm } = useConfirm()
@@ -39,13 +38,14 @@ const { t, locale: uiLocale } = useI18n()
39
38
  const { processApiMessage } = useTranslation()
40
39
  const previewComponent = resolveComponent(PREVIEW_COMPONENT) as Component
41
40
 
41
+ const readVersions = useTemplateVersionsRead(() => props.templateId)
42
42
  const versions = ref<TemplateVersionSummary[]>([])
43
43
  const loading = ref(true)
44
44
 
45
45
  async function load(): Promise<void> {
46
46
  loading.value = true
47
47
  try {
48
- versions.value = (await api.versions(props.templateId)).versions
48
+ versions.value = (await readVersions()).versions
49
49
  } catch (error) {
50
50
  toast.add({
51
51
  color: 'error',
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useMailingApi } from '../composables/useMailingApi'
3
- import { formatCardDate } from '../utils/gallery'
3
+ import { useWebhookStatus } from '../composables/useMailingReads'
4
+ import { CARD_DATE_FORMAT, formatCardDate } from '../utils/gallery'
4
5
  import {
5
6
  type WebhookLineTone,
6
7
  type WebhookStatus,
@@ -33,9 +34,14 @@ const TONE_CLASSES: Record<WebhookLineTone, string> = {
33
34
  const api = useMailingApi()
34
35
  const toast = useToast()
35
36
  const { t, locale } = useI18n()
37
+ const { dateTimeFormat } = useRegionalFormat()
36
38
 
37
- const status = ref<WebhookStatus | null>(null)
38
- const isLoading = ref(true)
39
+ const read = useWebhookStatus()
40
+ /** The status read, `null` when the read failed. */
41
+ const status = computed<WebhookStatus | null>(() =>
42
+ read.error.value ? null : read.data.value,
43
+ )
44
+ const isLoading = computed(() => !read.data.value && !read.error.value)
39
45
  const isTesting = ref(false)
40
46
 
41
47
  const lines = computed(() =>
@@ -51,17 +57,15 @@ function lineText(line: WebhookStatusLine): string {
51
57
  }
52
58
 
53
59
  function lineTime(line: WebhookStatusLine): string {
54
- return line.at ? formatCardDate(line.at, new Date(), locale.value) : ''
55
- }
56
-
57
- async function load(): Promise<void> {
58
- try {
59
- status.value = await api.webhookStatus()
60
- } catch {
61
- status.value = null
62
- } finally {
63
- isLoading.value = false
64
- }
60
+ return line.at
61
+ ? formatCardDate(
62
+ line.at,
63
+ new Date(),
64
+ locale.value,
65
+ undefined,
66
+ dateTimeFormat(CARD_DATE_FORMAT),
67
+ )
68
+ : ''
65
69
  }
66
70
 
67
71
  async function sendTest(): Promise<void> {
@@ -69,7 +73,8 @@ async function sendTest(): Promise<void> {
69
73
  isTesting.value = true
70
74
  try {
71
75
  const result = await api.webhookTest()
72
- status.value = result.status
76
+ read.data.value = result.status
77
+ read.error.value = null
73
78
  const message = webhookTestMessage(result)
74
79
  toast.add({
75
80
  color: message.color,
@@ -84,8 +89,6 @@ async function sendTest(): Promise<void> {
84
89
  isTesting.value = false
85
90
  }
86
91
  }
87
-
88
- onMounted(() => void load())
89
92
  </script>
90
93
 
91
94
  <template>
@@ -1,8 +1,12 @@
1
1
  <script setup lang="ts">
2
- import draggable from 'vuedraggable'
2
+ import { ref } from 'vue'
3
3
  import type { Block } from '../../types/mailing'
4
4
  import { useEditorContext } from '../../composables/useEditorContext'
5
- import { DRAG_GROUP, DRAG_HANDLE } from '../../utils/blocks'
5
+ import {
6
+ useSortableList,
7
+ type SortableListChange,
8
+ } from '../../composables/useSortableList'
9
+ import { DRAG_GROUP, DRAG_HANDLE, DRAG_ITEM } from '../../utils/blocks'
6
10
  import EditorBlock from './EditorBlock.vue'
7
11
  import EditorInsertPoint from './EditorInsertPoint.vue'
8
12
 
@@ -12,40 +16,39 @@ interface Props {
12
16
 
13
17
  const props = defineProps<Props>()
14
18
 
15
- interface DraggableChange {
16
- added?: { element: Block }
17
- }
18
-
19
19
  const { editor } = useEditorContext()
20
20
 
21
- function onChange(change: DraggableChange): void {
21
+ function onChange(change: SortableListChange<Block>): void {
22
22
  editor.touch()
23
23
  if (change.added) editor.select(change.added.element.id)
24
24
  }
25
+
26
+ const root = ref<HTMLElement | null>(null)
27
+
28
+ // The list is the editor's: a drop changes it in place, as a click does.
29
+ useSortableList(root, () => props.list, {
30
+ group: DRAG_GROUP,
31
+ handle: DRAG_HANDLE,
32
+ draggable: DRAG_ITEM,
33
+ ghostClass: 'opacity-30',
34
+ onChange,
35
+ })
25
36
  </script>
26
37
 
27
38
  <template>
28
- <draggable
29
- :list="props.list"
30
- :group="DRAG_GROUP"
31
- :handle="DRAG_HANDLE"
32
- item-key="id"
33
- ghost-class="opacity-30"
34
- class="flex flex-col"
35
- @change="onChange"
36
- >
37
- <template #item="{ element, index }">
38
- <div>
39
- <EditorInsertPoint :list="props.list" :index="index" />
40
- <EditorBlock :block="element" :index="index" :list="props.list" />
41
- </div>
42
- </template>
43
- <template #footer>
44
- <EditorInsertPoint
45
- :list="props.list"
46
- :index="props.list.length"
47
- :is-empty="props.list.length === 0"
48
- />
49
- </template>
50
- </draggable>
39
+ <div ref="root" class="flex flex-col">
40
+ <div
41
+ v-for="(element, index) in props.list"
42
+ :key="element.id"
43
+ data-drag-item
44
+ >
45
+ <EditorInsertPoint :list="props.list" :index="index" />
46
+ <EditorBlock :block="element" :index="index" :list="props.list" />
47
+ </div>
48
+ <EditorInsertPoint
49
+ :list="props.list"
50
+ :index="props.list.length"
51
+ :is-empty="props.list.length === 0"
52
+ />
53
+ </div>
51
54
  </template>