@nextsparkjs/plugin-walkme 0.1.0-beta.104
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/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +625 -0
- package/components/WalkmeBeacon.tsx +64 -0
- package/components/WalkmeControls.tsx +111 -0
- package/components/WalkmeModal.tsx +144 -0
- package/components/WalkmeOverlay.tsx +107 -0
- package/components/WalkmeProgress.tsx +53 -0
- package/components/WalkmeProvider.tsx +674 -0
- package/components/WalkmeSpotlight.tsx +188 -0
- package/components/WalkmeTooltip.tsx +152 -0
- package/examples/basic-tour.ts +38 -0
- package/examples/conditional-tour.ts +56 -0
- package/examples/cross-window-tour.ts +54 -0
- package/hooks/useTour.ts +52 -0
- package/hooks/useTourProgress.ts +38 -0
- package/hooks/useTourState.ts +146 -0
- package/hooks/useWalkme.ts +52 -0
- package/jest.config.cjs +27 -0
- package/lib/conditions.ts +113 -0
- package/lib/core.ts +323 -0
- package/lib/plugin-env.ts +87 -0
- package/lib/positioning.ts +172 -0
- package/lib/storage.ts +203 -0
- package/lib/targeting.ts +186 -0
- package/lib/triggers.ts +127 -0
- package/lib/validation.ts +122 -0
- package/messages/en.json +21 -0
- package/messages/es.json +21 -0
- package/package.json +18 -0
- package/plugin.config.ts +26 -0
- package/providers/walkme-context.ts +17 -0
- package/tests/lib/conditions.test.ts +172 -0
- package/tests/lib/core.test.ts +514 -0
- package/tests/lib/positioning.test.ts +43 -0
- package/tests/lib/storage.test.ts +232 -0
- package/tests/lib/targeting.test.ts +191 -0
- package/tests/lib/triggers.test.ts +198 -0
- package/tests/lib/validation.test.ts +249 -0
- package/tests/setup.ts +52 -0
- package/tests/tsconfig.json +32 -0
- package/tsconfig.json +47 -0
- package/types/walkme.types.ts +316 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useReducer, useEffect, useRef, useCallback } from 'react'
|
|
4
|
+
import type { Tour, WalkmeState, WalkmeAction } from '../types/walkme.types'
|
|
5
|
+
import { createInitialState, walkmeReducer } from '../lib/core'
|
|
6
|
+
import { createStorageAdapter } from '../lib/storage'
|
|
7
|
+
|
|
8
|
+
interface UseTourStateOptions {
|
|
9
|
+
persistState: boolean
|
|
10
|
+
debug: boolean
|
|
11
|
+
userId?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Internal hook that manages the core WalkMe state machine.
|
|
16
|
+
* Handles useReducer, localStorage persistence, and initial state loading.
|
|
17
|
+
*/
|
|
18
|
+
export function useTourState(tours: Tour[], options: UseTourStateOptions) {
|
|
19
|
+
const { persistState, debug, userId } = options
|
|
20
|
+
const [state, dispatch] = useReducer(walkmeReducer, createInitialState())
|
|
21
|
+
const storageRef = useRef(createStorageAdapter(userId))
|
|
22
|
+
const initialized = useRef(false)
|
|
23
|
+
const prevUserIdRef = useRef(userId)
|
|
24
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
25
|
+
|
|
26
|
+
// Re-create storage adapter when userId changes (e.g. session loads async)
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (prevUserIdRef.current === userId) return
|
|
29
|
+
prevUserIdRef.current = userId
|
|
30
|
+
storageRef.current = createStorageAdapter(userId)
|
|
31
|
+
|
|
32
|
+
// Re-restore persisted state from the new user-scoped storage
|
|
33
|
+
if (persistState) {
|
|
34
|
+
const saved = storageRef.current.load()
|
|
35
|
+
if (saved) {
|
|
36
|
+
dispatch({
|
|
37
|
+
type: 'RESTORE_STATE',
|
|
38
|
+
completedTours: saved.completedTours,
|
|
39
|
+
skippedTours: saved.skippedTours,
|
|
40
|
+
tourHistory: saved.tourHistory,
|
|
41
|
+
activeTour: saved.activeTour,
|
|
42
|
+
})
|
|
43
|
+
} else {
|
|
44
|
+
// New user with no state — reset to clean slate
|
|
45
|
+
dispatch({ type: 'RESET_ALL' })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, [userId, persistState])
|
|
49
|
+
|
|
50
|
+
// Initialize tours and restore persisted state on mount
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (initialized.current) return
|
|
53
|
+
initialized.current = true
|
|
54
|
+
|
|
55
|
+
// Initialize with tour definitions
|
|
56
|
+
dispatch({ type: 'INITIALIZE', tours })
|
|
57
|
+
|
|
58
|
+
if (debug) {
|
|
59
|
+
dispatch({ type: 'SET_DEBUG', enabled: true })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Restore persisted state
|
|
63
|
+
if (persistState) {
|
|
64
|
+
const storage = storageRef.current
|
|
65
|
+
const saved = storage.load()
|
|
66
|
+
|
|
67
|
+
if (saved) {
|
|
68
|
+
storage.incrementVisitCount()
|
|
69
|
+
|
|
70
|
+
dispatch({
|
|
71
|
+
type: 'RESTORE_STATE',
|
|
72
|
+
completedTours: saved.completedTours,
|
|
73
|
+
skippedTours: saved.skippedTours,
|
|
74
|
+
tourHistory: saved.tourHistory,
|
|
75
|
+
activeTour: saved.activeTour,
|
|
76
|
+
})
|
|
77
|
+
} else {
|
|
78
|
+
// First visit - initialize storage
|
|
79
|
+
storage.incrementVisitCount()
|
|
80
|
+
storage.setFirstVisitDate(new Date().toISOString())
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
84
|
+
|
|
85
|
+
// Re-register tour definitions when they change after initialization.
|
|
86
|
+
// This handles the case where tours arrive asynchronously (e.g. after API fetch)
|
|
87
|
+
// and the provider was already mounted with an empty tours array.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!initialized.current) return
|
|
90
|
+
if (tours.length === 0) return
|
|
91
|
+
dispatch({ type: 'UPDATE_TOURS', tours })
|
|
92
|
+
}, [tours]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
93
|
+
|
|
94
|
+
// Persist state changes to localStorage (debounced)
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!persistState || !state.initialized) return
|
|
97
|
+
|
|
98
|
+
if (debounceRef.current) {
|
|
99
|
+
clearTimeout(debounceRef.current)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
debounceRef.current = setTimeout(() => {
|
|
103
|
+
const storage = storageRef.current
|
|
104
|
+
const existing = storage.load()
|
|
105
|
+
|
|
106
|
+
storage.save({
|
|
107
|
+
version: 1,
|
|
108
|
+
completedTours: state.completedTours,
|
|
109
|
+
skippedTours: state.skippedTours,
|
|
110
|
+
activeTour: state.activeTour,
|
|
111
|
+
tourHistory: state.tourHistory,
|
|
112
|
+
visitCount: existing?.visitCount ?? 1,
|
|
113
|
+
firstVisitDate: existing?.firstVisitDate ?? new Date().toISOString(),
|
|
114
|
+
})
|
|
115
|
+
}, 100)
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
if (debounceRef.current) {
|
|
119
|
+
clearTimeout(debounceRef.current)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}, [
|
|
123
|
+
persistState,
|
|
124
|
+
state.initialized,
|
|
125
|
+
state.completedTours,
|
|
126
|
+
state.skippedTours,
|
|
127
|
+
state.activeTour,
|
|
128
|
+
state.tourHistory,
|
|
129
|
+
])
|
|
130
|
+
|
|
131
|
+
const stableDispatch = useCallback(
|
|
132
|
+
(action: WalkmeAction) => {
|
|
133
|
+
if (debug) {
|
|
134
|
+
console.log('[WalkMe]', action.type, action)
|
|
135
|
+
}
|
|
136
|
+
dispatch(action)
|
|
137
|
+
},
|
|
138
|
+
[debug],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
state,
|
|
143
|
+
dispatch: stableDispatch,
|
|
144
|
+
storage: storageRef.current,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useWalkmeContext } from '../providers/walkme-context'
|
|
4
|
+
import { getActiveTour, getActiveStep } from '../lib/core'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Main public hook for controlling WalkMe tours.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* const { startTour, isActive, nextStep } = useWalkme()
|
|
12
|
+
*
|
|
13
|
+
* return (
|
|
14
|
+
* <button onClick={() => startTour('onboarding')}>
|
|
15
|
+
* Start Tour
|
|
16
|
+
* </button>
|
|
17
|
+
* )
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function useWalkme() {
|
|
21
|
+
const ctx = useWalkmeContext()
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
// Tour control
|
|
25
|
+
startTour: ctx.startTour,
|
|
26
|
+
pauseTour: ctx.pauseTour,
|
|
27
|
+
resumeTour: ctx.resumeTour,
|
|
28
|
+
skipTour: ctx.skipTour,
|
|
29
|
+
completeTour: ctx.completeTour,
|
|
30
|
+
resetTour: ctx.resetTour,
|
|
31
|
+
resetAllTours: ctx.resetAllTours,
|
|
32
|
+
|
|
33
|
+
// Step navigation
|
|
34
|
+
nextStep: ctx.nextStep,
|
|
35
|
+
prevStep: ctx.prevStep,
|
|
36
|
+
goToStep: ctx.goToStep,
|
|
37
|
+
|
|
38
|
+
// State queries
|
|
39
|
+
isActive: ctx.state.activeTour !== null,
|
|
40
|
+
activeTourId: ctx.state.activeTour?.tourId ?? null,
|
|
41
|
+
currentStepIndex: ctx.state.activeTour?.currentStepIndex ?? 0,
|
|
42
|
+
|
|
43
|
+
// Tour info helpers
|
|
44
|
+
getActiveTour: () => getActiveTour(ctx.state),
|
|
45
|
+
getActiveStep: () => getActiveStep(ctx.state),
|
|
46
|
+
isTourCompleted: ctx.isTourCompleted,
|
|
47
|
+
isTourActive: ctx.isTourActive,
|
|
48
|
+
|
|
49
|
+
// Custom events (for onEvent triggers)
|
|
50
|
+
emitEvent: ctx.emitEvent,
|
|
51
|
+
}
|
|
52
|
+
}
|
package/jest.config.cjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** @type {import('jest').Config} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
preset: 'ts-jest',
|
|
4
|
+
testEnvironment: 'jsdom',
|
|
5
|
+
roots: ['<rootDir>/tests'],
|
|
6
|
+
testMatch: ['**/*.test.ts', '**/*.test.tsx'],
|
|
7
|
+
moduleNameMapper: {
|
|
8
|
+
'^~/(.*)$': '<rootDir>/$1',
|
|
9
|
+
},
|
|
10
|
+
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
|
11
|
+
transform: {
|
|
12
|
+
'^.+\\.tsx?$': ['ts-jest', {
|
|
13
|
+
tsconfig: '<rootDir>/tests/tsconfig.json',
|
|
14
|
+
}],
|
|
15
|
+
},
|
|
16
|
+
transformIgnorePatterns: [
|
|
17
|
+
'/node_modules/(?!(@floating-ui)/)',
|
|
18
|
+
],
|
|
19
|
+
collectCoverageFrom: [
|
|
20
|
+
'lib/**/*.{ts,tsx}',
|
|
21
|
+
'hooks/**/*.{ts,tsx}',
|
|
22
|
+
'!**/*.d.ts',
|
|
23
|
+
'!**/index.ts',
|
|
24
|
+
],
|
|
25
|
+
coverageDirectory: 'coverage',
|
|
26
|
+
verbose: true,
|
|
27
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalkMe Conditions Module
|
|
3
|
+
*
|
|
4
|
+
* Conditional display evaluation for tours.
|
|
5
|
+
* All specified conditions must pass (AND logic).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TourConditions, ConditionContext } from '../types/walkme.types'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Main Evaluation
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Evaluate all conditions for a tour.
|
|
16
|
+
* Returns true if no conditions are specified or all conditions pass.
|
|
17
|
+
* Uses AND logic: every specified condition must be satisfied.
|
|
18
|
+
*/
|
|
19
|
+
export function evaluateConditions(
|
|
20
|
+
conditions: TourConditions | undefined,
|
|
21
|
+
context: ConditionContext,
|
|
22
|
+
): boolean {
|
|
23
|
+
if (!conditions) return true
|
|
24
|
+
|
|
25
|
+
// Check user role
|
|
26
|
+
if (conditions.userRole && conditions.userRole.length > 0) {
|
|
27
|
+
if (!evaluateRoleCondition(conditions.userRole, context.userRole)) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check feature flags
|
|
33
|
+
if (conditions.featureFlags && conditions.featureFlags.length > 0) {
|
|
34
|
+
if (
|
|
35
|
+
!evaluateFeatureFlagCondition(
|
|
36
|
+
conditions.featureFlags,
|
|
37
|
+
context.featureFlags ?? [],
|
|
38
|
+
)
|
|
39
|
+
) {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check completed tours
|
|
45
|
+
if (conditions.completedTours && conditions.completedTours.length > 0) {
|
|
46
|
+
if (
|
|
47
|
+
!evaluateCompletedToursCondition(
|
|
48
|
+
conditions.completedTours,
|
|
49
|
+
context.completedTourIds,
|
|
50
|
+
)
|
|
51
|
+
) {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check not-completed tours
|
|
57
|
+
if (conditions.notCompletedTours && conditions.notCompletedTours.length > 0) {
|
|
58
|
+
if (
|
|
59
|
+
!evaluateNotCompletedCondition(
|
|
60
|
+
conditions.notCompletedTours,
|
|
61
|
+
context.completedTourIds,
|
|
62
|
+
)
|
|
63
|
+
) {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check custom condition
|
|
69
|
+
if (conditions.custom) {
|
|
70
|
+
if (!conditions.custom(context)) {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Individual Condition Evaluators
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** User role must be in the allowed list */
|
|
83
|
+
export function evaluateRoleCondition(
|
|
84
|
+
roles: string[],
|
|
85
|
+
userRole: string | undefined,
|
|
86
|
+
): boolean {
|
|
87
|
+
if (!userRole) return false
|
|
88
|
+
return roles.includes(userRole)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** All specified feature flags must be active */
|
|
92
|
+
export function evaluateFeatureFlagCondition(
|
|
93
|
+
requiredFlags: string[],
|
|
94
|
+
activeFlags: string[],
|
|
95
|
+
): boolean {
|
|
96
|
+
return requiredFlags.every((flag) => activeFlags.includes(flag))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** All specified tours must be completed */
|
|
100
|
+
export function evaluateCompletedToursCondition(
|
|
101
|
+
requiredTours: string[],
|
|
102
|
+
completedTourIds: string[],
|
|
103
|
+
): boolean {
|
|
104
|
+
return requiredTours.every((tourId) => completedTourIds.includes(tourId))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** None of the specified tours should be completed */
|
|
108
|
+
export function evaluateNotCompletedCondition(
|
|
109
|
+
excludedTours: string[],
|
|
110
|
+
completedTourIds: string[],
|
|
111
|
+
): boolean {
|
|
112
|
+
return !excludedTours.some((tourId) => completedTourIds.includes(tourId))
|
|
113
|
+
}
|
package/lib/core.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalkMe Core Engine
|
|
3
|
+
*
|
|
4
|
+
* Pure-function state machine for managing guided tour state.
|
|
5
|
+
* All functions are side-effect free and testable without React.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Tour,
|
|
10
|
+
TourStep,
|
|
11
|
+
TourState,
|
|
12
|
+
WalkmeState,
|
|
13
|
+
WalkmeAction,
|
|
14
|
+
} from '../types/walkme.types'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Initial State
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Creates a fresh initial WalkmeState */
|
|
21
|
+
export function createInitialState(): WalkmeState {
|
|
22
|
+
return {
|
|
23
|
+
tours: {},
|
|
24
|
+
activeTour: null,
|
|
25
|
+
completedTours: [],
|
|
26
|
+
skippedTours: [],
|
|
27
|
+
tourHistory: {},
|
|
28
|
+
initialized: false,
|
|
29
|
+
debug: false,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Reducer
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** Main reducer for the WalkMe state machine */
|
|
38
|
+
export function walkmeReducer(state: WalkmeState, action: WalkmeAction): WalkmeState {
|
|
39
|
+
switch (action.type) {
|
|
40
|
+
case 'INITIALIZE': {
|
|
41
|
+
const tours: Record<string, Tour> = {}
|
|
42
|
+
for (const tour of action.tours) {
|
|
43
|
+
tours[tour.id] = tour
|
|
44
|
+
}
|
|
45
|
+
return { ...state, tours, initialized: true }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'UPDATE_TOURS': {
|
|
49
|
+
const tours: Record<string, Tour> = { ...state.tours }
|
|
50
|
+
for (const tour of action.tours) {
|
|
51
|
+
tours[tour.id] = tour
|
|
52
|
+
}
|
|
53
|
+
return { ...state, tours }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'START_TOUR': {
|
|
57
|
+
if (state.activeTour) {
|
|
58
|
+
if (state.debug) {
|
|
59
|
+
console.warn(
|
|
60
|
+
`[WalkMe] Cannot start tour "${action.tourId}" — tour "${state.activeTour.tourId}" is already active. Complete or skip it first.`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return state
|
|
64
|
+
}
|
|
65
|
+
const tour = state.tours[action.tourId]
|
|
66
|
+
if (!tour) return state
|
|
67
|
+
if (tour.steps.length === 0) return state
|
|
68
|
+
|
|
69
|
+
const tourState: TourState = {
|
|
70
|
+
tourId: action.tourId,
|
|
71
|
+
status: 'active',
|
|
72
|
+
currentStepIndex: 0,
|
|
73
|
+
startedAt: new Date().toISOString(),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
...state,
|
|
78
|
+
activeTour: tourState,
|
|
79
|
+
tourHistory: { ...state.tourHistory, [action.tourId]: tourState },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
case 'NEXT_STEP': {
|
|
84
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return state
|
|
85
|
+
const tour = state.tours[state.activeTour.tourId]
|
|
86
|
+
if (!tour) return state
|
|
87
|
+
|
|
88
|
+
const nextIndex = state.activeTour.currentStepIndex + 1
|
|
89
|
+
|
|
90
|
+
// If we've passed the last step, complete the tour
|
|
91
|
+
if (nextIndex >= tour.steps.length) {
|
|
92
|
+
return completeTourState(state)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const updatedTour: TourState = {
|
|
96
|
+
...state.activeTour,
|
|
97
|
+
currentStepIndex: nextIndex,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
...state,
|
|
102
|
+
activeTour: updatedTour,
|
|
103
|
+
tourHistory: { ...state.tourHistory, [updatedTour.tourId]: updatedTour },
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'PREV_STEP': {
|
|
108
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return state
|
|
109
|
+
if (state.activeTour.currentStepIndex <= 0) return state
|
|
110
|
+
|
|
111
|
+
const updatedTour: TourState = {
|
|
112
|
+
...state.activeTour,
|
|
113
|
+
currentStepIndex: state.activeTour.currentStepIndex - 1,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
...state,
|
|
118
|
+
activeTour: updatedTour,
|
|
119
|
+
tourHistory: { ...state.tourHistory, [updatedTour.tourId]: updatedTour },
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case 'NAVIGATE_TO_STEP': {
|
|
124
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return state
|
|
125
|
+
const tour = state.tours[state.activeTour.tourId]
|
|
126
|
+
if (!tour) return state
|
|
127
|
+
if (action.stepIndex < 0 || action.stepIndex >= tour.steps.length) return state
|
|
128
|
+
|
|
129
|
+
const updatedTour: TourState = {
|
|
130
|
+
...state.activeTour,
|
|
131
|
+
currentStepIndex: action.stepIndex,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
...state,
|
|
136
|
+
activeTour: updatedTour,
|
|
137
|
+
tourHistory: { ...state.tourHistory, [updatedTour.tourId]: updatedTour },
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case 'SKIP_TOUR': {
|
|
142
|
+
if (!state.activeTour) return state
|
|
143
|
+
|
|
144
|
+
const tourId = state.activeTour.tourId
|
|
145
|
+
const skippedState: TourState = {
|
|
146
|
+
...state.activeTour,
|
|
147
|
+
status: 'skipped',
|
|
148
|
+
skippedAt: new Date().toISOString(),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
...state,
|
|
153
|
+
activeTour: null,
|
|
154
|
+
skippedTours: state.skippedTours.includes(tourId)
|
|
155
|
+
? state.skippedTours
|
|
156
|
+
: [...state.skippedTours, tourId],
|
|
157
|
+
tourHistory: { ...state.tourHistory, [tourId]: skippedState },
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case 'COMPLETE_TOUR': {
|
|
162
|
+
if (!state.activeTour) return state
|
|
163
|
+
return completeTourState(state)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case 'PAUSE_TOUR': {
|
|
167
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return state
|
|
168
|
+
|
|
169
|
+
const paused: TourState = {
|
|
170
|
+
...state.activeTour,
|
|
171
|
+
status: 'paused',
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
...state,
|
|
176
|
+
activeTour: paused,
|
|
177
|
+
tourHistory: { ...state.tourHistory, [paused.tourId]: paused },
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'RESUME_TOUR': {
|
|
182
|
+
if (!state.activeTour || state.activeTour.status !== 'paused') return state
|
|
183
|
+
|
|
184
|
+
const resumed: TourState = {
|
|
185
|
+
...state.activeTour,
|
|
186
|
+
status: 'active',
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
...state,
|
|
191
|
+
activeTour: resumed,
|
|
192
|
+
tourHistory: { ...state.tourHistory, [resumed.tourId]: resumed },
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case 'RESET_TOUR': {
|
|
197
|
+
const { [action.tourId]: _, ...remainingHistory } = state.tourHistory
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
...state,
|
|
201
|
+
activeTour:
|
|
202
|
+
state.activeTour?.tourId === action.tourId ? null : state.activeTour,
|
|
203
|
+
completedTours: state.completedTours.filter((id) => id !== action.tourId),
|
|
204
|
+
skippedTours: state.skippedTours.filter((id) => id !== action.tourId),
|
|
205
|
+
tourHistory: remainingHistory,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case 'RESET_ALL': {
|
|
210
|
+
return {
|
|
211
|
+
...state,
|
|
212
|
+
activeTour: null,
|
|
213
|
+
completedTours: [],
|
|
214
|
+
skippedTours: [],
|
|
215
|
+
tourHistory: {},
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case 'SET_DEBUG': {
|
|
220
|
+
return { ...state, debug: action.enabled }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case 'RESTORE_STATE': {
|
|
224
|
+
return {
|
|
225
|
+
...state,
|
|
226
|
+
completedTours: action.completedTours,
|
|
227
|
+
skippedTours: action.skippedTours,
|
|
228
|
+
tourHistory: action.tourHistory,
|
|
229
|
+
activeTour: action.activeTour,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
default:
|
|
234
|
+
return state
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Helpers
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
function completeTourState(state: WalkmeState): WalkmeState {
|
|
243
|
+
if (!state.activeTour) return state
|
|
244
|
+
|
|
245
|
+
const tourId = state.activeTour.tourId
|
|
246
|
+
const completedState: TourState = {
|
|
247
|
+
...state.activeTour,
|
|
248
|
+
status: 'completed',
|
|
249
|
+
completedAt: new Date().toISOString(),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
...state,
|
|
254
|
+
activeTour: null,
|
|
255
|
+
completedTours: state.completedTours.includes(tourId)
|
|
256
|
+
? state.completedTours
|
|
257
|
+
: [...state.completedTours, tourId],
|
|
258
|
+
tourHistory: { ...state.tourHistory, [tourId]: completedState },
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Check if a tour can be started */
|
|
263
|
+
export function canStartTour(state: WalkmeState, tourId: string): boolean {
|
|
264
|
+
if (state.activeTour) return false
|
|
265
|
+
const tour = state.tours[tourId]
|
|
266
|
+
if (!tour) return false
|
|
267
|
+
if (tour.steps.length === 0) return false
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Get the full Tour object for the currently active tour */
|
|
272
|
+
export function getActiveTour(state: WalkmeState): Tour | null {
|
|
273
|
+
if (!state.activeTour) return null
|
|
274
|
+
return state.tours[state.activeTour.tourId] ?? null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Get the current TourStep for the active tour */
|
|
278
|
+
export function getActiveStep(state: WalkmeState): TourStep | null {
|
|
279
|
+
const tour = getActiveTour(state)
|
|
280
|
+
if (!tour || !state.activeTour) return null
|
|
281
|
+
return tour.steps[state.activeTour.currentStepIndex] ?? null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Check if the current step is the first step */
|
|
285
|
+
export function isFirstStep(state: WalkmeState): boolean {
|
|
286
|
+
if (!state.activeTour) return false
|
|
287
|
+
return state.activeTour.currentStepIndex === 0
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Check if the current step is the last step */
|
|
291
|
+
export function isLastStep(state: WalkmeState): boolean {
|
|
292
|
+
if (!state.activeTour) return false
|
|
293
|
+
const tour = state.tours[state.activeTour.tourId]
|
|
294
|
+
if (!tour) return false
|
|
295
|
+
return state.activeTour.currentStepIndex === tour.steps.length - 1
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Get progress for a specific tour */
|
|
299
|
+
export function getTourProgress(
|
|
300
|
+
state: WalkmeState,
|
|
301
|
+
tourId: string,
|
|
302
|
+
): { current: number; total: number; percentage: number } {
|
|
303
|
+
const tour = state.tours[tourId]
|
|
304
|
+
if (!tour) return { current: 0, total: 0, percentage: 0 }
|
|
305
|
+
|
|
306
|
+
const total = tour.steps.length
|
|
307
|
+
const isActive = state.activeTour?.tourId === tourId
|
|
308
|
+
const current = isActive ? state.activeTour!.currentStepIndex + 1 : 0
|
|
309
|
+
const percentage = total > 0 ? Math.round((current / total) * 100) : 0
|
|
310
|
+
|
|
311
|
+
return { current, total, percentage }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Get global progress across all tours */
|
|
315
|
+
export function getGlobalProgress(
|
|
316
|
+
state: WalkmeState,
|
|
317
|
+
): { completed: number; total: number; percentage: number } {
|
|
318
|
+
const total = Object.keys(state.tours).length
|
|
319
|
+
const completed = state.completedTours.length
|
|
320
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0
|
|
321
|
+
|
|
322
|
+
return { completed, total, percentage }
|
|
323
|
+
}
|