@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.
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/public-endpoints.d.ts +2 -0
- package/dist/routes/public-endpoints.js +14 -0
- package/dist/routes/public-endpoints.js.map +1 -0
- package/frontend-vue/app/components/Context.vue +37 -61
- package/frontend-vue/app/components/FunnelBuilder.vue +22 -19
- package/frontend-vue/app/components/FunnelTemplates.vue +16 -27
- package/frontend-vue/app/components/InstallGuide.vue +40 -57
- package/frontend-vue/app/components/PagePreview.vue +60 -76
- package/frontend-vue/app/components/PagesExplorer.vue +86 -80
- package/frontend-vue/app/components/WebsitesGrid.vue +32 -23
- package/frontend-vue/app/composables/useMarketingApi.ts +7 -120
- package/frontend-vue/app/composables/useMarketingContext.ts +1 -10
- package/frontend-vue/app/composables/useMarketingNow.ts +29 -0
- package/frontend-vue/app/composables/useMarketingReads.ts +314 -0
- package/frontend-vue/app/utils/format.ts +37 -1
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/format.test.ts +21 -0
- package/frontend-vue/tests/marketing-reads.test.ts +252 -0
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
142
|
-
//
|
|
143
|
-
// over the one on screen
|
|
144
|
-
const {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
262
|
-
await
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
297
|
-
() =>
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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(() =>
|
|
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
|
|
614
|
+
void heatmapRead.refresh()
|
|
631
615
|
return
|
|
632
616
|
}
|
|
633
|
-
void
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
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="
|
|
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,
|
|
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
|
|
55
|
-
const
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
575
|
+
{
|
|
576
|
+
date: formatShortDate(card.createdAt, locale, readerZone),
|
|
577
|
+
},
|
|
569
578
|
)
|
|
570
579
|
}}
|
|
571
580
|
</p>
|