@antelopejs/dms-marketing 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.
@@ -1,18 +1,21 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
3
3
  import { useI18n } from '#dms/frontend-module'
4
- import { useLatestRequest } from '../composables/useLatestRequest'
5
4
  import {
6
5
  useMarketingApi,
7
6
  type MarketingPeriod,
8
- type MarketingHeatmap,
9
- type MarketingPageSnapshotResponse,
10
- type MarketingScrollDepth,
11
7
  type MarketingSnapshotLayout,
12
8
  type MarketingWebsite,
13
9
  type MarketingWebsitePatch,
14
10
  type MarketingFunnelListItem,
15
11
  } from '../composables/useMarketingApi'
12
+ import {
13
+ useHeatmapCountRead,
14
+ usePageHeatmap,
15
+ usePageScrollDepth,
16
+ usePageSnapshot,
17
+ type PagePreviewRequest,
18
+ } from '../composables/useMarketingReads'
16
19
  import { funnelLink, settingsLink } from '../composables/useMarketingRoutes'
17
20
  import { formatNumber, formatShortDate } from '../utils/format'
18
21
  import {
@@ -77,17 +80,14 @@ const props = defineProps<{
77
80
  trackerEnabled: boolean
78
81
  /** Pageviews of this path over the window, for the sampling hint. */
79
82
  pageviews?: number
80
- /** Bumped by the parent when it resets clicks out from under this view. */
81
- refreshToken?: number
82
83
  /** Funnels with a page step on this path. */
83
84
  usedIn?: MarketingFunnelListItem[]
84
85
  }>()
85
86
 
86
- /** The site's snapshot options were changed here; the parent owns the row. */
87
+ /** The site's snapshot options were saved here, as the backend answered
88
+ * them; the parent owns the row. */
87
89
  const emit = defineEmits<{
88
- 'website-updated': []
89
- /** The page lost its clicks: the inventory counts them too. */
90
- 'heatmap-reset': []
90
+ 'website-updated': [website: MarketingWebsite]
91
91
  }>()
92
92
 
93
93
  interface LayoutPreset {
@@ -138,19 +138,20 @@ const { t, locale } = useI18n()
138
138
 
139
139
  // --- Heatmap ----------------------------------------------------------------
140
140
 
141
- // Path, site and period all retrigger a load and the answers can land out of
142
- // order: a slow answer for the page the user just left would otherwise repaint
143
- // over the one on screen — see useLatestRequest.
144
- const {
145
- data: heatmap,
146
- loading,
147
- failed,
148
- run: loadHeatmap,
149
- } = useLatestRequest<MarketingHeatmap>(() =>
150
- props.website && props.path && !import.meta.env.SSR
151
- ? api.getHeatmap(props.website._id, props.path, props.period)
152
- : null,
141
+ // Path, site and period all read again, and a newer read replaces one still
142
+ // in flight: a slow answer for the page the user just left never repaints
143
+ // over the one on screen. Drawn in the browser only.
144
+ const previewRequest: PagePreviewRequest = {
145
+ websiteId: () => props.website?._id,
146
+ path: () => props.path,
147
+ period: () => props.period,
148
+ }
149
+ const heatmapRead = usePageHeatmap(previewRequest)
150
+ const heatmap = computed(() =>
151
+ heatmapRead.error.value ? null : heatmapRead.data.value,
153
152
  )
153
+ const loading = computed(() => heatmapRead.pending.value)
154
+ const failed = computed(() => !!heatmapRead.error.value && !loading.value)
154
155
 
155
156
  const points = computed(() => heatmap.value?.points ?? [])
156
157
  const maxWeight = computed(() => heatmap.value?.maxWeight ?? 0)
@@ -163,30 +164,13 @@ const overlayMode = ref<'clicks' | 'scroll'>('clicks')
163
164
  // Read with the clicks, not when its overlay opens: every sampled view
164
165
  // reports one depth, so its count is the page's sampled views the header
165
166
  // and the empty states speak of.
166
- const {
167
- data: scrollDepth,
168
- loading: loadingScroll,
169
- failed: scrollFailed,
170
- run: loadScrollDepth,
171
- } = useLatestRequest<MarketingScrollDepth>(() =>
172
- props.website && props.path && !import.meta.env.SSR
173
- ? api.getScrollDepth(props.website._id, props.path, props.period)
174
- : null,
167
+ const scrollRead = usePageScrollDepth(previewRequest)
168
+ const scrollDepth = computed(() =>
169
+ scrollRead.error.value ? null : scrollRead.data.value,
175
170
  )
176
-
177
- watch(
178
- () => [
179
- props.website?._id,
180
- props.path,
181
- props.period?.range?.from.getTime(),
182
- props.period?.range?.to.getTime(),
183
- props.refreshToken,
184
- ],
185
- () => {
186
- void loadHeatmap()
187
- void loadScrollDepth()
188
- },
189
- { immediate: true },
171
+ const loadingScroll = computed(() => scrollRead.pending.value)
172
+ const scrollFailed = computed(
173
+ () => !!scrollRead.error.value && !loadingScroll.value,
190
174
  )
191
175
 
192
176
  const scrollViews = computed(() => scrollDepth.value?.views ?? 0)
@@ -221,8 +205,13 @@ const { confirm } = useConfirm()
221
205
  const toast = useToast()
222
206
  const resetting = ref(false)
223
207
 
208
+ const countPageHeatmap = useHeatmapCountRead(
209
+ () => props.website?._id ?? '',
210
+ () => props.path,
211
+ )
212
+
224
213
  /** Deletes the clicks, scrolls and snapshots of the page (every period),
225
- * counted by the server before asking, then refetches both overlays. */
214
+ * counted by the server before asking; the page's reads follow. */
226
215
  async function resetPageHeatmap() {
227
216
  const website = props.website
228
217
  if (!website) {
@@ -230,8 +219,7 @@ async function resetPageHeatmap() {
230
219
  }
231
220
  const path = props.path
232
221
  resetting.value = true
233
- const counts = await api
234
- .countHeatmap(website._id, path)
222
+ const counts = await countPageHeatmap()
235
223
  .catch(() => null)
236
224
  .finally(() => (resetting.value = false))
237
225
  await confirm({
@@ -258,8 +246,8 @@ async function resetPageHeatmap() {
258
246
  what: heatmapDataLabel(deleted, t, locale.value),
259
247
  }),
260
248
  })
261
- emit('heatmap-reset')
262
- await Promise.all([loadHeatmap(), loadScrollDepth(), loadSnapshot()])
249
+ // The preview shows the emptied page before the button comes back.
250
+ await refreshPageBlocks()
263
251
  } finally {
264
252
  resetting.value = false
265
253
  }
@@ -283,29 +271,26 @@ const snapshotsEnabled = computed(
283
271
 
284
272
  // Not fetched while the site has snapshots off: the option's banner is the
285
273
  // whole story then, and a leftover capture must not contradict it.
286
- const {
287
- data: snapshotResponse,
288
- settled: snapshotSettled,
289
- run: loadSnapshot,
290
- } = useLatestRequest<MarketingPageSnapshotResponse>(() =>
291
- props.website && snapshotsEnabled.value && props.path && !import.meta.env.SSR
292
- ? api.getSnapshot(props.website._id, props.path, layout.value)
293
- : null,
274
+ const snapshotRead = usePageSnapshot({
275
+ websiteId: () => props.website?._id,
276
+ path: () => props.path,
277
+ layout,
278
+ enabled: snapshotsEnabled,
279
+ })
280
+ const isSnapshotAsked = computed(
281
+ () => snapshotsEnabled.value && !!props.website?._id && !!props.path,
294
282
  )
295
-
296
- watch(
297
- () => [
298
- props.website?._id,
299
- snapshotsEnabled.value,
300
- props.path,
301
- layout.value,
302
- props.refreshToken,
303
- ],
304
- () => void loadSnapshot(),
305
- { immediate: true },
283
+ /** A capture was asked and answered, or there is none to ask for. */
284
+ const snapshotSettled = computed(
285
+ () =>
286
+ !isSnapshotAsked.value ||
287
+ snapshotRead.status.value === 'success' ||
288
+ snapshotRead.status.value === 'error',
306
289
  )
307
290
 
308
- const snapshot = computed(() => snapshotResponse.value?.snapshot ?? null)
291
+ const snapshot = computed(() =>
292
+ snapshotRead.error.value ? null : (snapshotRead.data.value?.snapshot ?? null),
293
+ )
309
294
 
310
295
  const backdrop = computed(() => snapshot.value?.html ?? null)
311
296
 
@@ -345,8 +330,7 @@ async function updateSnapshotOptions(
345
330
  }
346
331
  savingOptions.value = true
347
332
  try {
348
- await api.updateWebsite(website._id, patch)
349
- emit('website-updated')
333
+ emit('website-updated', await api.updateWebsite(website._id, patch))
350
334
  } catch {
351
335
  toast.add({
352
336
  color: 'error',
@@ -627,10 +611,10 @@ const activeLoading = computed(() =>
627
611
 
628
612
  function retryActive(): void {
629
613
  if (overlayMode.value === 'clicks') {
630
- void loadHeatmap()
614
+ void heatmapRead.refresh()
631
615
  return
632
616
  }
633
- void loadScrollDepth()
617
+ void scrollRead.refresh()
634
618
  }
635
619
 
636
620
  const truncatedState = computed(() => {
@@ -947,7 +931,7 @@ const moreItems = computed(() => [
947
931
  type: 'checkbox' as const,
948
932
  label: t('page.marketing.pages.preview.snapshots_enabled'),
949
933
  checked: snapshotsEnabled.value,
950
- onUpdateChecked: (value: boolean) =>
934
+ onUpdateChecked: (value: boolean): void =>
951
935
  void updateSnapshotOptions({ snapshotsEnabled: value }),
952
936
  },
953
937
  {
@@ -955,7 +939,7 @@ const moreItems = computed(() => [
955
939
  label: t('page.marketing.pages.preview.mask_text'),
956
940
  checked: props.website?.snapshotMaskText === true,
957
941
  disabled: !snapshotsEnabled.value,
958
- onUpdateChecked: (value: boolean) =>
942
+ onUpdateChecked: (value: boolean): void =>
959
943
  void updateSnapshotOptions({ snapshotMaskText: value }),
960
944
  },
961
945
  ],
@@ -1,15 +1,19 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
3
  import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
- import { useLatestRequest } from '../composables/useLatestRequest'
5
4
  import {
6
5
  useMarketingApi,
7
- type MarketingFunnelListItem,
8
- type MarketingPages,
9
6
  type MarketingPageEntry,
7
+ type MarketingPages,
10
8
  type MarketingPagesSort,
11
9
  type MarketingWebsite,
12
10
  } from '../composables/useMarketingApi'
11
+ import {
12
+ useHeatmapCountRead,
13
+ usePageInventory,
14
+ useWebsiteFunnels,
15
+ useWebsites,
16
+ } from '../composables/useMarketingReads'
13
17
  import { useMarketingContext } from '../composables/useMarketingContext'
14
18
  import { installLink } from '../composables/useMarketingRoutes'
15
19
  import { PERIOD_SCOPE } from '../constants'
@@ -55,78 +59,83 @@ const { confirm } = useConfirm()
55
59
  const websiteId = computed(() => context.selected.value?.id ?? '')
56
60
  const period = usePeriodScope(props.periodScope)
57
61
  /** The days read: a comparison change alone reads nothing again. */
58
- const periodDays = computed(() => {
59
- const range = period.value?.range
60
- return range ? `${range.from.toISOString()}|${range.to.toISOString()}` : ''
61
- })
62
+ const pagesPeriod = computed(() =>
63
+ period.value
64
+ ? {
65
+ ...period.value,
66
+ comparison: 'none' as const,
67
+ compareRange: null,
68
+ }
69
+ : null,
70
+ )
62
71
 
63
72
  const queryPath = () =>
64
73
  typeof route.query.path === 'string' ? route.query.path : ''
65
74
  const selectedPath = ref<string | undefined>(queryPath() || undefined)
66
75
  const search = ref('')
76
+ /** The search the list is read with, once the box holds still. */
77
+ const searchedTerm = ref('')
67
78
  const sort = ref<MarketingPagesSort>('views')
68
79
 
69
80
  // The full row: the context carries the id, the preview needs the snapshot options.
70
- const website = ref<MarketingWebsite | null>(null)
71
-
72
- async function loadWebsite(): Promise<void> {
73
- const list = await api.listWebsites()
74
- website.value = list.find((site) => site._id === websiteId.value) ?? null
75
- }
76
-
77
- const {
78
- data: inventory,
79
- loading,
80
- settled,
81
- failed,
82
- run: loadPages,
83
- } = useLatestRequest<MarketingPages>(() =>
84
- websiteId.value && !import.meta.env.SSR
85
- ? api.listPages(
86
- websiteId.value,
87
- period.value,
88
- search.value.trim(),
89
- sort.value,
90
- )
91
- : null,
81
+ const websitesRead = useWebsites()
82
+ const website = computed(
83
+ () =>
84
+ websitesRead.data.value?.find((site) => site._id === websiteId.value) ??
85
+ null,
92
86
  )
93
87
 
94
- const funnels = ref<MarketingFunnelListItem[]>([])
95
-
96
- async function loadFunnels(): Promise<void> {
97
- try {
98
- funnels.value = (await api.listFunnels(websiteId.value)).results
99
- } catch {
100
- funnels.value = []
88
+ /** A site as its update answered it, in place: the list is not read again. */
89
+ function putWebsite(saved: MarketingWebsite): void {
90
+ const sites = websitesRead.data.value
91
+ if (sites) {
92
+ websitesRead.data.value = sites.map((site) =>
93
+ site._id === saved._id ? saved : site,
94
+ )
101
95
  }
102
96
  }
103
97
 
104
- watch(
105
- [websiteId, context.refreshToken],
106
- ([id], previous) => {
107
- if (previous?.[0] !== undefined && id !== previous[0]) {
108
- selectedPath.value = undefined
109
- }
110
- if (id) {
111
- void loadWebsite()
112
- void loadFunnels()
113
- }
114
- },
115
- { immediate: true },
98
+ const inventoryRead = usePageInventory({
99
+ websiteId,
100
+ period: pagesPeriod,
101
+ search: searchedTerm,
102
+ sort,
103
+ })
104
+ const inventory = computed(() =>
105
+ inventoryRead.error.value ? null : inventoryRead.data.value,
116
106
  )
107
+ const loading = computed(() => inventoryRead.status.value === 'pending')
108
+ /** A list is on screen (the previous one while another loads), or failed. */
109
+ const settled = computed(
110
+ () =>
111
+ inventoryRead.data.value !== null ||
112
+ inventoryRead.status.value === 'success' ||
113
+ inventoryRead.status.value === 'error',
114
+ )
115
+ const failed = computed(
116
+ () => !!inventoryRead.error.value && inventoryRead.status.value !== 'pending',
117
+ )
118
+ const loadPages = (): Promise<void> => inventoryRead.refresh()
117
119
 
118
- watch(
119
- [websiteId, periodDays, context.refreshToken, sort],
120
- () => void loadPages(),
121
- { immediate: true },
120
+ const funnelsRead = useWebsiteFunnels(websiteId)
121
+ const countSiteHeatmap = useHeatmapCountRead(websiteId)
122
+ const funnels = computed(() =>
123
+ funnelsRead.error.value ? [] : (funnelsRead.data.value ?? []),
122
124
  )
123
125
 
126
+ watch(websiteId, () => {
127
+ selectedPath.value = undefined
128
+ })
129
+
124
130
  let searchTimer: ReturnType<typeof setTimeout> | null = null
125
- watch(search, () => {
131
+ watch(search, (value) => {
126
132
  if (searchTimer) {
127
133
  clearTimeout(searchTimer)
128
134
  }
129
- searchTimer = setTimeout(() => void loadPages(), SEARCH_DEBOUNCE_MS)
135
+ searchTimer = setTimeout(
136
+ () => (searchedTerm.value = value.trim()),
137
+ SEARCH_DEBOUNCE_MS,
138
+ )
130
139
  })
131
140
  onBeforeUnmount(() => searchTimer && clearTimeout(searchTimer))
132
141
 
@@ -170,13 +179,15 @@ watch(queryPath, (path) => {
170
179
  })
171
180
 
172
181
  // A first impression that shows the feature: the first page with clicks,
173
- // else the first page.
174
- watch(inventory, (data) => {
182
+ // else the first page. In the browser: the list a server render hands over
183
+ // is chosen from once mounted.
184
+ function selectFirst(data: MarketingPages | null): void {
175
185
  if (!selectedPath.value && data?.pages.length) {
176
186
  const withClicks = data.pages.find((page) => page.clicks > 0)
177
187
  selectPath((withClicks ?? data.pages[0])?.path, 'replace')
178
188
  }
179
- })
189
+ }
190
+ watch(inventory, selectFirst)
180
191
 
181
192
  const previewWrapper = ref<HTMLElement | null>(null)
182
193
 
@@ -220,12 +231,13 @@ const usedIn = computed(() =>
220
231
  ),
221
232
  )
222
233
 
223
- const refreshToken = ref(0)
224
-
225
- // Client-only: the server renders no session, so its markup (an empty list)
226
- // would not match the client's and hydration would misplace the preview.
234
+ // The preview draws in the browser only (frames, canvases): a server render
235
+ // holds its place.
227
236
  const mounted = ref(false)
228
- onMounted(() => (mounted.value = true))
237
+ onMounted(() => {
238
+ mounted.value = true
239
+ selectFirst(inventory.value)
240
+ })
229
241
 
230
242
  const countLabel = computed(() => {
231
243
  const listed = pages.value.length
@@ -245,7 +257,7 @@ async function resetSite(): Promise<void> {
245
257
  if (!site) {
246
258
  return
247
259
  }
248
- const counts = await api.countHeatmap(site._id).catch(() => null)
260
+ const counts = await countSiteHeatmap().catch(() => null)
249
261
  await confirm({
250
262
  title: t('page.marketing.pages.reset.site_title', { name: site.name }),
251
263
  description: t('page.marketing.pages.reset.site_description'),
@@ -270,7 +282,8 @@ async function resetSite(): Promise<void> {
270
282
  what: heatmapDataLabel(deleted, t, locale.value),
271
283
  }),
272
284
  })
273
- refreshToken.value++
285
+ // The dialog closes once the pages show the emptied counts.
286
+ await refreshPageBlocks()
274
287
  },
275
288
  })
276
289
  }
@@ -323,18 +336,7 @@ const moreItems = computed(() => [
323
336
  :description="t('page.marketing.pages.tracking_off.description')"
324
337
  />
325
338
 
326
- <div
327
- v-if="!mounted"
328
- class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
329
- aria-hidden="true"
330
- >
331
- <USkeleton class="h-[32rem] rounded-xl" />
332
- <USkeleton class="h-[32rem] rounded-xl" />
333
- </div>
334
- <div
335
- v-else
336
- class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]"
337
- >
339
+ <div class="grid items-start gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
338
340
  <section
339
341
  class="dms-card flex min-w-0 flex-col overflow-hidden p-0 xl:sticky xl:top-4"
340
342
  >
@@ -516,21 +518,25 @@ const moreItems = computed(() => [
516
518
  </section>
517
519
 
518
520
  <div
519
- v-if="selectedPath && website"
521
+ v-if="(selectedPath && website) || (!mounted && pages.length > 0)"
520
522
  ref="previewWrapper"
521
523
  class="min-w-0 scroll-mt-4"
522
524
  >
525
+ <USkeleton
526
+ v-if="!mounted || !selectedPath || !website"
527
+ class="h-[32rem] rounded-xl"
528
+ aria-hidden="true"
529
+ />
523
530
  <DmsMarketingPagePreview
531
+ v-else
524
532
  :website="website"
525
533
  :path="selectedPath"
526
534
  :period="period"
527
535
  :sample-rate-label="sampleRateLabel"
528
536
  :tracker-enabled="trackerEnabled"
529
537
  :pageviews="selectedPage?.pageviews"
530
- :refresh-token="refreshToken + context.refreshToken.value"
531
538
  :used-in="usedIn"
532
- @website-updated="loadWebsite"
533
- @heatmap-reset="loadPages"
539
+ @website-updated="putWebsite"
534
540
  />
535
541
  </div>
536
542
  <DmsEmptyState
@@ -1,11 +1,16 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import { useDmsRouter, useI18n } from '#dms/frontend-module'
4
4
  import {
5
5
  useMarketingApi,
6
6
  type MarketingWebsiteCard,
7
7
  } from '../composables/useMarketingApi'
8
8
  import { installLink, overviewLink } from '../composables/useMarketingRoutes'
9
+ import {
10
+ useWebsiteCards,
11
+ useWebsiteDeletionImpactRead,
12
+ } from '../composables/useMarketingReads'
13
+ import { useMarketingNow } from '../composables/useMarketingNow'
9
14
  import {
10
15
  formatNumber,
11
16
  formatRelativeTime,
@@ -51,25 +56,23 @@ const { confirm } = useConfirm()
51
56
 
52
57
  void props
53
58
 
54
- const cards = ref<MarketingWebsiteCard[] | null>(null)
55
- const failed = ref(false)
59
+ const cardsRead = useWebsiteCards()
60
+ const cards = cardsRead.data
61
+ /** The cards could not be read; shown once a retry is over. */
62
+ const failed = computed(
63
+ () => !!cardsRead.error.value && cardsRead.status.value !== 'pending',
64
+ )
56
65
 
57
- async function load(): Promise<void> {
58
- failed.value = false
59
- try {
60
- cards.value = await api.listWebsiteCards()
61
- } catch {
62
- failed.value = true
63
- }
64
- }
66
+ /** The cards again: a retry after a failed read. */
67
+ const load = (): Promise<void> => cardsRead.refresh()
65
68
 
66
- const now = ref(Date.now())
67
- let clock: ReturnType<typeof setInterval> | null = null
68
- onMounted(() => {
69
- void load()
70
- clock = setInterval(() => (now.value = Date.now()), CLOCK_TICK_MS)
71
- })
72
- onBeforeUnmount(() => clock && clearInterval(clock))
69
+ /** The website a deletion is asked about. */
70
+ const deletionTarget = ref('')
71
+ const readDeletionImpact = useWebsiteDeletionImpactRead(deletionTarget)
72
+
73
+ const now = useMarketingNow(CLOCK_TICK_MS)
74
+ /** The browser's zone, which a server render writes the dates in. */
75
+ const readerZone = readerTimeZone()
73
76
 
74
77
  function stateLine(card: MarketingWebsiteCard): string {
75
78
  if (card.state === 'live' && card.lastActivityAt) {
@@ -106,7 +109,9 @@ async function runCardAction(
106
109
  try {
107
110
  await action()
108
111
  toast.add({ color: 'success', title: successTitle })
109
- await load()
112
+ // Their state and counts are the backend's: the page reads them again,
113
+ // the card's spinner on until it shows them.
114
+ await refreshPageBlocks()
110
115
  } catch (error) {
111
116
  const conflict = hostConflictOf(error)
112
117
  if (conflict) {
@@ -226,7 +231,7 @@ async function save(): Promise<void> {
226
231
  })
227
232
  toast.add({ color: 'success', title: t('page.marketing.websites.saved') })
228
233
  drawerOpen.value = false
229
- await load()
234
+ refreshPageBlocks()
230
235
  } catch (error) {
231
236
  // A refused field lives on the General tab: show it there.
232
237
  const conflict = hostConflictOf(error)
@@ -253,7 +258,8 @@ async function save(): Promise<void> {
253
258
  async function remove(card: MarketingWebsiteCard): Promise<void> {
254
259
  let impact
255
260
  try {
256
- impact = await api.getWebsiteDeletionImpact(card.id)
261
+ deletionTarget.value = card.id
262
+ impact = await readDeletionImpact()
257
263
  } catch (error) {
258
264
  fieldErrors.toastError(error, {
259
265
  toastTitle: 'page.marketing.websites.delete.impact_error',
@@ -305,7 +311,8 @@ async function remove(card: MarketingWebsiteCard): Promise<void> {
305
311
  title: t('page.marketing.websites.delete.done', { name: card.name }),
306
312
  })
307
313
  drawerOpen.value = false
308
- await load()
314
+ // Its card goes; the others are unchanged, so nothing is read again.
315
+ cards.value = cards.value?.filter((other) => other.id !== card.id) ?? null
309
316
  },
310
317
  })
311
318
  }
@@ -565,7 +572,9 @@ async function remove(card: MarketingWebsiteCard): Promise<void> {
565
572
  card.rejected.length > 0
566
573
  ? 'page.marketing.websites.waiting_refused'
567
574
  : 'page.marketing.websites.waiting_description',
568
- { date: formatShortDate(card.createdAt, locale) },
575
+ {
576
+ date: formatShortDate(card.createdAt, locale, readerZone),
577
+ },
569
578
  )
570
579
  }}
571
580
  </p>