@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.
@@ -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
- // ── Event handlers ────────────────────────────────────────────────────────────
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
- // Announcements is the ONLY entry point. Each tour chains to the next via @finish.
174
- // VOnboardingWrapper is registered as client-only, so wrapper refs are null
175
- // until after hydration.
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
- [announcementsWrapper, notificationsWrapper, searchWrapper, adminNavWrapper],
178
- async ([announcementsW, notificationsW, searchW, adminNavW]) => {
179
- if (!announcementsW || !notificationsW || !searchW || !adminNavW) return
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
- // If the backend flag is set, clear localStorage so the tour runs again
183
- if (user.value?.data?.show_onboarding) {
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
- * Call this before navigating to dashboard to restart the full tour.
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
+ }
@@ -2,6 +2,7 @@
2
2
  "next": "Weiter",
3
3
  "previous": "Zurück",
4
4
  "finish": "Fertig",
5
+ "skip": "Überspringen",
5
6
  "announcements": {
6
7
  "step1_title": "Meldungen",
7
8
  "step1_desc": "Dieses Panel zeigt wichtige Meldungen von Ihrem Team oder Systemadministratoren.",
@@ -2,6 +2,7 @@
2
2
  "next": "Next",
3
3
  "previous": "Previous",
4
4
  "finish": "Done",
5
+ "skip": "Skip",
5
6
  "announcements": {
6
7
  "step1_title": "Announcements",
7
8
  "step1_desc": "This panel shows important announcements from your team or system administrators.",
@@ -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'), icon: 'i-lucide-home' }]" />
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.14.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.14.1"
20
+ "@motor-cms/ui-core": "1.15.0"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "nuxt": "^4.0.0",