@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 CHANGED
@@ -52,6 +52,7 @@ const page_1 = require("@antelopejs/interface-dms/page");
52
52
  const node_cron_1 = __importDefault(require("node-cron"));
53
53
  const config_1 = require("./config");
54
54
  const hooks_2 = require("./hooks");
55
+ const public_endpoints_1 = require("./routes/public-endpoints");
55
56
  const services_1 = require("./services");
56
57
  const constants_1 = require("./types/constants");
57
58
  const HOURLY_CRON = "0 * * * *";
@@ -69,6 +70,7 @@ async function construct(config) {
69
70
  sourcePath: node_path_1.default.join(__dirname, "../frontend-vue"),
70
71
  renderer: { name: "vue", version: "3" },
71
72
  priority: 0,
73
+ publicEndpoints: public_endpoints_1.TRACKER_PUBLIC_ENDPOINTS,
72
74
  });
73
75
  }
74
76
  async function onDatabaseInitialized() {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,8BAqBC;AAiCD,0BAAkC;AAElC,oBAWC;AAhGD,6BAA8C;AAC9C,sBAAoB;AACpB,oBAAkB;AAClB,mBAAiB;AAEjB,0DAA6B;AAC7B,+DAAgE;AAChE,gEAA6D;AAC7D,6FAAqE;AACrE,2DAAqE;AACrE,yDAAmE;AACnE,0DAAqD;AACrD,qCAA8D;AAC9D,mCAAyD;AACzD,yCAKoB;AACpB,iDAAwE;AAExE,MAAM,WAAW,GAAG,WAAW,CAAC;AAEhC,MAAM,kBAAkB,GAAG,EAAE,GAAG,yBAAa,CAAC;AAE9C,IAAI,SAAS,GAAyB,IAAI,CAAC;AAC3C,IAAI,cAAc,GAA0B,IAAI,CAAC;AAE1C,KAAK,UAAU,SAAS,CAC7B,MAAoC;IAEpC,IAAA,kBAAS,EAAC,MAAM,CAAC,CAAC;IAClB,MAAM,IAAA,4BAAiB,GAAE,CAAC;IAC1B,IAAA,sCAA8B,GAAE,CAAC;IACjC,IAAA,oBAAY,EAAC,YAAI,CAAC,oBAAoB,EAAE,qBAAqB,CAAC,CAAC;IAI/D,IAAA,mCAAkB,EAChB,wDAAa,qCAAqC,GAAC,EACnD,wDAAa,iCAAiC,GAAC,CAChD,CAAC;IAEF,MAAM,IAAA,wBAAiB,EAAC;QACtB,IAAI,EAAE,gCAAoB;QAC1B,UAAU,EAAE,mBAAI,CAAC,IAAI,CAAC,SAAS,EAAE,iBAAiB,CAAC;QACnD,QAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE;QACvC,QAAQ,EAAE,CAAC;KACZ,CAAC,CAAC;AACL,CAAC;AAQD,KAAK,UAAU,qBAAqB;IAClC,MAAM,QAAQ,GAAG,IAAA,wCAAQ,EAAC,2BAAsB,CAAC,CAAC;IAClD,MAAM,IAAA,uBAAY,EAAC,QAAQ,CAAC,CAAC;IAI7B,cAAc,KAAK,WAAW,CAAC,GAAG,EAAE;QAClC,KAAK,IAAA,yBAAc,EAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;IACtD,CAAC,EAAE,kBAAkB,CAAC,CAAC;IACvB,cAAc,CAAC,KAAK,EAAE,CAAC;IACvB,SAAS,KAAK,mBAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;QAC5C,KAAK,IAAA,yBAAc,EAAC,QAAQ,CAAC;aAC1B,IAAI,CAAC,GAAG,EAAE,CAAC,IAAA,0BAAe,EAAC,aAAa,CAAC,CAAC;aAC1C,KAAK,CAAC,aAAa,CAAC,CAAC;IAC1B,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,aAAa,CAAC,GAAY;IACjC,iBAAO,CAAC,KAAK,CAAC,yCAAyC,EAAE,GAAG,CAAC,CAAC;AAChE,CAAC;AAED,SAAS,cAAc,CAAC,GAAY;IAClC,iBAAO,CAAC,KAAK,CAAC,yCAAyC,EAAE,GAAG,CAAC,CAAC;AAChE,CAAC;AAED,SAAgB,OAAO,KAAU,CAAC;AAElC,SAAgB,IAAI;IAClB,IAAI,cAAc,EAAE,CAAC;QACnB,aAAa,CAAC,cAAc,CAAC,CAAC;QAC9B,cAAc,GAAG,IAAI,CAAC;IACxB,CAAC;IACD,IAAI,SAAS,EAAE,CAAC;QAGd,KAAK,SAAS,CAAC,IAAI,EAAE,CAAC;QACtB,SAAS,GAAG,IAAI,CAAC;IACnB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,8BAsBC;AAiCD,0BAAkC;AAElC,oBAWC;AAlGD,6BAA8C;AAC9C,sBAAoB;AACpB,oBAAkB;AAClB,mBAAiB;AAEjB,0DAA6B;AAC7B,+DAAgE;AAChE,gEAA6D;AAC7D,6FAAqE;AACrE,2DAAqE;AACrE,yDAAmE;AACnE,0DAAqD;AACrD,qCAA8D;AAC9D,mCAAyD;AACzD,gEAAqE;AACrE,yCAKoB;AACpB,iDAAwE;AAExE,MAAM,WAAW,GAAG,WAAW,CAAC;AAEhC,MAAM,kBAAkB,GAAG,EAAE,GAAG,yBAAa,CAAC;AAE9C,IAAI,SAAS,GAAyB,IAAI,CAAC;AAC3C,IAAI,cAAc,GAA0B,IAAI,CAAC;AAE1C,KAAK,UAAU,SAAS,CAC7B,MAAoC;IAEpC,IAAA,kBAAS,EAAC,MAAM,CAAC,CAAC;IAClB,MAAM,IAAA,4BAAiB,GAAE,CAAC;IAC1B,IAAA,sCAA8B,GAAE,CAAC;IACjC,IAAA,oBAAY,EAAC,YAAI,CAAC,oBAAoB,EAAE,qBAAqB,CAAC,CAAC;IAI/D,IAAA,mCAAkB,EAChB,wDAAa,qCAAqC,GAAC,EACnD,wDAAa,iCAAiC,GAAC,CAChD,CAAC;IAEF,MAAM,IAAA,wBAAiB,EAAC;QACtB,IAAI,EAAE,gCAAoB;QAC1B,UAAU,EAAE,mBAAI,CAAC,IAAI,CAAC,SAAS,EAAE,iBAAiB,CAAC;QACnD,QAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE;QACvC,QAAQ,EAAE,CAAC;QACX,eAAe,EAAE,2CAAwB;KAC1C,CAAC,CAAC;AACL,CAAC;AAQD,KAAK,UAAU,qBAAqB;IAClC,MAAM,QAAQ,GAAG,IAAA,wCAAQ,EAAC,2BAAsB,CAAC,CAAC;IAClD,MAAM,IAAA,uBAAY,EAAC,QAAQ,CAAC,CAAC;IAI7B,cAAc,KAAK,WAAW,CAAC,GAAG,EAAE;QAClC,KAAK,IAAA,yBAAc,EAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;IACtD,CAAC,EAAE,kBAAkB,CAAC,CAAC;IACvB,cAAc,CAAC,KAAK,EAAE,CAAC;IACvB,SAAS,KAAK,mBAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;QAC5C,KAAK,IAAA,yBAAc,EAAC,QAAQ,CAAC;aAC1B,IAAI,CAAC,GAAG,EAAE,CAAC,IAAA,0BAAe,EAAC,aAAa,CAAC,CAAC;aAC1C,KAAK,CAAC,aAAa,CAAC,CAAC;IAC1B,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,aAAa,CAAC,GAAY;IACjC,iBAAO,CAAC,KAAK,CAAC,yCAAyC,EAAE,GAAG,CAAC,CAAC;AAChE,CAAC;AAED,SAAS,cAAc,CAAC,GAAY;IAClC,iBAAO,CAAC,KAAK,CAAC,yCAAyC,EAAE,GAAG,CAAC,CAAC;AAChE,CAAC;AAED,SAAgB,OAAO,KAAU,CAAC;AAElC,SAAgB,IAAI;IAClB,IAAI,cAAc,EAAE,CAAC;QACnB,aAAa,CAAC,cAAc,CAAC,CAAC;QAC9B,cAAc,GAAG,IAAI,CAAC;IACxB,CAAC;IACD,IAAI,SAAS,EAAE,CAAC;QAGd,KAAK,SAAS,CAAC,IAAI,EAAE,CAAC;QACtB,SAAS,GAAG,IAAI,CAAC;IACnB,CAAC;AACH,CAAC"}
@@ -0,0 +1,2 @@
1
+ import type { FrontendPublicEndpoint } from "@antelopejs/interface-dms/page";
2
+ export declare const TRACKER_PUBLIC_ENDPOINTS: FrontendPublicEndpoint[];
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TRACKER_PUBLIC_ENDPOINTS = void 0;
4
+ const constants_1 = require("@/types/constants");
5
+ const SCRIPT_METHODS = ["GET", "HEAD"];
6
+ const BEACON_METHODS = ["POST"];
7
+ exports.TRACKER_PUBLIC_ENDPOINTS = [
8
+ { path: `${constants_1.API_BASE_PATH}/tracker.js`, methods: SCRIPT_METHODS },
9
+ { path: `${constants_1.API_BASE_PATH}/experiments.js`, methods: SCRIPT_METHODS },
10
+ { path: `${constants_1.API_BASE_PATH}/snapshot.js`, methods: SCRIPT_METHODS },
11
+ { path: `${constants_1.API_BASE_PATH}/collect`, methods: BEACON_METHODS },
12
+ { path: `${constants_1.API_BASE_PATH}/snapshot`, methods: BEACON_METHODS },
13
+ ];
14
+ //# sourceMappingURL=public-endpoints.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-endpoints.js","sourceRoot":"","sources":["../../src/routes/public-endpoints.ts"],"names":[],"mappings":";;;AACA,iDAAkD;AAElD,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AACvC,MAAM,cAAc,GAAG,CAAC,MAAM,CAAC,CAAC;AAQnB,QAAA,wBAAwB,GAA6B;IAChE,EAAE,IAAI,EAAE,GAAG,yBAAa,aAAa,EAAE,OAAO,EAAE,cAAc,EAAE;IAChE,EAAE,IAAI,EAAE,GAAG,yBAAa,iBAAiB,EAAE,OAAO,EAAE,cAAc,EAAE;IACpE,EAAE,IAAI,EAAE,GAAG,yBAAa,cAAc,EAAE,OAAO,EAAE,cAAc,EAAE;IACjE,EAAE,IAAI,EAAE,GAAG,yBAAa,UAAU,EAAE,OAAO,EAAE,cAAc,EAAE;IAC7D,EAAE,IAAI,EAAE,GAAG,yBAAa,WAAW,EAAE,OAAO,EAAE,cAAc,EAAE;CAC/D,CAAC"}
@@ -4,11 +4,12 @@ import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
4
  import {
5
5
  MARKETING_CONTEXT_KEY,
6
6
  type ContextWebsite,
7
- type MarketingContextPayload,
8
7
  } from '../composables/useMarketingContext'
9
8
  import { useMarketingApi } from '../composables/useMarketingApi'
9
+ import { useMarketingContextRead } from '../composables/useMarketingReads'
10
+ import { useMarketingNow } from '../composables/useMarketingNow'
10
11
  import { installLink } from '../composables/useMarketingRoutes'
11
- import { formatRelativeTime, initials } from '../utils/format'
12
+ import { formatRelativeTime, initials, utcDayStart } from '../utils/format'
12
13
  import { MAX_PERIOD_DAYS, MS_PER_DAY, PERIOD_SCOPE } from '../constants'
13
14
 
14
15
  /**
@@ -30,7 +31,9 @@ import { MAX_PERIOD_DAYS, MS_PER_DAY, PERIOD_SCOPE } from '../constants'
30
31
  * Its children render once the URL names one of the tenant's websites; with
31
32
  * no website at all the first-run hero takes their place, and with
32
33
  * `waitingGuide` a website still waiting for its first event shows the way
33
- * to its install guide instead.
34
+ * to its install guide instead. A server render reads the context first: a
35
+ * URL naming no website (or one the tenant does not have) is redirected to
36
+ * the one saved, and the page renders on it.
34
37
  */
35
38
  const props = withDefaults(
36
39
  defineProps<{
@@ -88,11 +91,22 @@ const comparisonLabels = computed(() =>
88
91
  ),
89
92
  )
90
93
 
91
- const context = ref<MarketingContextPayload | null>(null)
92
- const loading = ref(true)
93
- const failed = ref(false)
94
+ // The minute's activity of every website: read on the bar's own clock.
95
+ const contextRead = useMarketingContextRead({
96
+ refreshInterval: LIVE_REFRESH_MS,
97
+ })
98
+ const context = contextRead.data
99
+ /**
100
+ * The context could not be read, and none is shown: a failed re-read (the
101
+ * minute's activity, a retry) keeps the bar as it is.
102
+ */
103
+ const failed = computed(
104
+ () =>
105
+ !!contextRead.error.value &&
106
+ contextRead.status.value !== 'pending' &&
107
+ !context.value,
108
+ )
94
109
  const switching = ref(false)
95
- const refreshToken = ref(0)
96
110
 
97
111
  const websites = computed(() => context.value?.websites ?? [])
98
112
  /** The URL's website, while it is one of the tenant's. */
@@ -109,26 +123,8 @@ const selected = computed<ContextWebsite | null>(
109
123
  () => websites.value.find((site) => site.id === selectedId.value) ?? null,
110
124
  )
111
125
 
112
- async function reload(): Promise<void> {
113
- loading.value = true
114
- failed.value = false
115
- try {
116
- context.value = await api.getContext()
117
- } catch {
118
- failed.value = true
119
- } finally {
120
- loading.value = false
121
- }
122
- }
123
-
124
- /** The websites' last events again, quietly: a failure keeps the bar as is. */
125
- async function refreshActivity(): Promise<void> {
126
- try {
127
- context.value = await api.getContext()
128
- } catch {
129
- // The next refresh tries again.
130
- }
131
- }
126
+ /** The websites and their last events again; a failure keeps the bar as is. */
127
+ const reload = (): Promise<void> => contextRead.refresh()
132
128
 
133
129
  async function select(id: string): Promise<void> {
134
130
  if (id === selectedId.value) {
@@ -153,8 +149,10 @@ async function select(id: string): Promise<void> {
153
149
  }
154
150
 
155
151
  // The URL names no website, or one the tenant does not have: fill in the
156
- // saved one, without a history entry. Watched with the query too, so a block
157
- // rewriting the URL for its own state (the heatmap path) keeps it.
152
+ // saved one, without a history entry (a server render redirects to it).
153
+ // Watched with the query too, so a block rewriting the URL for its own state
154
+ // (the heatmap path) keeps it. Set up once the context is read.
155
+ await contextRead
158
156
  watch(
159
157
  [selectedId, () => route.query],
160
158
  ([id]) => {
@@ -165,8 +163,6 @@ watch(
165
163
  { immediate: true },
166
164
  )
167
165
 
168
- watch(refreshToken, () => refreshPageBlocks())
169
-
170
166
  // --- Period --------------------------------------------------------------------
171
167
 
172
168
  const period = usePeriodScope(props.periodScope)
@@ -190,52 +186,40 @@ const cutHint = computed(() => {
190
186
  provide(MARKETING_CONTEXT_KEY, {
191
187
  websites,
192
188
  selected,
193
- refreshToken,
194
189
  select,
195
190
  reload,
196
191
  })
197
192
 
198
- onMounted(reload)
199
-
200
193
  // --- Clock -------------------------------------------------------------------
201
194
  // A period reaching today follows the clock: its blocks read again every
202
195
  // minute, through the page's live gate. The selector itself moves a day
203
196
  // preset on at midnight.
204
197
 
205
- const now = ref(Date.now())
198
+ const now = useMarketingNow(CLOCK_TICK_MS)
206
199
 
207
200
  /** The period covers the present: its counts are still moving. */
208
201
  const reachesToday = computed(
209
202
  () => !!range.value && range.value.to.getTime() >= now.value,
210
203
  )
211
204
 
212
- /** Refresh: every block reads again. */
205
+ /** Refresh: every block reads again, and the bar. */
213
206
  function refresh(): void {
214
- refreshToken.value++
215
- void refreshActivity()
216
- }
217
-
218
- function tick(): void {
219
- now.value = Date.now()
207
+ refreshPageBlocks()
208
+ void reload()
220
209
  }
221
210
 
211
+ /** A period reaching today: its blocks read again, through the live gate. */
222
212
  function liveRefresh(): void {
223
213
  if (reachesToday.value && linkedId.value) {
224
214
  refreshLivePageBlocks()
225
215
  }
226
- void refreshActivity()
227
216
  }
228
217
 
229
- let clock: ReturnType<typeof setInterval> | null = null
230
218
  let live: ReturnType<typeof setInterval> | null = null
231
219
  onMounted(() => {
232
- clock = setInterval(tick, CLOCK_TICK_MS)
233
220
  live = setInterval(liveRefresh, LIVE_REFRESH_MS)
234
221
  })
235
222
  onBeforeUnmount(() => {
236
- if (clock) {
237
- clearInterval(clock)
238
- }
239
223
  if (live) {
240
224
  clearInterval(live)
241
225
  }
@@ -298,18 +282,10 @@ const websiteItems = computed(() => [
298
282
  * UTC's, the bar says when their days really start.
299
283
  */
300
284
  const utcHint = computed(() => {
301
- const today = new Date(now.value)
302
- if (today.getTimezoneOffset() === 0) {
303
- return ''
304
- }
305
- const utcMidnight = new Date(
306
- Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
307
- )
308
- const time = new Intl.DateTimeFormat(locale.value, {
309
- hour: '2-digit',
310
- minute: '2-digit',
311
- }).format(utcMidnight)
312
- return t('page.marketing.context.utc_days', { time })
285
+ // The reader's zone: the browser's, which a server render reads from its
286
+ // cookie.
287
+ const time = utcDayStart(now.value, locale.value, readerTimeZone())
288
+ return time ? t('page.marketing.context.utc_days', { time }) : ''
313
289
  })
314
290
  </script>
315
291
 
@@ -320,7 +296,7 @@ const utcHint = computed(() => {
320
296
  role="toolbar"
321
297
  :aria-label="t('page.marketing.context.label')"
322
298
  >
323
- <USkeleton v-if="loading && !context" class="h-7 w-72" />
299
+ <USkeleton v-if="!context && !failed" class="h-7 w-72" />
324
300
  <template v-else-if="selected">
325
301
  <UDropdownMenu :items="websiteItems" :content="{ align: 'start' }">
326
302
  <UButton
@@ -11,6 +11,11 @@ import {
11
11
  type MarketingFunnelStep,
12
12
  type MarketingFunnelSuggestions,
13
13
  } from '../composables/useMarketingApi'
14
+ import {
15
+ useContextOnDemand,
16
+ useFunnelRead,
17
+ useFunnelSuggestions,
18
+ } from '../composables/useMarketingReads'
14
19
  import type { ContextWebsite } from '../composables/useMarketingContext'
15
20
  import {
16
21
  funnelBuilderLink,
@@ -199,9 +204,13 @@ function clearVariationError(): void {
199
204
 
200
205
  // --- Loading -------------------------------------------------------------------
201
206
 
207
+ // The definition edited, or the one a Duplicate copies.
208
+ const readDefinition = useFunnelRead(funnelId || sourceId)
209
+ const readContext = useContextOnDemand()
210
+
202
211
  /** A stored definition as the form holds it. */
203
- async function fetchDefinition(id: string) {
204
- const row = await api.getFunnel(id)
212
+ async function fetchDefinition() {
213
+ const row = await readDefinition()
205
214
  const experiment = parseExperimentValue(row.experiment)
206
215
  const draft: FunnelDraftValues = {
207
216
  name: row.name,
@@ -230,7 +239,7 @@ function objectiveOf(draft: FunnelDraftValues): string {
230
239
  }
231
240
 
232
241
  async function loadExisting(): Promise<void> {
233
- const { draft, experiment } = await fetchDefinition(funnelId)
242
+ const { draft, experiment } = await fetchDefinition()
234
243
  applyValues(draft)
235
244
  original.value = experiment
236
245
  savedObjective.value = objectiveOf(draft)
@@ -242,7 +251,7 @@ const copied = ref<FunnelDraftValues | null>(null)
242
251
  /** Duplicate: the copy is a new draft, unsaved until the user saves it — its
243
252
  * experiment key must change, keys being unique per site. */
244
253
  async function loadCopy(): Promise<void> {
245
- const { draft } = await fetchDefinition(sourceId)
254
+ const { draft } = await fetchDefinition()
246
255
  applyValues({
247
256
  ...draft,
248
257
  name: t('page.marketing.builder.copy_name', { name: draft.name }).slice(
@@ -260,7 +269,7 @@ async function loadCopy(): Promise<void> {
260
269
  const websites = ref<ContextWebsite[]>([])
261
270
 
262
271
  async function loadWebsites(): Promise<void> {
263
- const payload = await api.getContext()
272
+ const payload = await readContext()
264
273
  websites.value = payload.websites
265
274
  const linked = queryParam('website')
266
275
  websiteId.value ||=
@@ -301,21 +310,15 @@ onMounted(load)
301
310
 
302
311
  // --- Suggestions and preview -------------------------------------------------
303
312
 
304
- const suggestions = ref<MarketingFunnelSuggestions>({ pages: [], events: [] })
313
+ const NO_SUGGESTIONS: MarketingFunnelSuggestions = { pages: [], events: [] }
305
314
 
306
- async function loadSuggestions(): Promise<void> {
307
- if (!websiteId.value) {
308
- return
309
- }
310
- try {
311
- suggestions.value = await api.getFunnelSuggestions(websiteId.value)
312
- } catch {
313
- suggestions.value = { pages: [], events: [] }
314
- }
315
- }
316
-
317
- // Fires once the site is known (the context, or the funnel being edited).
318
- watch(websiteId, () => void loadSuggestions())
315
+ // Read once the site is known (the context, or the funnel being edited).
316
+ const suggestionsRead = useFunnelSuggestions(websiteId)
317
+ const suggestions = computed(() =>
318
+ suggestionsRead.error.value
319
+ ? NO_SUGGESTIONS
320
+ : (suggestionsRead.data.value ?? NO_SUGGESTIONS),
321
+ )
319
322
 
320
323
  const focusedStep = ref<number | null>(null)
321
324
  /** The step whose list Escape closed, until it is typed in again. */
@@ -1,12 +1,9 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
2
+ import { computed } from 'vue'
3
3
  import { useDmsRouter, useI18n } from '#dms/frontend-module'
4
4
  import { useMarketingContext } from '../composables/useMarketingContext'
5
- import {
6
- useMarketingApi,
7
- type MarketingFunnelStep,
8
- type MarketingFunnelTemplate,
9
- } from '../composables/useMarketingApi'
5
+ import type { MarketingFunnelStep } from '../composables/useMarketingApi'
6
+ import { useFunnelTemplates } from '../composables/useMarketingReads'
10
7
  import {
11
8
  funnelBuilderLink,
12
9
  installLink,
@@ -31,31 +28,23 @@ void props
31
28
  const { t, locale } = useI18n()
32
29
  const inContext = useContextLink()
33
30
  const router = useDmsRouter()
34
- const api = useMarketingApi()
35
31
  const context = useMarketingContext()
36
32
  const period = usePeriodScope(PERIOD_SCOPE)
37
33
 
38
- const templates = ref<MarketingFunnelTemplate[] | null>(null)
39
-
40
- watch(
41
- [
42
- () => context.selected.value?.id,
43
- () => period.value?.range?.from.getTime(),
44
- () => period.value?.range?.to.getTime(),
45
- ],
46
- async ([website]) => {
47
- if (!website) {
48
- return
49
- }
50
- templates.value = null
51
- try {
52
- templates.value = await api.getFunnelTemplates(website, period.value)
53
- } catch {
54
- templates.value = []
55
- }
56
- },
57
- { immediate: true },
34
+ const templatesRead = useFunnelTemplates(
35
+ () => context.selected.value?.id,
36
+ period,
58
37
  )
38
+ /**
39
+ * The site's templates; `null` while they load (the previous site's or
40
+ * period's included), none once the read failed.
41
+ */
42
+ const templates = computed(() => {
43
+ if (templatesRead.error.value && templatesRead.status.value !== 'pending') {
44
+ return []
45
+ }
46
+ return templatesRead.placeholder.value ? null : templatesRead.data.value
47
+ })
59
48
 
60
49
  /** The site has sent nothing yet: templates cannot come before the tracker. */
61
50
  const waiting = computed(() => context.selected.value?.state === 'waiting')
@@ -1,24 +1,19 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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
- useMarketingApi,
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
- let timer: ReturnType<typeof setTimeout> | null = null
119
-
120
- function stopPolling(): void {
121
- if (timer) {
122
- clearTimeout(timer)
123
- timer = null
124
- }
125
- }
126
-
127
- async function poll(): Promise<void> {
128
- stopPolling()
129
- if (!websiteId.value) {
130
- return
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
- async function checkAgain(): Promise<void> {
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
- void poll()
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
- await poll()
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 poll()
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 class="block font-semibold break-words text-highlighted">
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 class="block font-semibold break-words text-highlighted">
661
+ <span
662
+ class="block font-semibold break-words text-highlighted"
663
+ >
681
664
  {{
682
665
  t(
683
666
  'page.marketing.install.refused',