@motor-cms/ui-admin 1.14.1 → 1.15.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/app/components/OnboardingStep.vue +42 -0
- package/app/components/UsersOnboarding.vue +28 -4
- package/app/components/dashboard/DashboardOnboarding.vue +83 -20
- package/app/composables/useOnboardingState.ts +31 -1
- package/app/lang/de/motor-admin/onboarding.json +1 -0
- package/app/lang/en/motor-admin/onboarding.json +1 -0
- package/app/pages/index.vue +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { StepEntity } from 'v-onboarding'
|
|
3
|
+
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
|
|
6
|
+
defineProps<{
|
|
7
|
+
step: StepEntity | undefined
|
|
8
|
+
isFirst: boolean
|
|
9
|
+
isLast: boolean
|
|
10
|
+
next: () => void
|
|
11
|
+
previous: () => void
|
|
12
|
+
skip: () => void
|
|
13
|
+
}>()
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<VOnboardingStep>
|
|
18
|
+
<div class="v-onboarding-item">
|
|
19
|
+
<div class="v-onboarding-item__header">
|
|
20
|
+
<span v-if="step?.content?.title" class="v-onboarding-item__header-title">
|
|
21
|
+
{{ step.content.title }}
|
|
22
|
+
</span>
|
|
23
|
+
<button type="button" class="v-onboarding-item__header-close" :aria-label="t('motor-admin.onboarding.skip')" @click="skip">
|
|
24
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
25
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
26
|
+
</svg>
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
<p v-if="step?.content?.description" class="v-onboarding-item__description">
|
|
30
|
+
{{ step.content.description }}
|
|
31
|
+
</p>
|
|
32
|
+
<div class="v-onboarding-item__actions">
|
|
33
|
+
<button v-if="!isFirst" type="button" class="v-onboarding-btn-secondary" @click="previous">
|
|
34
|
+
{{ t('motor-admin.onboarding.previous') }}
|
|
35
|
+
</button>
|
|
36
|
+
<button type="button" class="v-onboarding-btn-primary" @click="next">
|
|
37
|
+
{{ isLast ? t('motor-admin.onboarding.finish') : t('motor-admin.onboarding.next') }}
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</VOnboardingStep>
|
|
42
|
+
</template>
|
|
@@ -4,10 +4,11 @@ const { t } = useI18n()
|
|
|
4
4
|
const { isCompleted: adminGridCompleted, markCompleted: markAdminGridDone } = useOnboardingState('admin-grid')
|
|
5
5
|
const { getPending, clearPending, setPending } = usePendingOnboarding()
|
|
6
6
|
const { completeOnboarding } = useProfileApi()
|
|
7
|
+
const { commitDone } = useOnboardingDone()
|
|
7
8
|
const router = useRouter()
|
|
8
9
|
|
|
9
10
|
const wrapper = ref(null)
|
|
10
|
-
const { start } = useVOnboarding(wrapper)
|
|
11
|
+
const { start, finish: finishWrapper } = useVOnboarding(wrapper)
|
|
11
12
|
|
|
12
13
|
const steps = computed(() => [
|
|
13
14
|
{
|
|
@@ -29,13 +30,34 @@ const options = computed(() => ({
|
|
|
29
30
|
},
|
|
30
31
|
}))
|
|
31
32
|
|
|
33
|
+
// Prevents onFinish from navigating when the user clicked skip instead
|
|
34
|
+
const skipping = ref(false)
|
|
35
|
+
|
|
32
36
|
function onFinish() {
|
|
37
|
+
if (skipping.value) {
|
|
38
|
+
skipping.value = false
|
|
39
|
+
return
|
|
40
|
+
}
|
|
33
41
|
markAdminGridDone()
|
|
42
|
+
// commitDone() before the API call — the browser may cancel the in-flight
|
|
43
|
+
// request if the user reloads before it resolves, and the localStorage flag
|
|
44
|
+
// must already be present to prevent DashboardOnboarding from resetting
|
|
45
|
+
// state and restarting the tour when the dashboard remounts after the
|
|
46
|
+
// builder tour redirects back to '/'.
|
|
47
|
+
commitDone()
|
|
34
48
|
completeOnboarding().catch(() => {})
|
|
35
49
|
setPending('builder-pages')
|
|
36
50
|
router.push('/motor-builder/builder-pages')
|
|
37
51
|
}
|
|
38
52
|
|
|
53
|
+
function skipAdminGrid() {
|
|
54
|
+
commitDone()
|
|
55
|
+
skipping.value = true
|
|
56
|
+
markAdminGridDone()
|
|
57
|
+
completeOnboarding().catch(() => {})
|
|
58
|
+
finishWrapper()
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
// Auto-start when the page was reached via the admin-nav onboarding chain.
|
|
40
62
|
// The pending flag is set in DashboardOnboarding.vue after the admin-nav tour finishes.
|
|
41
63
|
const stopWatch = watch(
|
|
@@ -46,8 +68,6 @@ const stopWatch = watch(
|
|
|
46
68
|
|
|
47
69
|
if (getPending() === 'admin-grid' && !adminGridCompleted.value) {
|
|
48
70
|
clearPending()
|
|
49
|
-
// Brief delay to let the grid data load before the tooltip appears
|
|
50
|
-
await new Promise(resolve => setTimeout(resolve, 600))
|
|
51
71
|
start()
|
|
52
72
|
}
|
|
53
73
|
},
|
|
@@ -61,5 +81,9 @@ const stopWatch = watch(
|
|
|
61
81
|
:steps="steps"
|
|
62
82
|
:options="options"
|
|
63
83
|
@finish="onFinish"
|
|
64
|
-
|
|
84
|
+
>
|
|
85
|
+
<template #default="{ step, next, previous, isFirst, isLast }">
|
|
86
|
+
<OnboardingStep :step="step" :next="next" :previous="previous" :skip="skipAdminGrid" :is-first="isFirst" :is-last="isLast" />
|
|
87
|
+
</template>
|
|
88
|
+
</VOnboardingWrapper>
|
|
65
89
|
</template>
|
|
@@ -5,6 +5,8 @@ const { t } = useI18n()
|
|
|
5
5
|
const { can } = usePermissions()
|
|
6
6
|
const { user } = useSanctumAuth<User>()
|
|
7
7
|
const router = useRouter()
|
|
8
|
+
const { completeOnboarding } = useProfileApi()
|
|
9
|
+
const { commitDone, isDone } = useOnboardingDone()
|
|
8
10
|
|
|
9
11
|
const { isCompleted: announcementsCompleted, markCompleted: markAnnouncementsDone } = useOnboardingState('dashboard-announcements')
|
|
10
12
|
const { isCompleted: notificationsCompleted, markCompleted: markNotificationsDone } = useOnboardingState('notifications')
|
|
@@ -19,10 +21,10 @@ const notificationsWrapper = ref(null)
|
|
|
19
21
|
const searchWrapper = ref(null)
|
|
20
22
|
const adminNavWrapper = ref(null)
|
|
21
23
|
|
|
22
|
-
const { start: startAnnouncements } = useVOnboarding(announcementsWrapper)
|
|
23
|
-
const { start: startNotifications } = useVOnboarding(notificationsWrapper)
|
|
24
|
-
const { start: startSearch } = useVOnboarding(searchWrapper)
|
|
25
|
-
const { start: startAdminNav } = useVOnboarding(adminNavWrapper)
|
|
24
|
+
const { start: startAnnouncements, finish: finishAnnouncements } = useVOnboarding(announcementsWrapper)
|
|
25
|
+
const { start: startNotifications, finish: finishNotifications } = useVOnboarding(notificationsWrapper)
|
|
26
|
+
const { start: startSearch, finish: finishSearch } = useVOnboarding(searchWrapper)
|
|
27
|
+
const { start: startAdminNav, finish: finishAdminNav } = useVOnboarding(adminNavWrapper)
|
|
26
28
|
|
|
27
29
|
// ── Shared options ────────────────────────────────────────────────────────────
|
|
28
30
|
const options = computed(() => ({
|
|
@@ -120,7 +122,7 @@ const adminNavSteps = computed(() => [
|
|
|
120
122
|
},
|
|
121
123
|
])
|
|
122
124
|
|
|
123
|
-
// ──
|
|
125
|
+
// ── Finish handlers ───────────────────────────────────────────────────────────
|
|
124
126
|
|
|
125
127
|
/**
|
|
126
128
|
* Announcements tour finished → chain to notifications.
|
|
@@ -169,24 +171,69 @@ function onAdminNavFinish() {
|
|
|
169
171
|
router.push('/motor-admin/users')
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
// ── Skip handlers ─────────────────────────────────────────────────────────────
|
|
175
|
+
// Pre-mark all remaining tours as done so @finish chain conditions are false,
|
|
176
|
+
// then call finish() to close the wrapper.
|
|
177
|
+
//
|
|
178
|
+
// finish() is required — exit() from the slot only emits @exit without
|
|
179
|
+
// changing the wrapper's internal currentIndex, so the tooltip stays open.
|
|
180
|
+
//
|
|
181
|
+
// completeOnboarding() clears show_onboarding on the backend so the watch
|
|
182
|
+
// does not trigger another resetOnboardingState() on the next dashboard visit.
|
|
183
|
+
// We also set user.value.data.show_onboarding = false immediately so the
|
|
184
|
+
// in-memory auth cache does not cause another reset before the API response
|
|
185
|
+
// arrives.
|
|
186
|
+
|
|
187
|
+
function skipAll() {
|
|
188
|
+
// commitDone() must run BEFORE the API call — if the browser cancels the
|
|
189
|
+
// in-flight request on a hard reload the localStorage flag still survives
|
|
190
|
+
// and prevents the show_onboarding watch from resetting state.
|
|
191
|
+
commitDone()
|
|
192
|
+
markAnnouncementsDone()
|
|
193
|
+
markNotificationsDone()
|
|
194
|
+
markSearchDone()
|
|
195
|
+
markAdminNavDone()
|
|
196
|
+
if (user.value?.data) user.value.data.show_onboarding = false
|
|
197
|
+
completeOnboarding().catch(() => {})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function skipAnnouncements() { skipAll(); finishAnnouncements() }
|
|
201
|
+
function skipNotifications() { skipAll(); finishNotifications() }
|
|
202
|
+
function skipSearch() { skipAll(); finishSearch() }
|
|
203
|
+
function skipAdminNav() { skipAll(); finishAdminNav() }
|
|
204
|
+
|
|
172
205
|
// ── Start logic ───────────────────────────────────────────────────────────────
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
206
|
+
// Watch both the 4 wrappers AND user.value.data so the show_onboarding check
|
|
207
|
+
// never runs against a null/stale user — solving the race between wrapper mount
|
|
208
|
+
// and the sanctum /api/user response.
|
|
209
|
+
//
|
|
210
|
+
// After resetting we immediately set user.value.data.show_onboarding = false so
|
|
211
|
+
// that if DashboardOnboarding is unmounted and remounted (navigation away/back)
|
|
212
|
+
// the same session does not trigger a second reset from the in-memory cache.
|
|
213
|
+
// completeOnboarding() would clear the backend flag, but that is fire-and-forget
|
|
214
|
+
// and may resolve after a re-render; the local mutation is the reliable guard.
|
|
176
215
|
const stopWatch = watch(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
216
|
+
() => [
|
|
217
|
+
announcementsWrapper.value,
|
|
218
|
+
notificationsWrapper.value,
|
|
219
|
+
searchWrapper.value,
|
|
220
|
+
adminNavWrapper.value,
|
|
221
|
+
user.value?.data,
|
|
222
|
+
] as const,
|
|
223
|
+
async ([announcementsW, notificationsW, searchW, adminNavW, userData]) => {
|
|
224
|
+
if (!announcementsW || !notificationsW || !searchW || !adminNavW || !userData) return
|
|
180
225
|
stopWatch()
|
|
181
226
|
|
|
182
|
-
//
|
|
183
|
-
|
|
227
|
+
// Only reset if the user has NOT already committed a skip.
|
|
228
|
+
// isSkipCommitted() reads localStorage, so it survives hard reloads even
|
|
229
|
+
// when the completeOnboarding() API call was cancelled by the browser.
|
|
230
|
+
// resetAll() in useOnboardingResetAll clears the skip flag, so a
|
|
231
|
+
// deliberate "restart tour" from the profile page still works correctly.
|
|
232
|
+
if (userData.show_onboarding && !isDone()) {
|
|
233
|
+
userData.show_onboarding = false
|
|
184
234
|
resetOnboardingState()
|
|
185
235
|
}
|
|
186
236
|
|
|
187
|
-
// Allow initial toasts to clear before starting
|
|
188
|
-
await new Promise(resolve => setTimeout(resolve, 2500))
|
|
189
|
-
|
|
190
237
|
if (!announcementsCompleted.value) {
|
|
191
238
|
startAnnouncements()
|
|
192
239
|
}
|
|
@@ -201,23 +248,39 @@ const stopWatch = watch(
|
|
|
201
248
|
:steps="announcementSteps"
|
|
202
249
|
:options="options"
|
|
203
250
|
@finish="onAnnouncementsFinish"
|
|
204
|
-
|
|
251
|
+
>
|
|
252
|
+
<template #default="{ step, next, previous, isFirst, isLast }">
|
|
253
|
+
<OnboardingStep :step="step" :next="next" :previous="previous" :skip="skipAnnouncements" :is-first="isFirst" :is-last="isLast" />
|
|
254
|
+
</template>
|
|
255
|
+
</VOnboardingWrapper>
|
|
205
256
|
<VOnboardingWrapper
|
|
206
257
|
ref="notificationsWrapper"
|
|
207
258
|
:steps="notificationsSteps"
|
|
208
259
|
:options="options"
|
|
209
260
|
@finish="onNotificationsFinish"
|
|
210
|
-
|
|
261
|
+
>
|
|
262
|
+
<template #default="{ step, next, previous, isFirst, isLast }">
|
|
263
|
+
<OnboardingStep :step="step" :next="next" :previous="previous" :skip="skipNotifications" :is-first="isFirst" :is-last="isLast" />
|
|
264
|
+
</template>
|
|
265
|
+
</VOnboardingWrapper>
|
|
211
266
|
<VOnboardingWrapper
|
|
212
267
|
ref="searchWrapper"
|
|
213
268
|
:steps="searchSteps"
|
|
214
269
|
:options="options"
|
|
215
270
|
@finish="onSearchFinish"
|
|
216
|
-
|
|
271
|
+
>
|
|
272
|
+
<template #default="{ step, next, previous, isFirst, isLast }">
|
|
273
|
+
<OnboardingStep :step="step" :next="next" :previous="previous" :skip="skipSearch" :is-first="isFirst" :is-last="isLast" />
|
|
274
|
+
</template>
|
|
275
|
+
</VOnboardingWrapper>
|
|
217
276
|
<VOnboardingWrapper
|
|
218
277
|
ref="adminNavWrapper"
|
|
219
278
|
:steps="adminNavSteps"
|
|
220
279
|
:options="options"
|
|
221
280
|
@finish="onAdminNavFinish"
|
|
222
|
-
|
|
281
|
+
>
|
|
282
|
+
<template #default="{ step, next, previous, isFirst, isLast }">
|
|
283
|
+
<OnboardingStep :step="step" :next="next" :previous="previous" :skip="skipAdminNav" :is-first="isFirst" :is-last="isLast" />
|
|
284
|
+
</template>
|
|
285
|
+
</VOnboardingWrapper>
|
|
223
286
|
</template>
|
|
@@ -12,6 +12,10 @@ export type OnboardingArea =
|
|
|
12
12
|
|
|
13
13
|
const STORAGE_KEY = 'motor-onboarding-completed'
|
|
14
14
|
const PENDING_KEY = 'motor-onboarding-pending'
|
|
15
|
+
// Written before the completeOnboarding() API call so a hard reload between
|
|
16
|
+
// skip/complete and the network response does not trigger a false restart.
|
|
17
|
+
// Cleared by resetAll() so "restart tour" from profile overrides any prior done state.
|
|
18
|
+
const DONE_KEY = 'motor-onboarding-done'
|
|
15
19
|
|
|
16
20
|
function getCompleted(): Set<string> {
|
|
17
21
|
try {
|
|
@@ -51,7 +55,8 @@ export function useOnboardingState(area: OnboardingArea) {
|
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
57
|
* Clears all completed onboarding areas for the current user from localStorage.
|
|
54
|
-
*
|
|
58
|
+
* Also clears the skip-committed flag so a "restart tour" from profile
|
|
59
|
+
* correctly overrides any previous skip.
|
|
55
60
|
*/
|
|
56
61
|
export function useOnboardingResetAll() {
|
|
57
62
|
const { user } = useSanctumAuth<User>()
|
|
@@ -72,6 +77,7 @@ export function useOnboardingResetAll() {
|
|
|
72
77
|
completed.delete(`${userId.value}:${area}`)
|
|
73
78
|
}
|
|
74
79
|
persistCompleted(completed)
|
|
80
|
+
localStorage.removeItem(`${DONE_KEY}:${userId.value}`)
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
return { resetAll }
|
|
@@ -99,3 +105,27 @@ export function usePendingOnboarding() {
|
|
|
99
105
|
|
|
100
106
|
return { setPending, getPending, clearPending }
|
|
101
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Records that the user has intentionally ended the tour (via skip OR normal
|
|
111
|
+
* completion). Written to localStorage BEFORE the completeOnboarding() API
|
|
112
|
+
* call so the flag survives a hard reload if the browser cancels the request.
|
|
113
|
+
* DashboardOnboarding checks this flag before calling resetOnboardingState()
|
|
114
|
+
* so a stale show_onboarding=true in the sanctum auth cache never triggers a
|
|
115
|
+
* false restart. Cleared by resetAll() so "restart tour" from the profile
|
|
116
|
+
* page correctly overrides any prior done state.
|
|
117
|
+
*/
|
|
118
|
+
export function useOnboardingDone() {
|
|
119
|
+
const { user } = useSanctumAuth<User>()
|
|
120
|
+
const userId = computed(() => user.value?.data?.id?.toString() ?? 'anonymous')
|
|
121
|
+
|
|
122
|
+
function commitDone() {
|
|
123
|
+
localStorage.setItem(`${DONE_KEY}:${userId.value}`, '1')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isDone(): boolean {
|
|
127
|
+
return !!localStorage.getItem(`${DONE_KEY}:${userId.value}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { commitDone, isDone }
|
|
131
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -58,7 +58,7 @@ async function onDismiss(id: number) {
|
|
|
58
58
|
<div class="flex items-center gap-2 mb-3">
|
|
59
59
|
<UDashboardSidebarToggle class="lg:hidden shrink-0 -ml-2" />
|
|
60
60
|
<SidebarToggleButton />
|
|
61
|
-
<UBreadcrumb :items="[{ label: t('motor-core.global.dashboard')
|
|
61
|
+
<UBreadcrumb :items="[{ label: t('motor-core.global.dashboard') }]" />
|
|
62
62
|
</div>
|
|
63
63
|
<div class="flex flex-col mb-6">
|
|
64
64
|
<div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motor-cms/ui-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"@vueuse/core": "^14.0.0",
|
|
18
18
|
"sortablejs": "^1.15.0",
|
|
19
19
|
"zod": "^4.0.0",
|
|
20
|
-
"@motor-cms/ui-core": "1.
|
|
20
|
+
"@motor-cms/ui-core": "1.15.0"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"nuxt": "^4.0.0",
|