@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.
Files changed (43) hide show
  1. package/.env.example +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +625 -0
  4. package/components/WalkmeBeacon.tsx +64 -0
  5. package/components/WalkmeControls.tsx +111 -0
  6. package/components/WalkmeModal.tsx +144 -0
  7. package/components/WalkmeOverlay.tsx +107 -0
  8. package/components/WalkmeProgress.tsx +53 -0
  9. package/components/WalkmeProvider.tsx +674 -0
  10. package/components/WalkmeSpotlight.tsx +188 -0
  11. package/components/WalkmeTooltip.tsx +152 -0
  12. package/examples/basic-tour.ts +38 -0
  13. package/examples/conditional-tour.ts +56 -0
  14. package/examples/cross-window-tour.ts +54 -0
  15. package/hooks/useTour.ts +52 -0
  16. package/hooks/useTourProgress.ts +38 -0
  17. package/hooks/useTourState.ts +146 -0
  18. package/hooks/useWalkme.ts +52 -0
  19. package/jest.config.cjs +27 -0
  20. package/lib/conditions.ts +113 -0
  21. package/lib/core.ts +323 -0
  22. package/lib/plugin-env.ts +87 -0
  23. package/lib/positioning.ts +172 -0
  24. package/lib/storage.ts +203 -0
  25. package/lib/targeting.ts +186 -0
  26. package/lib/triggers.ts +127 -0
  27. package/lib/validation.ts +122 -0
  28. package/messages/en.json +21 -0
  29. package/messages/es.json +21 -0
  30. package/package.json +18 -0
  31. package/plugin.config.ts +26 -0
  32. package/providers/walkme-context.ts +17 -0
  33. package/tests/lib/conditions.test.ts +172 -0
  34. package/tests/lib/core.test.ts +514 -0
  35. package/tests/lib/positioning.test.ts +43 -0
  36. package/tests/lib/storage.test.ts +232 -0
  37. package/tests/lib/targeting.test.ts +191 -0
  38. package/tests/lib/triggers.test.ts +198 -0
  39. package/tests/lib/validation.test.ts +249 -0
  40. package/tests/setup.ts +52 -0
  41. package/tests/tsconfig.json +32 -0
  42. package/tsconfig.json +47 -0
  43. 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
+ }
@@ -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
+ }