11import { DriveStep , driver } from 'driver.js' ;
2- import { createApp } from 'vue' ;
2+ import { createApp , ref } from 'vue' ;
33import type { App , Component } from 'vue' ;
44import GetAppStep from '../components/onboarding/GetAppStep.vue' ;
55import ProfileVignette from '../components/onboarding/ProfileVignette.vue' ;
@@ -8,30 +8,43 @@ import VaultListVignette from '../components/onboarding/VaultListVignette.vue';
88import WelcomeVignette from '../components/onboarding/WelcomeVignette.vue' ;
99import i18n from '../i18n' ;
1010import auth from './auth' ;
11+ import { isFlagSet , setFlag } from './util' ;
1112import 'driver.js/dist/driver.css' ;
1213
1314type TourStep = {
1415 target ?: string ;
16+ showWithoutTarget ?: boolean ;
1517 titleKey : string ;
1618 descriptionKey ?: string ;
1719 vignette ?: Component ;
1820} ;
1921
2022const vaultListTarget = '[data-tour="vaultList"]' ;
21- const adminNavTarget = '[data-tour="adminNav"]' ;
23+
24+ let activeTour : ReturnType < typeof driver > | undefined ;
25+
26+ // driver.js rebuilds the popover per step, so each render unmounts the previous step's content app;
27+ // module scope lets a replayed tour clean up a predecessor whose onDestroyed hook driver.js skipped
28+ let contentApp : App | undefined ;
29+
30+ function unmountContent ( ) {
31+ contentApp ?. unmount ( ) ;
32+ contentApp = undefined ;
33+ }
2234
2335// addVault must be gated explicitly because the button is always rendered, merely disabled;
2436// the admin step needs no role check: its target only exists for admins and invisible steps are skipped
2537/** exported for testing */
2638export function tourSteps ( canCreateVaults : boolean ) : TourStep [ ] {
2739 return [
2840 { titleKey : 'onboarding.welcome.title' , descriptionKey : 'onboarding.welcome.description' , vignette : WelcomeVignette } ,
29- { target : vaultListTarget , titleKey : 'onboarding.vaultList.title' , descriptionKey : canCreateVaults ? 'onboarding.vaultList.description' : 'onboarding.vaultList.description.shared ' , vignette : VaultListVignette } ,
41+ { target : vaultListTarget , titleKey : 'onboarding.vaultList.title' , descriptionKey : canCreateVaults ? 'onboarding.vaultList.owner. description' : 'onboarding.vaultList.member.description ' , vignette : VaultListVignette } ,
3042 canCreateVaults
3143 ? { target : '[data-tour="addVault"]' , titleKey : 'onboarding.addVault.title' , descriptionKey : 'onboarding.addVault.description' }
32- : { target : vaultListTarget , titleKey : 'onboarding.useVault.title' , descriptionKey : 'onboarding.useVault.description' } ,
33- { target : adminNavTarget , titleKey : 'onboarding.adminNav.title' , descriptionKey : 'onboarding.adminNav.description' } ,
34- { target : '[data-tour="profile"]' , titleKey : 'onboarding.profile.title' , descriptionKey : 'onboarding.profile.description' , vignette : ProfileVignette } ,
44+ : { titleKey : 'onboarding.useVault.title' , descriptionKey : 'onboarding.useVault.description' } ,
45+ { target : '[data-tour="adminNav"]' , titleKey : 'onboarding.adminNav.title' , descriptionKey : 'onboarding.adminNav.description' } ,
46+ // the account step must reach everyone, so it falls back to a floating card when the sidebar is hidden
47+ { target : '[data-tour="profile"]' , showWithoutTarget : true , titleKey : 'onboarding.profile.title' , descriptionKey : 'onboarding.profile.description' , vignette : ProfileVignette } ,
3548 { titleKey : 'onboarding.getApp.title' , vignette : GetAppStep }
3649 ] ;
3750}
@@ -40,27 +53,26 @@ function onboardingKey(userId: string): string {
4053 return `hub.onboardingCompleted.${ userId } ` ;
4154}
4255
43- function isOnboardingCompleted ( userId : string ) : boolean {
44- try {
45- return localStorage . getItem ( onboardingKey ( userId ) ) !== null ;
46- } catch {
47- // without localStorage we cannot remember a completion, so rather skip the tour than repeat it on every visit
48- return true ;
49- }
56+ // bumped on completion, so long-lived components like the sidebar react without a reload
57+ const completionVersion = ref ( 0 ) ;
58+
59+ /** also gates UI that should only appear once the tour is over, e.g. the sidebar app hint */
60+ export function isOnboardingCompleted ( userId : string ) : boolean {
61+ void completionVersion . value ; // subscribes reactive callers to tour completions
62+ return isFlagSet ( onboardingKey ( userId ) ) ;
5063}
5164
52- function setOnboardingCompleted ( userId : string ) {
53- try {
54- localStorage . setItem ( onboardingKey ( userId ) , new Date ( ) . toISOString ( ) ) ;
55- } catch {
56- // if localStorage is unavailable, the tour may show again next time
57- }
65+ function completeOnboarding ( userId : string ) {
66+ setFlag ( onboardingKey ( userId ) ) ;
67+ completionVersion . value ++ ;
5868}
5969
6070function findVisibleElement ( selector : string ) : Element | undefined {
61- return Array . from ( document . querySelectorAll ( selector ) ) . find ( element => element . checkVisibility ( ) ) ;
71+ return Array . from ( document . querySelectorAll ( selector ) ) . find ( element => element . checkVisibility ( ) && ! element . matches ( ':disabled' ) ) ;
6272}
6373
74+ // driver.js' own waitForElement cannot be used here: the steps are filtered and their duplicate
75+ // anchors resolved to the visible instance before the tour is built
6476async function waitForVisibleElement ( selector : string ) : Promise < boolean > {
6577 for ( let attempt = 0 ; attempt < 80 && ! findVisibleElement ( selector ) ; attempt ++ ) {
6678 await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
@@ -85,7 +97,7 @@ export async function startOnboardingIfNeeded(userId: string) {
8597 * @param userId the id of the currently logged in user
8698 */
8799export async function startOnboarding ( userId : string ) {
88- await driveTour ( tourSteps ( ( await auth ) . hasRole ( 'create-vaults' ) ) , ( ) => setOnboardingCompleted ( userId ) ) ;
100+ await driveTour ( tourSteps ( ( await auth ) . hasRole ( 'create-vaults' ) ) , ( ) => completeOnboarding ( userId ) ) ;
89101}
90102
91103/**
@@ -96,70 +108,66 @@ export function stopOnboarding() {
96108 const tour = activeTour ;
97109 activeTour = undefined ;
98110 tour ?. destroy ( ) ;
111+ // driver.js skips onDestroyed during its highlight transition, so do not rely on it for cleanup
112+ unmountContent ( ) ;
99113}
100114
101- let activeTour : ReturnType < typeof driver > | undefined ;
102-
103- // driver.js rebuilds the popover per step, so each render unmounts the previous step's content app;
104- // module scope lets a replayed tour clean up a predecessor whose onDestroyed hook driver.js skipped
105- let contentApp : App | undefined ;
106-
107- function unmountContent ( ) {
108- contentApp ?. unmount ( ) ;
109- contentApp = undefined ;
110- }
111-
112- async function driveTour ( includedSteps : TourStep [ ] , onTourDestroyed ?: ( ) => void ) {
115+ async function driveTour ( includedSteps : TourStep [ ] , onTourDestroyed : ( ) => void ) {
113116 const { t } = i18n . global ;
114117 if ( ! await waitForVisibleElement ( vaultListTarget ) ) {
115118 console . warn ( 'Onboarding tour not started because the vault list did not become visible' ) ;
116119 return ;
117120 }
121+ // concurrent starts (e.g. a replay while the auto-start is still waiting) converge on the last caller
118122 stopOnboarding ( ) ;
119123
120124 const steps : DriveStep [ ] = [ ] ;
125+ const titles : string [ ] = [ ] ;
121126 const contents : TourPopoverProps [ ] = [ ] ;
122127 for ( const step of includedSteps ) {
123128 const element = step . target ? findVisibleElement ( step . target ) : undefined ;
124- if ( step . target && ! element ) {
129+ if ( step . target && ! element && ! step . showWithoutTarget ) {
125130 continue ;
126131 }
127- // the blank placeholder keeps driver.js from hiding the description element it hands to onPopoverRender
128- steps . push ( { element, popover : { title : t ( step . titleKey ) , description : ' ' } } ) ;
129- contents . push ( { content : step . vignette , text : step . descriptionKey ? t ( step . descriptionKey ) : undefined } ) ;
132+ // blank placeholders keep driver.js from hiding the title and description elements it hands to onPopoverRender
133+ steps . push ( { element, popover : { title : ' ' , description : ' ' } } ) ;
134+ titles . push ( t ( step . titleKey ) ) ;
135+ contents . push ( { vignette : step . vignette , text : step . descriptionKey ? t ( step . descriptionKey ) : undefined } ) ;
130136 }
131137
132- activeTour = driver ( {
138+ const tour = driver ( {
133139 popoverClass : 'onboarding-popover' ,
134- overlayColor : 'var(--color-gray-500)' ,
140+ overlayColor : 'var(--color-gray-500, #6b7280 )' ,
135141 overlayOpacity : 0.75 ,
136142 stagePadding : 8 ,
137143 stageRadius : 8 ,
138144 showProgress : true ,
139- // driver.js substitutes {{current}} and {{total}} itself, vue-i18n only places them in the message
140- progressText : t ( 'onboarding.progress' , [ '{{current}}' , '{{total}}' ] ) ,
141- nextBtnText : t ( 'common.next' ) ,
142- prevBtnText : t ( 'common.previous' ) ,
143- doneBtnText : t ( 'onboarding.done' ) ,
144145 onPopoverRender : ( popover , { state } ) => {
145- popover . closeButton . setAttribute ( 'aria-label' , t ( 'common.close' ) ) ;
146146 unmountContent ( ) ;
147- const content = state . activeIndex !== undefined ? contents [ state . activeIndex ] : undefined ;
148- if ( ! content ) {
147+ const index = state . activeIndex ;
148+ const content = index !== undefined ? contents [ index ] : undefined ;
149+ if ( index === undefined || ! content ) {
149150 return ;
150151 }
152+ // driver.js assigns all of these via innerHTML, so set them as text to keep translations inert
153+ popover . title . textContent = titles [ index ] ;
154+ popover . progress . textContent = t ( 'onboarding.progress' , [ index + 1 , steps . length ] ) ;
155+ popover . previousButton . textContent = t ( 'common.previous' ) ;
156+ popover . nextButton . textContent = index === steps . length - 1 ? t ( 'onboarding.done' ) : t ( 'common.next' ) ;
157+ popover . closeButton . setAttribute ( 'aria-label' , t ( 'common.close' ) ) ;
151158 contentApp = createApp ( TourPopoverContent , content ) ;
152159 contentApp . mount ( popover . description ) ;
153160 } ,
154161 onDestroyed : ( ) => {
155162 unmountContent ( ) ;
156163 // a tour cancelled via stopOnboarding() has already been detached and must not count as completed
157- if ( activeTour !== undefined ) {
164+ if ( activeTour === tour ) {
158165 activeTour = undefined ;
159- onTourDestroyed ?. ( ) ;
166+ onTourDestroyed ( ) ;
160167 }
161168 } ,
162169 steps
163170 } ) ;
164- activeTour . drive ( ) ;
171+ activeTour = tour ;
172+ tour . drive ( ) ;
165173}
0 commit comments