@antelopejs/dms-marketing 0.6.1 → 0.7.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/config.d.ts +3 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/data-api/websites.d.ts +10 -4
- package/dist/data-api/websites.js +79 -3
- package/dist/data-api/websites.js.map +1 -1
- package/dist/db/models/page_snapshots.model.d.ts +2 -0
- package/dist/db/models/page_snapshots.model.js +6 -0
- package/dist/db/models/page_snapshots.model.js.map +1 -1
- package/dist/db/models/websites.model.d.ts +1 -0
- package/dist/db/models/websites.model.js +9 -0
- package/dist/db/models/websites.model.js.map +1 -1
- package/dist/db/tables/page_snapshots.table.d.ts +1 -0
- package/dist/db/tables/page_snapshots.table.js +4 -0
- package/dist/db/tables/page_snapshots.table.js.map +1 -1
- package/dist/db/tables/websites.table.d.ts +2 -0
- package/dist/db/tables/websites.table.js +4 -0
- package/dist/db/tables/websites.table.js.map +1 -1
- package/dist/frontend-module.d.ts +6 -0
- package/dist/frontend-module.js +46 -0
- package/dist/frontend-module.js.map +1 -0
- package/dist/index.js +3 -11
- package/dist/index.js.map +1 -1
- package/dist/pages/acquisition/page.d.ts +1 -0
- package/dist/pages/acquisition/page.js +9 -4
- package/dist/pages/acquisition/page.js.map +1 -1
- package/dist/pages/blocks.d.ts +11 -2
- package/dist/pages/blocks.js +43 -5
- package/dist/pages/blocks.js.map +1 -1
- package/dist/pages/funnel/page.d.ts +1 -0
- package/dist/pages/funnel/page.js +1 -0
- package/dist/pages/funnel/page.js.map +1 -1
- package/dist/pages/funnels/page.d.ts +1 -0
- package/dist/pages/funnels/page.js +4 -2
- package/dist/pages/funnels/page.js.map +1 -1
- package/dist/pages/overview/page.d.ts +1 -0
- package/dist/pages/overview/page.js +5 -6
- package/dist/pages/overview/page.js.map +1 -1
- package/dist/pages/pages/page.d.ts +1 -0
- package/dist/pages/pages/page.js +1 -0
- package/dist/pages/pages/page.js.map +1 -1
- package/dist/pages/settings/form.js +1 -0
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/websites/page.d.ts +2 -3
- package/dist/pages/websites/page.js +63 -5
- package/dist/pages/websites/page.js.map +1 -1
- package/dist/routes/dashboard-tracking.js +5 -1
- package/dist/routes/dashboard-tracking.js.map +1 -1
- package/dist/routes/funnels.d.ts +2 -0
- package/dist/routes/funnels.js +6 -5
- package/dist/routes/funnels.js.map +1 -1
- package/dist/routes/public-endpoints.js +1 -1
- package/dist/routes/public-endpoints.js.map +1 -1
- package/dist/routes/snapshot.d.ts +1 -1
- package/dist/routes/snapshot.js +30 -13
- package/dist/routes/snapshot.js.map +1 -1
- package/dist/routes/stats.d.ts +10 -4
- package/dist/routes/stats.js +77 -23
- package/dist/routes/stats.js.map +1 -1
- package/dist/routes/websites.d.ts +5 -1
- package/dist/routes/websites.js +31 -13
- package/dist/routes/websites.js.map +1 -1
- package/dist/services/acquisition-report.d.ts +1 -0
- package/dist/services/acquisition-report.js +1 -0
- package/dist/services/acquisition-report.js.map +1 -1
- package/dist/services/collect.js +7 -2
- package/dist/services/collect.js.map +1 -1
- package/dist/services/dashboard-tracking.d.ts +7 -14
- package/dist/services/dashboard-tracking.js +42 -26
- package/dist/services/dashboard-tracking.js.map +1 -1
- package/dist/services/funnels-table.d.ts +3 -2
- package/dist/services/funnels-table.js +9 -9
- package/dist/services/funnels-table.js.map +1 -1
- package/dist/services/heatmap.d.ts +6 -0
- package/dist/services/heatmap.js +82 -17
- package/dist/services/heatmap.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/prune.js +6 -1
- package/dist/services/prune.js.map +1 -1
- package/dist/services/realtime.d.ts +3 -0
- package/dist/services/realtime.js +49 -0
- package/dist/services/realtime.js.map +1 -0
- package/dist/services/request-body.d.ts +1 -0
- package/dist/services/request-body.js +18 -0
- package/dist/services/request-body.js.map +1 -1
- package/dist/services/sessionize.d.ts +1 -0
- package/dist/services/sessionize.js +9 -0
- package/dist/services/sessionize.js.map +1 -1
- package/dist/services/snapshots.d.ts +28 -1
- package/dist/services/snapshots.js +147 -26
- package/dist/services/snapshots.js.map +1 -1
- package/dist/services/tenant-website.d.ts +11 -0
- package/dist/services/tenant-website.js +27 -5
- package/dist/services/tenant-website.js.map +1 -1
- package/dist/services/website-report.d.ts +5 -0
- package/dist/services/website-report.js +13 -1
- package/dist/services/website-report.js.map +1 -1
- package/dist/services/workspace-scope.d.ts +31 -0
- package/dist/services/workspace-scope.js +72 -0
- package/dist/services/workspace-scope.js.map +1 -0
- package/dist/types/constants.d.ts +7 -0
- package/dist/types/constants.js +8 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/types/events.d.ts +25 -25
- package/dist/types/snapshots.d.ts +29 -29
- package/dist/types/snapshots.js +1 -1
- package/dist/types/snapshots.js.map +1 -1
- package/dist/utils/funnel-steps-type.d.ts +13 -13
- package/frontend-vue/app/components/ChannelsCard.vue +19 -2
- package/frontend-vue/app/components/Context.vue +50 -376
- package/frontend-vue/app/components/FunnelBuilder.vue +28 -22
- package/frontend-vue/app/components/FunnelFigure.vue +15 -2
- package/frontend-vue/app/components/FunnelReport.vue +3 -0
- package/frontend-vue/app/components/FunnelTemplates.vue +15 -13
- package/frontend-vue/app/components/InstallGuide.vue +1 -1
- package/frontend-vue/app/components/PagePreview.vue +330 -74
- package/frontend-vue/app/components/PagesExplorer.vue +230 -85
- package/frontend-vue/app/components/TopListTabs.vue +51 -18
- package/frontend-vue/app/components/WebsiteCard.vue +394 -0
- package/frontend-vue/app/components/WebsiteCreate.vue +159 -0
- package/frontend-vue/app/components/WebsitePicker.vue +210 -0
- package/frontend-vue/app/components/WebsiteSettings.vue +539 -0
- package/frontend-vue/app/components/WorkspaceScope.vue +119 -0
- package/frontend-vue/app/composables/useContextWebsite.ts +105 -0
- package/frontend-vue/app/composables/useCopyDraft.ts +47 -0
- package/frontend-vue/app/composables/useMarketingApi.ts +92 -4
- package/frontend-vue/app/composables/useMarketingReads.ts +32 -23
- package/frontend-vue/app/composables/useMarketingRoutes.ts +25 -2
- package/frontend-vue/app/composables/useRecordCrumb.ts +17 -0
- package/frontend-vue/app/composables/useSnapshotFrame.ts +35 -1
- package/frontend-vue/app/utils/anchor-drift.ts +58 -0
- package/frontend-vue/app/utils/drift-dismissals.ts +20 -0
- package/frontend-vue/app/utils/fill-height.ts +141 -0
- package/frontend-vue/app/utils/format.ts +21 -2
- package/frontend-vue/app/utils/funnel-draft.ts +22 -0
- package/frontend-vue/app/utils/heatmap.ts +17 -0
- package/frontend-vue/app/utils/page-snapshot.ts +75 -0
- package/frontend-vue/app/utils/snapshot-options.ts +148 -0
- package/frontend-vue/app/utils/snapshotScrollRoot.ts +93 -0
- package/frontend-vue/app/utils/website-card.ts +93 -0
- package/frontend-vue/app/utils/website-purge.ts +95 -0
- package/frontend-vue/app/utils/website-settings.ts +121 -0
- package/frontend-vue/app/utils/website-state.ts +93 -0
- package/frontend-vue/app/utils/workspace-scope.ts +51 -0
- package/frontend-vue/i18n/locales/marketing-en-GB.json +112 -36
- package/frontend-vue/i18n/locales/marketing-fr-FR.json +112 -36
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/anchor-drift.test.ts +59 -0
- package/frontend-vue/tests/build-flags.test.ts +38 -0
- package/frontend-vue/tests/context-bar.test.ts +226 -0
- package/frontend-vue/tests/copy-draft.test.ts +64 -0
- package/frontend-vue/tests/drift-dismissals.test.ts +12 -0
- package/frontend-vue/tests/fill-height.test.ts +110 -0
- package/frontend-vue/tests/format.test.ts +14 -0
- package/frontend-vue/tests/funnel-draft.test.ts +37 -0
- package/frontend-vue/tests/heatmap.test.ts +25 -0
- package/frontend-vue/tests/install-guide.test.ts +25 -0
- package/frontend-vue/tests/marketing-reads.test.ts +20 -6
- package/frontend-vue/tests/page-snapshot.test.ts +62 -0
- package/frontend-vue/tests/record-crumb.test.ts +43 -0
- package/frontend-vue/tests/registration.test.ts +4 -1
- package/frontend-vue/tests/routes.test.ts +21 -0
- package/frontend-vue/tests/save-funnel.test.ts +47 -0
- package/frontend-vue/tests/server-parity.test.ts +3 -0
- package/frontend-vue/tests/snapshot-options.test.ts +148 -0
- package/frontend-vue/tests/snapshot-scroll-root.test.ts +84 -0
- package/frontend-vue/tests/stubs/frontend-module.ts +5 -1
- package/frontend-vue/tests/top-list-tabs.test.ts +158 -0
- package/frontend-vue/tests/website-card.test.ts +119 -0
- package/frontend-vue/tests/website-purge.test.ts +70 -0
- package/frontend-vue/tests/website-settings.test.ts +133 -0
- package/frontend-vue/tests/website-state.test.ts +96 -0
- package/frontend-vue/tests/workspace-scope-control.test.ts +113 -0
- package/frontend-vue/tests/workspace-scope.test.ts +55 -0
- package/package.json +5 -4
- package/skills/dms-marketing/SKILL.md +47 -6
- package/static/snapshot.js +539 -48
- package/static/tracker.js +154 -5
- 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>
|