@antelopejs/dms-marketing 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +2 -2
  2. package/dist/config.d.ts +3 -0
  3. package/dist/config.js +11 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/data-api/websites.d.ts +10 -4
  6. package/dist/data-api/websites.js +79 -3
  7. package/dist/data-api/websites.js.map +1 -1
  8. package/dist/db/models/funnels.model.d.ts +0 -1
  9. package/dist/db/models/funnels.model.js +0 -18
  10. package/dist/db/models/funnels.model.js.map +1 -1
  11. package/dist/db/models/page_snapshots.model.d.ts +2 -1
  12. package/dist/db/models/page_snapshots.model.js +6 -14
  13. package/dist/db/models/page_snapshots.model.js.map +1 -1
  14. package/dist/db/models/websites.model.d.ts +1 -1
  15. package/dist/db/models/websites.model.js +9 -29
  16. package/dist/db/models/websites.model.js.map +1 -1
  17. package/dist/db/tables/funnels.table.d.ts +0 -1
  18. package/dist/db/tables/funnels.table.js +0 -4
  19. package/dist/db/tables/funnels.table.js.map +1 -1
  20. package/dist/db/tables/page_snapshots.table.d.ts +1 -1
  21. package/dist/db/tables/page_snapshots.table.js +4 -4
  22. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  23. package/dist/db/tables/websites.table.d.ts +2 -1
  24. package/dist/db/tables/websites.table.js +4 -4
  25. package/dist/db/tables/websites.table.js.map +1 -1
  26. package/dist/frontend-module.d.ts +6 -0
  27. package/dist/frontend-module.js +46 -0
  28. package/dist/frontend-module.js.map +1 -0
  29. package/dist/implementations/dms-marketing/index.js +0 -5
  30. package/dist/implementations/dms-marketing/index.js.map +1 -1
  31. package/dist/index.js +3 -13
  32. package/dist/index.js.map +1 -1
  33. package/dist/pages/acquisition/page.d.ts +1 -0
  34. package/dist/pages/acquisition/page.js +9 -4
  35. package/dist/pages/acquisition/page.js.map +1 -1
  36. package/dist/pages/blocks.d.ts +11 -2
  37. package/dist/pages/blocks.js +43 -5
  38. package/dist/pages/blocks.js.map +1 -1
  39. package/dist/pages/funnel/page.d.ts +1 -0
  40. package/dist/pages/funnel/page.js +1 -0
  41. package/dist/pages/funnel/page.js.map +1 -1
  42. package/dist/pages/funnels/page.d.ts +1 -0
  43. package/dist/pages/funnels/page.js +4 -2
  44. package/dist/pages/funnels/page.js.map +1 -1
  45. package/dist/pages/overview/page.d.ts +1 -0
  46. package/dist/pages/overview/page.js +5 -6
  47. package/dist/pages/overview/page.js.map +1 -1
  48. package/dist/pages/pages/page.d.ts +1 -0
  49. package/dist/pages/pages/page.js +1 -0
  50. package/dist/pages/pages/page.js.map +1 -1
  51. package/dist/pages/settings/form.js +1 -0
  52. package/dist/pages/settings/form.js.map +1 -1
  53. package/dist/pages/websites/page.d.ts +2 -3
  54. package/dist/pages/websites/page.js +63 -5
  55. package/dist/pages/websites/page.js.map +1 -1
  56. package/dist/routes/dashboard-tracking.js +5 -1
  57. package/dist/routes/dashboard-tracking.js.map +1 -1
  58. package/dist/routes/funnels.d.ts +2 -0
  59. package/dist/routes/funnels.js +6 -5
  60. package/dist/routes/funnels.js.map +1 -1
  61. package/dist/routes/public-endpoints.js +1 -1
  62. package/dist/routes/public-endpoints.js.map +1 -1
  63. package/dist/routes/snapshot.d.ts +1 -1
  64. package/dist/routes/snapshot.js +30 -13
  65. package/dist/routes/snapshot.js.map +1 -1
  66. package/dist/routes/stats.d.ts +10 -4
  67. package/dist/routes/stats.js +77 -23
  68. package/dist/routes/stats.js.map +1 -1
  69. package/dist/routes/websites.d.ts +5 -1
  70. package/dist/routes/websites.js +31 -13
  71. package/dist/routes/websites.js.map +1 -1
  72. package/dist/services/acquisition-report.d.ts +1 -0
  73. package/dist/services/acquisition-report.js +1 -0
  74. package/dist/services/acquisition-report.js.map +1 -1
  75. package/dist/services/collect.js +7 -2
  76. package/dist/services/collect.js.map +1 -1
  77. package/dist/services/dashboard-tracking.d.ts +7 -14
  78. package/dist/services/dashboard-tracking.js +42 -26
  79. package/dist/services/dashboard-tracking.js.map +1 -1
  80. package/dist/services/declarations.d.ts +0 -2
  81. package/dist/services/declarations.js +0 -11
  82. package/dist/services/declarations.js.map +1 -1
  83. package/dist/services/declared-funnel.js +11 -99
  84. package/dist/services/declared-funnel.js.map +1 -1
  85. package/dist/services/declared-snapshot.js +8 -37
  86. package/dist/services/declared-snapshot.js.map +1 -1
  87. package/dist/services/declared-website.js +5 -46
  88. package/dist/services/declared-website.js.map +1 -1
  89. package/dist/services/funnels-table.d.ts +3 -2
  90. package/dist/services/funnels-table.js +9 -9
  91. package/dist/services/funnels-table.js.map +1 -1
  92. package/dist/services/heatmap.d.ts +6 -0
  93. package/dist/services/heatmap.js +82 -17
  94. package/dist/services/heatmap.js.map +1 -1
  95. package/dist/services/index.d.ts +1 -0
  96. package/dist/services/index.js +1 -0
  97. package/dist/services/index.js.map +1 -1
  98. package/dist/services/prune.js +6 -1
  99. package/dist/services/prune.js.map +1 -1
  100. package/dist/services/realtime.d.ts +3 -0
  101. package/dist/services/realtime.js +49 -0
  102. package/dist/services/realtime.js.map +1 -0
  103. package/dist/services/request-body.d.ts +1 -0
  104. package/dist/services/request-body.js +18 -0
  105. package/dist/services/request-body.js.map +1 -1
  106. package/dist/services/sessionize.d.ts +1 -0
  107. package/dist/services/sessionize.js +9 -0
  108. package/dist/services/sessionize.js.map +1 -1
  109. package/dist/services/snapshots.d.ts +29 -1
  110. package/dist/services/snapshots.js +151 -26
  111. package/dist/services/snapshots.js.map +1 -1
  112. package/dist/services/tenant-website.d.ts +11 -0
  113. package/dist/services/tenant-website.js +27 -5
  114. package/dist/services/tenant-website.js.map +1 -1
  115. package/dist/services/website-report.d.ts +5 -0
  116. package/dist/services/website-report.js +13 -1
  117. package/dist/services/website-report.js.map +1 -1
  118. package/dist/services/workspace-scope.d.ts +31 -0
  119. package/dist/services/workspace-scope.js +72 -0
  120. package/dist/services/workspace-scope.js.map +1 -0
  121. package/dist/types/constants.d.ts +7 -0
  122. package/dist/types/constants.js +8 -1
  123. package/dist/types/constants.js.map +1 -1
  124. package/dist/types/events.d.ts +25 -25
  125. package/dist/types/snapshots.d.ts +29 -29
  126. package/dist/types/snapshots.js +1 -1
  127. package/dist/types/snapshots.js.map +1 -1
  128. package/dist/utils/funnel-steps-type.d.ts +13 -13
  129. package/frontend-vue/app/components/ChannelsCard.vue +19 -2
  130. package/frontend-vue/app/components/Context.vue +50 -376
  131. package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
  132. package/frontend-vue/app/components/FunnelFigure.vue +15 -2
  133. package/frontend-vue/app/components/FunnelReport.vue +3 -0
  134. package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
  135. package/frontend-vue/app/components/InstallGuide.vue +1 -1
  136. package/frontend-vue/app/components/PagePreview.vue +330 -74
  137. package/frontend-vue/app/components/PagesExplorer.vue +230 -85
  138. package/frontend-vue/app/components/TopListTabs.vue +51 -18
  139. package/frontend-vue/app/components/WebsiteCard.vue +394 -0
  140. package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
  141. package/frontend-vue/app/components/WebsitePicker.vue +210 -0
  142. package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
  143. package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
  144. package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
  145. package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
  146. package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
  147. package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
  148. package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
  149. package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
  150. package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
  151. package/frontend-vue/app/utils/anchor-drift.ts +58 -0
  152. package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
  153. package/frontend-vue/app/utils/fill-height.ts +141 -0
  154. package/frontend-vue/app/utils/format.ts +21 -2
  155. package/frontend-vue/app/utils/funnel-draft.ts +22 -0
  156. package/frontend-vue/app/utils/heatmap.ts +17 -0
  157. package/frontend-vue/app/utils/page-snapshot.ts +75 -0
  158. package/frontend-vue/app/utils/snapshot-options.ts +148 -0
  159. package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
  160. package/frontend-vue/app/utils/website-card.ts +93 -0
  161. package/frontend-vue/app/utils/website-purge.ts +95 -0
  162. package/frontend-vue/app/utils/website-settings.ts +121 -0
  163. package/frontend-vue/app/utils/website-state.ts +93 -0
  164. package/frontend-vue/app/utils/workspace-scope.ts +51 -0
  165. package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
  166. package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
  167. package/frontend-vue/package.json +1 -1
  168. package/frontend-vue/tests/anchor-drift.test.ts +59 -0
  169. package/frontend-vue/tests/build-flags.test.ts +38 -0
  170. package/frontend-vue/tests/context-bar.test.ts +226 -0
  171. package/frontend-vue/tests/copy-draft.test.ts +64 -0
  172. package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
  173. package/frontend-vue/tests/fill-height.test.ts +110 -0
  174. package/frontend-vue/tests/format.test.ts +14 -0
  175. package/frontend-vue/tests/funnel-draft.test.ts +37 -0
  176. package/frontend-vue/tests/heatmap.test.ts +25 -0
  177. package/frontend-vue/tests/install-guide.test.ts +25 -0
  178. package/frontend-vue/tests/marketing-reads.test.ts +20 -6
  179. package/frontend-vue/tests/page-snapshot.test.ts +62 -0
  180. package/frontend-vue/tests/record-crumb.test.ts +43 -0
  181. package/frontend-vue/tests/registration.test.ts +4 -1
  182. package/frontend-vue/tests/routes.test.ts +21 -0
  183. package/frontend-vue/tests/save-funnel.test.ts +47 -0
  184. package/frontend-vue/tests/server-parity.test.ts +3 -0
  185. package/frontend-vue/tests/snapshot-options.test.ts +148 -0
  186. package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
  187. package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
  188. package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
  189. package/frontend-vue/tests/website-card.test.ts +119 -0
  190. package/frontend-vue/tests/website-purge.test.ts +70 -0
  191. package/frontend-vue/tests/website-settings.test.ts +133 -0
  192. package/frontend-vue/tests/website-state.test.ts +96 -0
  193. package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
  194. package/frontend-vue/tests/workspace-scope.test.ts +55 -0
  195. package/package.json +8 -7
  196. package/skills/dms-marketing/SKILL.md +54 -12
  197. package/static/snapshot.js +539 -48
  198. package/static/tracker.js +154 -5
  199. package/dist/services/readiness.d.ts +0 -2
  200. package/dist/services/readiness.js +0 -15
  201. package/dist/services/readiness.js.map +0 -1
  202. package/frontend-vue/app/components/WebsitesGrid.vue +0 -812
