@antelopejs/dms-marketing 0.4.0 → 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/hooks/index.js +1 -5
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/funnels/page.js +5 -5
- package/dist/pages/funnels/page.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/FunnelReport.vue +1 -1
- 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 -2
- package/frontend-vue/pnpm-lock.yaml +51 -19
- package/frontend-vue/tests/format.test.ts +21 -0
- package/frontend-vue/tests/marketing-reads.test.ts +252 -0
- package/frontend-vue/tests/registration.test.ts +0 -4
- package/frontend-vue/vitest.config.ts +3 -1
- package/package.json +3 -3
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed,
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
3
|
import {
|
|
4
4
|
useDmsRoute,
|
|
5
5
|
useDmsRouter,
|
|
6
6
|
useDmsRuntimeConfig,
|
|
7
7
|
useI18n,
|
|
8
8
|
} from '#dms/frontend-module'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
type MarketingConnectionStatus,
|
|
12
|
-
} from '../composables/useMarketingApi'
|
|
9
|
+
import { useMarketingApi } from '../composables/useMarketingApi'
|
|
10
|
+
import { useWebsiteConnection } from '../composables/useMarketingReads'
|
|
13
11
|
import {
|
|
14
12
|
installLink,
|
|
15
13
|
overviewLink,
|
|
16
14
|
websitesLink,
|
|
17
15
|
} from '../composables/useMarketingRoutes'
|
|
18
|
-
import {
|
|
19
|
-
allowHostConfirmation,
|
|
20
|
-
hostConflictOf,
|
|
21
|
-
} from '../utils/host-conflict'
|
|
16
|
+
import { allowHostConfirmation, hostConflictOf } from '../utils/host-conflict'
|
|
22
17
|
import { trackerSnippet } from '../utils/snippet'
|
|
23
18
|
|
|
24
19
|
/**
|
|
@@ -57,12 +52,20 @@ const fieldErrors = useFieldErrors({
|
|
|
57
52
|
const websiteId = ref(
|
|
58
53
|
typeof route.query.website === 'string' ? route.query.website : '',
|
|
59
54
|
)
|
|
60
|
-
const status = ref<MarketingConnectionStatus | null>(null)
|
|
61
|
-
const failed = ref(false)
|
|
62
|
-
/** The `?website=` names no site of the workspace (deleted, mistyped). */
|
|
63
|
-
const missing = ref(false)
|
|
64
55
|
/** Server time "Check again" waits from; null: since the site's creation. */
|
|
65
56
|
const recheckSince = ref<number | null>(null)
|
|
57
|
+
/** The check's polling period: `POLL_MS` while it waits for a pageview, 0 (stopped) otherwise. */
|
|
58
|
+
const pollInterval = ref(0)
|
|
59
|
+
const connection = useWebsiteConnection(websiteId, recheckSince, {
|
|
60
|
+
refreshInterval: pollInterval,
|
|
61
|
+
})
|
|
62
|
+
const status = connection.data
|
|
63
|
+
/** The `?website=` names no site of the workspace (deleted, mistyped). */
|
|
64
|
+
const missing = computed(
|
|
65
|
+
() => apiErrorStatus(connection.error.value) === HTTP_NOT_FOUND,
|
|
66
|
+
)
|
|
67
|
+
/** The last check failed; the next one is on its way. */
|
|
68
|
+
const failed = computed(() => !!connection.error.value && !missing.value)
|
|
66
69
|
|
|
67
70
|
// --- Step 1 ----------------------------------------------------------------------
|
|
68
71
|
|
|
@@ -95,7 +98,6 @@ async function createWebsite(): Promise<void> {
|
|
|
95
98
|
await api.selectContextWebsite(created._id)
|
|
96
99
|
websiteId.value = created._id
|
|
97
100
|
await router.replace(installLink(created._id))
|
|
98
|
-
await poll()
|
|
99
101
|
} catch (error) {
|
|
100
102
|
const conflict = hostConflictOf(error)
|
|
101
103
|
if (conflict) {
|
|
@@ -115,47 +117,27 @@ async function createWebsite(): Promise<void> {
|
|
|
115
117
|
|
|
116
118
|
// --- Step 3: connection check ---------------------------------------------------
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
try {
|
|
133
|
-
status.value = await api.getConnection(
|
|
134
|
-
websiteId.value,
|
|
135
|
-
recheckSince.value ?? undefined,
|
|
136
|
-
)
|
|
137
|
-
failed.value = false
|
|
138
|
-
} catch (error) {
|
|
139
|
-
if (apiErrorStatus(error) === HTTP_NOT_FOUND) {
|
|
140
|
-
missing.value = true
|
|
141
|
-
status.value = null
|
|
142
|
-
return
|
|
143
|
-
}
|
|
144
|
-
failed.value = true
|
|
145
|
-
}
|
|
146
|
-
if (!status.value?.visit) {
|
|
147
|
-
timer = setTimeout(() => void poll(), POLL_MS)
|
|
148
|
-
}
|
|
149
|
-
}
|
|
120
|
+
/** No pageview yet, and the site is there: the check polls. */
|
|
121
|
+
const isWaiting = computed(
|
|
122
|
+
() => !!websiteId.value && !missing.value && !status.value?.visit,
|
|
123
|
+
)
|
|
124
|
+
// The check polls while it waits, and stops once a pageview arrived or the
|
|
125
|
+
// site is gone.
|
|
126
|
+
watch(
|
|
127
|
+
isWaiting,
|
|
128
|
+
(waiting) => {
|
|
129
|
+
pollInterval.value = waiting ? POLL_MS : 0
|
|
130
|
+
},
|
|
131
|
+
{ immediate: true },
|
|
132
|
+
)
|
|
150
133
|
|
|
151
134
|
/** Waits for a pageview sent from now on, to re-verify an installed tag. */
|
|
152
|
-
|
|
135
|
+
function checkAgain(): void {
|
|
153
136
|
if (!status.value) {
|
|
154
137
|
return
|
|
155
138
|
}
|
|
156
139
|
recheckSince.value = status.value.checkedAt
|
|
157
140
|
status.value = { ...status.value, visit: null }
|
|
158
|
-
await poll()
|
|
159
141
|
}
|
|
160
142
|
|
|
161
143
|
const apiOrigin = ref('')
|
|
@@ -164,9 +146,7 @@ onMounted(() => {
|
|
|
164
146
|
apiOrigin.value = String(
|
|
165
147
|
(config.public as { dms?: { baseURL?: string } }).dms?.baseURL ?? '',
|
|
166
148
|
)
|
|
167
|
-
void poll()
|
|
168
149
|
})
|
|
169
|
-
onBeforeUnmount(stopPolling)
|
|
170
150
|
|
|
171
151
|
watch(
|
|
172
152
|
() => route.query.website,
|
|
@@ -174,10 +154,8 @@ watch(
|
|
|
174
154
|
const next = typeof value === 'string' ? value : ''
|
|
175
155
|
if (next !== websiteId.value) {
|
|
176
156
|
websiteId.value = next
|
|
177
|
-
status.value = null
|
|
178
|
-
missing.value = false
|
|
179
157
|
recheckSince.value = null
|
|
180
|
-
|
|
158
|
+
connection.clear()
|
|
181
159
|
}
|
|
182
160
|
},
|
|
183
161
|
)
|
|
@@ -201,7 +179,8 @@ async function allow(hostname: string): Promise<void> {
|
|
|
201
179
|
color: 'success',
|
|
202
180
|
title: t('page.marketing.install.allowed', { host: hostname }),
|
|
203
181
|
})
|
|
204
|
-
|
|
182
|
+
// The refused host leaves the check before its button comes back.
|
|
183
|
+
await refreshPageBlocks()
|
|
205
184
|
} catch (error) {
|
|
206
185
|
const conflict = hostConflictOf(error)
|
|
207
186
|
if (conflict) {
|
|
@@ -232,7 +211,7 @@ async function resumeTracking(): Promise<void> {
|
|
|
232
211
|
color: 'success',
|
|
233
212
|
title: t('page.marketing.websites.resumed'),
|
|
234
213
|
})
|
|
235
|
-
await
|
|
214
|
+
await refreshPageBlocks()
|
|
236
215
|
} catch (error) {
|
|
237
216
|
fieldErrors.toastError(error, {
|
|
238
217
|
toastTitle: 'page.marketing.websites.save_error',
|
|
@@ -638,7 +617,9 @@ const stepTwoState = computed(() =>
|
|
|
638
617
|
/>
|
|
639
618
|
</span>
|
|
640
619
|
<span class="min-w-0 flex-1">
|
|
641
|
-
<span
|
|
620
|
+
<span
|
|
621
|
+
class="block font-semibold break-words text-highlighted"
|
|
622
|
+
>
|
|
642
623
|
{{
|
|
643
624
|
t(
|
|
644
625
|
recheckSince === null
|
|
@@ -677,7 +658,9 @@ const stepTwoState = computed(() =>
|
|
|
677
658
|
>
|
|
678
659
|
<DmsIconWell icon="i-ph-prohibit" tone="error" />
|
|
679
660
|
<span class="min-w-0 flex-1">
|
|
680
|
-
<span
|
|
661
|
+
<span
|
|
662
|
+
class="block font-semibold break-words text-highlighted"
|
|
663
|
+
>
|
|
681
664
|
{{
|
|
682
665
|
t(
|
|
683
666
|
'page.marketing.install.refused',
|
|
@@ -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
|