@@ -0,0 +1,539 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, ref } from 'vue'
3
+ import { useDmsRoute, useDmsRouter, useI18n } from '#dms/frontend-module'
4
+ import {
5
+ useMarketingApi,
6
+ type MarketingPurgeScope,
7
+ } from '../composables/useMarketingApi'
8
+ import {
9
+ websiteSettingOf,
10
+ type WebsiteSetting,
11
+ } from '../composables/useMarketingRoutes'
12
+ import { useWebsiteDeletionImpactRead } from '../composables/useMarketingReads'
13
+ import { formatNumber, formatShortDate, initials } from '../utils/format'
14
+ import { hostConflictOf } from '../utils/host-conflict'
15
+ import { snapshotDiscardConfirmation } from '../utils/snapshot-options'
16
+ import { websiteCardOf, type WebsiteRow } from '../utils/website-card'
17
+ import { websitePurgeImpact, websitePurgeResult } from '../utils/website-purge'
18
+ import {
19
+ blankRequiredFields,
20
+ draftAsksBeforeSaving,
21
+ draftDiscardsSnapshots,
22
+ isWebsiteDraftDirty,
23
+ takeWebsiteSetting,
24
+ websiteDraftOf,
25
+ websitePatchOf,
26
+ type WebsiteSettingsTab,
27
+ } from '../utils/website-settings'
28
+ import {
29
+ MAX_WEBSITE_DOMAIN_LENGTH,
30
+ MAX_WEBSITE_NAME_LENGTH,
31
+ } from '../constants'
32
+
33
+ /**
34
+ * The settings of one website, in the modal the Websites table opens on a
35
+ * row (a click on its card, its pencil, `?record=<id>`): General (name,
36
+ * hosts, tracking and page snapshots) and Danger zone tabs: purge the
37
+ * heatmaps or all the data, delete the site (never the dashboard's own,
38
+ * which the backend refuses too). Opened on a setting (the snapshot pill,
39
+ * `&setting=snapshots`), it scrolls to it. Saving, purging or deleting
40
+ * closes it and reads the table again.
41
+ */
42
+ const props = withDefaults(
43
+ defineProps<{
44
+ rowData?: WebsiteRow
45
+ containerId?: string
46
+ onSuccessCallback?: () => void
47
+ }>(),
48
+ { rowData: undefined, containerId: undefined, onSuccessCallback: undefined },
49
+ )
50
+
51
+ const emit = defineEmits<{ success: [] }>()
52
+
53
+ const { t, locale } = useI18n()
54
+ const route = useDmsRoute()
55
+ const router = useDmsRouter()
56
+ const api = useMarketingApi()
57
+ const toast = useToast()
58
+ const { confirm } = useConfirm()
59
+ // A field-tied error from the save shows under its field, the rest as a toast.
60
+ const fieldErrors = useFieldErrors({
61
+ fields: {
62
+ name: 'website-name',
63
+ domain: 'website-domain',
64
+ extraDomains: 'website-hosts',
65
+ },
66
+ })
67
+
68
+ const card = computed(() => websiteCardOf(props.rowData ?? {}))
69
+ const tab = ref<WebsiteSettingsTab>('general')
70
+ const draft = ref(websiteDraftOf(card.value))
71
+ const saving = ref(false)
72
+
73
+ /** Saved or deleted: nothing is left to lose, the guard steps aside. */
74
+ const settled = ref(false)
75
+ // Closing with changes not saved (X, Escape, Cancel, leaving) asks first.
76
+ const { confirmLeave } = useUnsavedChanges({
77
+ dirty: () => !settled.value && isWebsiteDraftDirty(card.value, draft.value),
78
+ containerId: () => props.containerId,
79
+ })
80
+
81
+ async function cancel(): Promise<void> {
82
+ if (await confirmLeave()) {
83
+ settled.value = true
84
+ emit('success')
85
+ }
86
+ }
87
+
88
+ /** Control of each setting a link can scroll the modal to. */
89
+ const SETTING_CONTROL_IDS: Record<WebsiteSetting, string> = {
90
+ snapshots: 'website-snapshots',
91
+ }
92
+
93
+ /** Waits for the modal to render the row, then brings it into view. */
94
+ async function scrollToSetting(setting: WebsiteSetting): Promise<void> {
95
+ await nextTick()
96
+ requestAnimationFrame(() => {
97
+ document
98
+ .getElementById(SETTING_CONTROL_IDS[setting])
99
+ ?.scrollIntoView({ block: 'center', behavior: 'smooth' })
100
+ })
101
+ }
102
+
103
+ // Asked by the card (its snapshot pill) or by the link that opened the
104
+ // modal; the link's key then leaves the URL, so a reload opens the modal
105
+ // without scrolling it again. `record` stays: the table owns it.
106
+ onMounted(() => {
107
+ const fromLink = websiteSettingOf(route.query)
108
+ const setting = takeWebsiteSetting(card.value.id) ?? fromLink
109
+ if (setting) {
110
+ void scrollToSetting(setting)
111
+ }
112
+ if (fromLink) {
113
+ const { setting: _setting, ...rest } = route.query
114
+ void router.replace({ query: rest })
115
+ }
116
+ })
117
+
118
+ const tabItems = computed(() => [
119
+ { value: 'general', label: t('page.marketing.websites.modal.general') },
120
+ { value: 'danger', label: t('page.marketing.websites.modal.danger') },
121
+ ])
122
+
123
+ /** Turning snapshots off, or changing the mask, discards the captures. */
124
+ const discardsSnapshots = computed(() =>
125
+ draftDiscardsSnapshots(card.value, draft.value),
126
+ )
127
+
128
+ /** Empty required fields say so under themselves, on the General tab. */
129
+ async function requireFields(): Promise<boolean> {
130
+ const blank = blankRequiredFields(draft.value)
131
+ if (blank.length > 0) {
132
+ tab.value = 'general'
133
+ }
134
+ for (const field of blank) {
135
+ await fieldErrors.setError(field, t('dms.field_errors.required'))
136
+ }
137
+ return blank.length === 0
138
+ }
139
+
140
+ /**
141
+ * The banner warns; saving asks. Declining puts the snapshot switches back
142
+ * and saves nothing, so the modal stays open on the rest of the draft.
143
+ */
144
+ async function confirmSnapshotDiscard(): Promise<boolean> {
145
+ if (!draftAsksBeforeSaving(card.value, draft.value)) {
146
+ return true
147
+ }
148
+ if (await confirm(snapshotDiscardConfirmation(t, card.value.snapshots))) {
149
+ return true
150
+ }
151
+ draft.value.snapshots = card.value.snapshotsEnabled
152
+ draft.value.mask = card.value.snapshotMaskText
153
+ return false
154
+ }
155
+
156
+ /** Closes the modal and reads the table again. */
157
+ function done(): void {
158
+ settled.value = true
159
+ if (props.onSuccessCallback) {
160
+ props.onSuccessCallback()
161
+ } else {
162
+ emit('success')
163
+ refreshPageBlocks()
164
+ }
165
+ }
166
+
167
+ async function save(): Promise<void> {
168
+ fieldErrors.clear()
169
+ if (!(await requireFields()) || !(await confirmSnapshotDiscard())) {
170
+ return
171
+ }
172
+ saving.value = true
173
+ try {
174
+ await api.updateWebsite(card.value.id, websitePatchOf(draft.value))
175
+ toast.add({ color: 'success', title: t('page.marketing.websites.saved') })
176
+ done()
177
+ } catch (error) {
178
+ // A refused field lives on the General tab: show it there.
179
+ const conflict = hostConflictOf(error)
180
+ if (conflict) {
181
+ tab.value = 'general'
182
+ await fieldErrors.setError(
183
+ conflict.field === 'extraDomains' ? 'extraDomains' : 'domain',
184
+ t('page.marketing.errors.duplicate_host_named', { ...conflict }),
185
+ )
186
+ return
187
+ }
188
+ if (await fieldErrors.applyApiError(error)) {
189
+ tab.value = 'general'
190
+ return
191
+ }
192
+ fieldErrors.toastError(error, {
193
+ toastTitle: 'page.marketing.websites.save_error',
194
+ })
195
+ } finally {
196
+ saving.value = false
197
+ }
198
+ }
199
+
200
+ /** The purges the danger zone offers, the narrower first. */
201
+ const PURGE_SCOPES: readonly MarketingPurgeScope[] = ['heatmaps', 'all']
202
+
203
+ /** The website a deletion or a purge is asked about. */
204
+ const deletionTarget = computed(() => card.value.id)
205
+ const readDeletionImpact = useWebsiteDeletionImpactRead(deletionTarget)
206
+
207
+ /** The scope a purge is running for: its button spins. */
208
+ const purging = ref<MarketingPurgeScope | null>(null)
209
+
210
+ /**
211
+ * Purges the heatmaps or all the data of the site, which stays with its
212
+ * settings and funnels. The dialog counts what the scope deletes, and the
213
+ * domain typed confirms it.
214
+ */
215
+ async function purge(scope: MarketingPurgeScope): Promise<void> {
216
+ const website = card.value
217
+ let impact
218
+ purging.value = scope
219
+ try {
220
+ impact = await readDeletionImpact()
221
+ } catch (error) {
222
+ fieldErrors.toastError(error, {
223
+ toastTitle: 'page.marketing.websites.delete.impact_error',
224
+ })
225
+ return
226
+ } finally {
227
+ purging.value = null
228
+ }
229
+ await confirm({
230
+ title: t(`page.marketing.websites.purge.${scope}_title`, {
231
+ name: website.name,
232
+ }),
233
+ description: t(`page.marketing.websites.purge.${scope}_description`),
234
+ color: 'error',
235
+ icon: 'i-ph-eraser',
236
+ confirmLabel: t(`page.marketing.websites.purge.${scope}_confirm`),
237
+ // A hostname is not case-sensitive: neither is the typed check.
238
+ confirmText: website.domain,
239
+ confirmTextCaseInsensitive: true,
240
+ impact: websitePurgeImpact(scope, impact, t, locale.value),
241
+ onConfirm: async () => {
242
+ purging.value = scope
243
+ try {
244
+ const answer = await api.purgeWebsite(website.id, scope)
245
+ toast.add({
246
+ color: 'success',
247
+ title: t(`page.marketing.websites.purge.${scope}_done`, {
248
+ name: website.name,
249
+ }),
250
+ description: websitePurgeResult(answer, t, locale.value),
251
+ })
252
+ done()
253
+ } finally {
254
+ purging.value = null
255
+ }
256
+ },
257
+ })
258
+ }
259
+
260
+ async function remove(): Promise<void> {
261
+ const website = card.value
262
+ // The backend refuses it too (409): the button is disabled.
263
+ if (website.dashboard) {
264
+ return
265
+ }
266
+ let impact
267
+ try {
268
+ impact = await readDeletionImpact()
269
+ } catch (error) {
270
+ fieldErrors.toastError(error, {
271
+ toastTitle: 'page.marketing.websites.delete.impact_error',
272
+ })
273
+ return
274
+ }
275
+ // Every stored day goes, so the counts read "since the oldest one".
276
+ const since =
277
+ impact.since === null ? null : formatShortDate(impact.since, locale.value)
278
+ const sinceLabel = (key: 'sessions' | 'pageviews') =>
279
+ since === null
280
+ ? t(`page.marketing.websites.delete.${key}`)
281
+ : t(`page.marketing.websites.delete.${key}_since`, { date: since })
282
+ await confirm({
283
+ title: t('page.marketing.websites.delete.title', { name: website.name }),
284
+ description: t('page.marketing.websites.delete.description'),
285
+ color: 'error',
286
+ icon: 'i-ph-trash',
287
+ confirmLabel: t('page.marketing.websites.delete.confirm'),
288
+ // A hostname is not case-sensitive: neither is the typed check.
289
+ confirmText: website.domain,
290
+ confirmTextCaseInsensitive: true,
291
+ impact: [
292
+ {
293
+ icon: 'i-ph-users',
294
+ label: sinceLabel('sessions'),
295
+ count: formatNumber(impact.sessions, locale.value),
296
+ },
297
+ {
298
+ icon: 'i-ph-eye',
299
+ label: sinceLabel('pageviews'),
300
+ count: formatNumber(impact.pageviews, locale.value),
301
+ },
302
+ {
303
+ icon: 'i-ph-funnel',
304
+ label: t('page.marketing.websites.delete.funnels'),
305
+ count: impact.funnels,
306
+ },
307
+ {
308
+ icon: 'i-ph-camera',
309
+ label: t('page.marketing.websites.delete.snapshots'),
310
+ count: impact.snapshots,
311
+ },
312
+ ],
313
+ onConfirm: async () => {
314
+ await api.deleteWebsite(website.id)
315
+ toast.add({
316
+ color: 'success',
317
+ title: t('page.marketing.websites.delete.done', { name: website.name }),
318
+ })
319
+ done()
320
+ },
321
+ })
322
+ }
323
+ </script>
324
+
325
+ <template>
326
+ <div class="flex flex-col gap-5">
327
+ <header class="flex min-w-0 items-center gap-3">
328
+ <span
329
+ class="grid size-9 shrink-0 place-items-center rounded-lg bg-primary/15 font-mono text-sm font-semibold text-(--ui-color-primary-800) dark:text-primary"
330
+ >
331
+ {{ initials(card.name) }}
332
+ </span>
333
+ <span class="flex min-w-0 flex-col">
334
+ <span class="truncate text-lg font-semibold text-highlighted">
335
+ {{ card.name }}
336
+ </span>
337
+ <span class="font-mono text-xs break-all text-dimmed">
338
+ {{ card.id }} ·
339
+ {{
340
+ t('page.marketing.websites.modal.added', {
341
+ date: formatShortDate(card.createdAt, locale),
342
+ })
343
+ }}
344
+ </span>
345
+ </span>
346
+ </header>
347
+
348
+ <DmsSegmented
349
+ v-model="tab"
350
+ :items="tabItems"
351
+ size="sm"
352
+ class="self-start"
353
+ />
354
+
355
+ <template v-if="tab === 'general'">
356
+ <div class="grid gap-x-6 gap-y-1 md:grid-cols-2">
357
+ <DmsFieldRow
358
+ layout="stack"
359
+ spacing="list"
360
+ :inset="false"
361
+ label="$page.marketing.websites.fields.name"
362
+ label-for="website-name"
363
+ required
364
+ >
365
+ <UInput
366
+ id="website-name"
367
+ v-model="draft.name"
368
+ class="w-full"
369
+ :maxlength="MAX_WEBSITE_NAME_LENGTH"
370
+ v-bind="fieldErrors.aria('name')"
371
+ @update:model-value="fieldErrors.clear('name')"
372
+ />
373
+ <DmsFieldError
374
+ :id="fieldErrors.errorId('name')"
375
+ :message="fieldErrors.errors.name"
376
+ />
377
+ </DmsFieldRow>
378
+ <DmsFieldRow
379
+ layout="stack"
380
+ spacing="list"
381
+ :inset="false"
382
+ label="$page.marketing.websites.fields.domain"
383
+ description="$page.marketing.websites.fields.domain_hint"
384
+ label-for="website-domain"
385
+ required
386
+ >
387
+ <UInput
388
+ id="website-domain"
389
+ v-model="draft.domain"
390
+ class="w-full font-mono"
391
+ :maxlength="MAX_WEBSITE_DOMAIN_LENGTH"
392
+ v-bind="fieldErrors.aria('domain')"
393
+ @update:model-value="fieldErrors.clear('domain')"
394
+ />
395
+ <DmsFieldError
396
+ :id="fieldErrors.errorId('domain')"
397
+ :message="fieldErrors.errors.domain"
398
+ />
399
+ </DmsFieldRow>
400
+ </div>
401
+ <DmsFieldRow
402
+ layout="stack"
403
+ spacing="list"
404
+ :inset="false"
405
+ label="$page.marketing.websites.fields.hosts"
406
+ description="$page.marketing.websites.fields.hosts_hint"
407
+ label-for="website-hosts"
408
+ >
409
+ <UInputTags
410
+ id="website-hosts"
411
+ v-model="draft.hosts"
412
+ class="w-full font-mono"
413
+ :placeholder="'staging.example.com'"
414
+ v-bind="fieldErrors.aria('extraDomains')"
415
+ @update:model-value="fieldErrors.clear('extraDomains')"
416
+ />
417
+ <DmsFieldError
418
+ :id="fieldErrors.errorId('extraDomains')"
419
+ :message="fieldErrors.errors.extraDomains"
420
+ />
421
+ </DmsFieldRow>
422
+ <DmsFieldRow
423
+ spacing="list"
424
+ :inset="false"
425
+ label="$page.marketing.websites.fields.tracking"
426
+ description="$page.marketing.websites.fields.tracking_hint"
427
+ label-for="website-tracking"
428
+ >
429
+ <USwitch id="website-tracking" v-model="draft.tracking" />
430
+ </DmsFieldRow>
431
+ <DmsFieldRow
432
+ spacing="list"
433
+ :inset="false"
434
+ label="$page.marketing.websites.fields.snapshots"
435
+ description="$page.marketing.websites.fields.snapshots_hint"
436
+ label-for="website-snapshots"
437
+ >
438
+ <USwitch id="website-snapshots" v-model="draft.snapshots" />
439
+ </DmsFieldRow>
440
+ <DmsFieldRow
441
+ spacing="list"
442
+ :inset="false"
443
+ label="$page.marketing.websites.fields.mask"
444
+ description="$page.marketing.websites.fields.mask_hint"
445
+ label-for="website-mask"
446
+ :disabled="!draft.snapshots"
447
+ >
448
+ <USwitch
449
+ id="website-mask"
450
+ v-model="draft.mask"
451
+ :disabled="!draft.snapshots"
452
+ />
453
+ </DmsFieldRow>
454
+ <DmsBanner
455
+ v-if="discardsSnapshots"
456
+ tone="warning"
457
+ size="sm"
458
+ :title="
459
+ t(
460
+ 'page.marketing.websites.fields.discard',
461
+ { count: card.snapshots },
462
+ card.snapshots,
463
+ )
464
+ "
465
+ />
466
+ <DmsFormRequiredLegend />
467
+ </template>
468
+ <div v-else class="flex flex-col gap-4">
469
+ <section
470
+ class="flex flex-col gap-3 rounded-lg border border-warning/40 p-4"
471
+ >
472
+ <span class="text-sm font-semibold text-highlighted">
473
+ {{ t('page.marketing.websites.purge.section') }}
474
+ </span>
475
+ <span class="text-sm text-muted">
476
+ {{ t('page.marketing.websites.purge.section_description') }}
477
+ </span>
478
+ <div
479
+ v-for="scope in PURGE_SCOPES"
480
+ :key="scope"
481
+ class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-t border-default pt-3"
482
+ >
483
+ <span class="min-w-0 flex-1 basis-64 text-sm text-muted">
484
+ {{ t(`page.marketing.websites.purge.${scope}_hint`) }}
485
+ </span>
486
+ <UButton
487
+ color="error"
488
+ variant="outline"
489
+ icon="i-ph-eraser"
490
+ :loading="purging === scope"
491
+ :disabled="purging !== null && purging !== scope"
492
+ :label="t(`page.marketing.websites.purge.${scope}_action`)"
493
+ @click="purge(scope)"
494
+ />
495
+ </div>
496
+ </section>
497
+ <section
498
+ class="flex flex-col gap-3 rounded-lg border border-error/40 p-4"
499
+ >
500
+ <span class="text-sm font-semibold text-highlighted">
501
+ {{ t('page.marketing.websites.delete.section') }}
502
+ </span>
503
+ <span class="text-sm text-muted">
504
+ {{
505
+ card.dashboard
506
+ ? t('page.marketing.websites.delete.dashboard_kept')
507
+ : t('page.marketing.websites.delete.section_description')
508
+ }}
509
+ </span>
510
+ <UButton
511
+ class="self-start"
512
+ color="error"
513
+ variant="outline"
514
+ icon="i-ph-trash"
515
+ :disabled="card.dashboard"
516
+ :label="t('page.marketing.websites.delete.action')"
517
+ @click="remove"
518
+ />
519
+ </section>
520
+ </div>
521
+
522
+ <footer
523
+ v-if="tab !== 'danger'"
524
+ class="flex items-center justify-end gap-2 border-t border-default pt-4"
525
+ >
526
+ <UButton
527
+ color="neutral"
528
+ variant="ghost"
529
+ :label="t('page.marketing.common.cancel')"
530
+ @click="cancel"
531
+ />
532
+ <UButton
533
+ :loading="saving"
534
+ :label="t('page.marketing.common.save_changes')"
535
+ @click="save"
536
+ />
537
+ </footer>
538
+ </div>
539
+ </template>
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ import { computed, useId } from 'vue'
3
+ import { useI18n } from '#dms/frontend-module'
4
+ import type { MarketingWorkspace } from '../composables/useMarketingApi'
5
+ import { ALL_WORKSPACES } from '../utils/workspace-scope'
6
+
7
+ /**
8
+ * Whose dashboard the heatmaps read: the viewer's workspace, another one,
9
+ * or all of them combined (see utils/workspace-scope). Self-contained — the
10
+ * options in, the scope out through `v-model` — so it can sit wherever the
11
+ * view's website context sits: the top of the pages panel, above its search.
12
+ * Outside the viewer's workspace its note (combined, read-only, masked) sits
13
+ * in the tooltip of an info icon beside it, keyboard focusable and
14
+ * describing the selector, so the row stays compact. The parent renders it
15
+ * only when the backend offers two workspaces or more: never to a workspace
16
+ * owner.
17
+ */
18
+ const props = defineProps<{
19
+ workspaces: MarketingWorkspace[]
20
+ /** `''` own workspace, `all`, or another workspace's id. */
21
+ modelValue: string
22
+ }>()
23
+
24
+ const emit = defineEmits<{
25
+ 'update:modelValue': [workspace: string]
26
+ }>()
27
+
28
+ const { t } = useI18n()
29
+ const noteId = useId()
30
+
31
+ const own = computed(() => props.workspaces.find((option) => option.current))
32
+
33
+ const label = computed(() => {
34
+ if (props.modelValue === ALL_WORKSPACES) {
35
+ return t('page.marketing.pages.workspace.all')
36
+ }
37
+ const picked = props.workspaces.find(
38
+ (option) => option.id === props.modelValue,
39
+ )
40
+ return (picked ?? own.value)?.name ?? ''
41
+ })
42
+
43
+ const items = computed(() => [
44
+ [
45
+ {
46
+ label: t('page.marketing.pages.workspace.all'),
47
+ description: t(
48
+ 'page.marketing.pages.workspace.all_hint',
49
+ { count: props.workspaces.length },
50
+ props.workspaces.length,
51
+ ),
52
+ icon: 'i-ph-stack',
53
+ type: 'checkbox' as const,
54
+ checked: props.modelValue === ALL_WORKSPACES,
55
+ onSelect: () => emit('update:modelValue', ALL_WORKSPACES),
56
+ },
57
+ ],
58
+ props.workspaces.map((option) => ({
59
+ label: option.name,
60
+ description: option.current
61
+ ? t('page.marketing.pages.workspace.yours')
62
+ : undefined,
63
+ icon: 'i-ph-buildings',
64
+ type: 'checkbox' as const,
65
+ checked: option.current
66
+ ? !props.modelValue
67
+ : props.modelValue === option.id,
68
+ onSelect: () => emit('update:modelValue', option.current ? '' : option.id),
69
+ })),
70
+ ])
71
+
72
+ /** Outside the viewer's own workspace, what the view shows differs: said. */
73
+ const note = computed(() => {
74
+ if (!props.modelValue) {
75
+ return ''
76
+ }
77
+ return props.modelValue === ALL_WORKSPACES
78
+ ? t(
79
+ 'page.marketing.pages.workspace.combined_note',
80
+ { count: props.workspaces.length },
81
+ props.workspaces.length,
82
+ )
83
+ : t('page.marketing.pages.workspace.other_note')
84
+ })
85
+ </script>
86
+
87
+ <template>
88
+ <div class="flex min-w-0 items-center gap-1.5">
89
+ <UDropdownMenu :items="items" :content="{ align: 'start' }">
90
+ <UButton
91
+ color="neutral"
92
+ variant="outline"
93
+ size="sm"
94
+ icon="i-ph-buildings"
95
+ trailing-icon="i-ph-caret-up-down"
96
+ class="min-w-0 max-w-full"
97
+ :aria-label="t('page.marketing.pages.workspace.switch')"
98
+ :aria-describedby="note ? noteId : undefined"
99
+ >
100
+ <span class="shrink-0 text-muted">
101
+ {{ t('page.marketing.pages.workspace.label') }}
102
+ </span>
103
+ <span class="truncate font-semibold">{{ label }}</span>
104
+ </UButton>
105
+ </UDropdownMenu>
106
+ <UTooltip v-if="note" :text="note">
107
+ <span
108
+ class="grid size-6 shrink-0 place-items-center rounded text-muted hover:text-default focus-visible:outline-2 focus-visible:outline-primary"
109
+ tabindex="0"
110
+ role="img"
111
+ :aria-label="t('page.marketing.pages.workspace.note_label')"
112
+ :aria-describedby="noteId"
113
+ >
114
+ <UIcon name="i-ph-info" class="size-4" aria-hidden="true" />
115
+ </span>
116
+ </UTooltip>
117
+ <span v-if="note" :id="noteId" class="sr-only">{{ note }}</span>
118
+ </div>
119
+ </template>