@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,127 @@
1
+ /**
2
+ * WalkMe Triggers Module
3
+ *
4
+ * Tour trigger evaluation system.
5
+ * Determines when a tour should be automatically activated.
6
+ */
7
+
8
+ import type { Tour, TourTrigger } from '../types/walkme.types'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface TriggerEvaluationContext {
15
+ /** Current page route/pathname */
16
+ currentRoute: string
17
+ /** Total number of page visits */
18
+ visitCount: number
19
+ /** ISO date string of first visit */
20
+ firstVisitDate: string | null
21
+ /** IDs of completed tours */
22
+ completedTourIds: string[]
23
+ /** Set of custom events that have been emitted */
24
+ customEvents: Set<string>
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Main Evaluation
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Evaluate whether a tour should be triggered based on its trigger config
33
+ * and the current context. Does NOT check conditions (that's a separate step).
34
+ */
35
+ export function shouldTriggerTour(
36
+ tour: Tour,
37
+ context: TriggerEvaluationContext,
38
+ ): boolean {
39
+ const { trigger } = tour
40
+
41
+ switch (trigger.type) {
42
+ case 'onFirstVisit':
43
+ return evaluateOnFirstVisit(trigger, context)
44
+ case 'onRouteEnter':
45
+ return evaluateOnRouteEnter(trigger, context)
46
+ case 'onEvent':
47
+ return evaluateOnEvent(trigger, context)
48
+ case 'manual':
49
+ return false // Manual tours are started programmatically only
50
+ case 'scheduled':
51
+ return evaluateScheduled(trigger, context)
52
+ default:
53
+ return false
54
+ }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Individual Trigger Evaluators
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** First visit: triggers only when visitCount === 1 */
62
+ export function evaluateOnFirstVisit(
63
+ _trigger: TourTrigger,
64
+ context: TriggerEvaluationContext,
65
+ ): boolean {
66
+ return context.visitCount === 1
67
+ }
68
+
69
+ /** Route enter: triggers when current route matches the trigger's route pattern */
70
+ export function evaluateOnRouteEnter(
71
+ trigger: TourTrigger,
72
+ context: TriggerEvaluationContext,
73
+ ): boolean {
74
+ if (!trigger.route) return false
75
+
76
+ const pattern = trigger.route
77
+ const route = context.currentRoute
78
+
79
+ // Exact match
80
+ if (pattern === route) return true
81
+
82
+ // Wildcard match: /dashboard/* matches /dashboard/anything
83
+ if (pattern.endsWith('/*')) {
84
+ const prefix = pattern.slice(0, -2)
85
+ return route.startsWith(prefix)
86
+ }
87
+
88
+ // Glob match: /dashboard/** matches /dashboard/a/b/c
89
+ if (pattern.endsWith('/**')) {
90
+ const prefix = pattern.slice(0, -3)
91
+ return route.startsWith(prefix)
92
+ }
93
+
94
+ return false
95
+ }
96
+
97
+ /** Event trigger: activates when a specific custom event has been emitted */
98
+ export function evaluateOnEvent(
99
+ trigger: TourTrigger,
100
+ context: TriggerEvaluationContext,
101
+ ): boolean {
102
+ if (!trigger.event) return false
103
+ return context.customEvents.has(trigger.event)
104
+ }
105
+
106
+ /** Scheduled trigger: activates after N visits or N days since first visit */
107
+ export function evaluateScheduled(
108
+ trigger: TourTrigger,
109
+ context: TriggerEvaluationContext,
110
+ ): boolean {
111
+ // Check visit-based threshold
112
+ if (trigger.afterVisits !== undefined) {
113
+ if (context.visitCount >= trigger.afterVisits) return true
114
+ }
115
+
116
+ // Check day-based threshold
117
+ if (trigger.afterDays !== undefined && context.firstVisitDate) {
118
+ const firstVisit = new Date(context.firstVisitDate)
119
+ const now = new Date()
120
+ const daysSinceFirstVisit = Math.floor(
121
+ (now.getTime() - firstVisit.getTime()) / (1000 * 60 * 60 * 24),
122
+ )
123
+ if (daysSinceFirstVisit >= trigger.afterDays) return true
124
+ }
125
+
126
+ return false
127
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * WalkMe Validation Module
3
+ *
4
+ * Zod schemas for runtime validation of tour configurations.
5
+ * Ensures tour definitions are well-formed before they're used.
6
+ */
7
+
8
+ import { z } from 'zod'
9
+ import type { Tour, TourStep as TourStepType } from '../types/walkme.types'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Schemas
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const TourTriggerSchema = z.object({
16
+ type: z.enum(['onFirstVisit', 'onRouteEnter', 'onEvent', 'manual', 'scheduled']),
17
+ delay: z.number().min(0).optional(),
18
+ route: z.string().optional(),
19
+ event: z.string().optional(),
20
+ afterVisits: z.number().min(1).optional(),
21
+ afterDays: z.number().min(0).optional(),
22
+ })
23
+
24
+ export const TourConditionsSchema = z.object({
25
+ userRole: z.array(z.string()).optional(),
26
+ featureFlags: z.array(z.string()).optional(),
27
+ completedTours: z.array(z.string()).optional(),
28
+ notCompletedTours: z.array(z.string()).optional(),
29
+ custom: z.function().optional(),
30
+ }).optional()
31
+
32
+ export const TourStepSchema = z.object({
33
+ id: z.string().min(1),
34
+ type: z.enum(['tooltip', 'modal', 'spotlight', 'beacon', 'floating']),
35
+ title: z.string().min(1),
36
+ content: z.string(),
37
+ target: z.string().optional(),
38
+ route: z.string().optional(),
39
+ position: z.enum(['top', 'bottom', 'left', 'right', 'auto']).optional(),
40
+ actions: z.array(z.enum(['next', 'prev', 'skip', 'complete', 'close'])).min(1),
41
+ delay: z.number().min(0).optional(),
42
+ autoAdvance: z.number().min(0).optional(),
43
+ beforeShow: z.function().optional(),
44
+ afterShow: z.function().optional(),
45
+ }).refine(
46
+ (step) => {
47
+ // tooltip, spotlight, and beacon require a target
48
+ if (['tooltip', 'spotlight', 'beacon'].includes(step.type)) {
49
+ return !!step.target
50
+ }
51
+ return true
52
+ },
53
+ {
54
+ message: 'Steps of type tooltip, spotlight, and beacon require a target selector',
55
+ },
56
+ )
57
+
58
+ export const TourSchema = z.object({
59
+ id: z.string().min(1),
60
+ name: z.string().min(1),
61
+ description: z.string().optional(),
62
+ trigger: TourTriggerSchema,
63
+ conditions: TourConditionsSchema,
64
+ steps: z.array(TourStepSchema).min(1),
65
+ onComplete: z.function().optional(),
66
+ onSkip: z.function().optional(),
67
+ priority: z.number().optional(),
68
+ })
69
+
70
+ export const TourArraySchema = z.array(TourSchema)
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Validation Functions
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Validate a single tour configuration */
77
+ export function validateTour(
78
+ tour: unknown,
79
+ ): { valid: boolean; errors?: z.ZodError; tour?: Tour } {
80
+ const result = TourSchema.safeParse(tour)
81
+ if (result.success) {
82
+ // Zod's output matches our Tour type structurally; functions (onComplete,
83
+ // onSkip, beforeShow, afterShow, custom) are validated as z.function() but
84
+ // their signatures aren't preserved in the inferred type, so we cast once here.
85
+ return { valid: true, tour: result.data as Tour }
86
+ }
87
+ return { valid: false, errors: result.error }
88
+ }
89
+
90
+ /** Validate an array of tours, returning only the valid ones */
91
+ export function validateTours(
92
+ tours: unknown[],
93
+ ): { valid: boolean; errors?: z.ZodError[]; validTours: Tour[] } {
94
+ const validTours: Tour[] = []
95
+ const errors: z.ZodError[] = []
96
+
97
+ for (const tour of tours) {
98
+ const result = TourSchema.safeParse(tour)
99
+ if (result.success) {
100
+ validTours.push(result.data as Tour)
101
+ } else {
102
+ errors.push(result.error)
103
+ }
104
+ }
105
+
106
+ return {
107
+ valid: errors.length === 0,
108
+ errors: errors.length > 0 ? errors : undefined,
109
+ validTours,
110
+ }
111
+ }
112
+
113
+ /** Validate a single step configuration */
114
+ export function validateStep(
115
+ step: unknown,
116
+ ): { valid: boolean; errors?: z.ZodError; step?: TourStepType } {
117
+ const result = TourStepSchema.safeParse(step)
118
+ if (result.success) {
119
+ return { valid: true, step: result.data as TourStepType }
120
+ }
121
+ return { valid: false, errors: result.error }
122
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "walkme": {
3
+ "next": "Next",
4
+ "prev": "Previous",
5
+ "skip": "Skip tour",
6
+ "complete": "Complete",
7
+ "close": "Close",
8
+ "progress": "Step {current} of {total}",
9
+ "tourAvailable": "Tour available",
10
+ "beaconLabel": "Click to start guided tour",
11
+ "modalTitle": "Guided Tour",
12
+ "tooltipLabel": "Tour step",
13
+ "spotlightLabel": "Highlighted element",
14
+ "keyboardHint": "Press Arrow Right for next step, Escape to skip",
15
+ "tourCompleted": "Tour completed!",
16
+ "tourSkipped": "Tour skipped",
17
+ "errorTargetNotFound": "Element not found, skipping step",
18
+ "resumeTour": "Resume tour",
19
+ "restartTour": "Restart tour"
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "walkme": {
3
+ "next": "Siguiente",
4
+ "prev": "Anterior",
5
+ "skip": "Saltar tour",
6
+ "complete": "Completar",
7
+ "close": "Cerrar",
8
+ "progress": "Paso {current} de {total}",
9
+ "tourAvailable": "Tour disponible",
10
+ "beaconLabel": "Haz clic para iniciar el tour guiado",
11
+ "modalTitle": "Tour Guiado",
12
+ "tooltipLabel": "Paso del tour",
13
+ "spotlightLabel": "Elemento destacado",
14
+ "keyboardHint": "Presiona flecha derecha para siguiente paso, Escape para saltar",
15
+ "tourCompleted": "Tour completado!",
16
+ "tourSkipped": "Tour saltado",
17
+ "errorTargetNotFound": "Elemento no encontrado, saltando paso",
18
+ "resumeTour": "Reanudar tour",
19
+ "restartTour": "Reiniciar tour"
20
+ }
21
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@nextsparkjs/plugin-walkme",
3
+ "version": "0.1.0-beta.104",
4
+ "private": false,
5
+ "main": "./plugin.config.ts",
6
+ "requiredPlugins": [],
7
+ "dependencies": {},
8
+ "peerDependencies": {
9
+ "@phosphor-icons/react": "^2.0.0",
10
+ "next": "^15.0.0",
11
+ "react": "^19.0.0",
12
+ "react-dom": "^19.0.0"
13
+ },
14
+ "nextspark": {
15
+ "type": "plugin",
16
+ "name": "walkme"
17
+ }
18
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * WalkMe Plugin Configuration
3
+ *
4
+ * Guided tours and onboarding system for NextSpark applications.
5
+ * Provides declarative tour definitions, multi-step tooltips, modals,
6
+ * spotlights, beacons, cross-page navigation, and full persistence.
7
+ */
8
+
9
+ import type { PluginConfig } from '@nextsparkjs/core/types/plugin'
10
+
11
+ export const walkmePluginConfig: PluginConfig = {
12
+ name: 'walkme',
13
+ displayName: 'WalkMe',
14
+ version: '1.0.0',
15
+ description: 'Guided tours and onboarding system for NextSpark applications',
16
+ enabled: true,
17
+ dependencies: [],
18
+ api: {},
19
+ hooks: {
20
+ onLoad: async () => {
21
+ console.log('[WalkMe Plugin] Loaded - guided tours system ready')
22
+ },
23
+ },
24
+ }
25
+
26
+ export default walkmePluginConfig
@@ -0,0 +1,17 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext } from 'react'
4
+ import type { WalkmeContextValue } from '../types/walkme.types'
5
+
6
+ export const WalkmeContext = createContext<WalkmeContextValue | null>(null)
7
+
8
+ export function useWalkmeContext(): WalkmeContextValue {
9
+ const context = useContext(WalkmeContext)
10
+ if (!context) {
11
+ throw new Error(
12
+ 'useWalkmeContext must be used within a <WalkmeProvider>. ' +
13
+ 'Wrap your app or page with <WalkmeProvider tours={[...]}> to use WalkMe hooks.',
14
+ )
15
+ }
16
+ return context
17
+ }
@@ -0,0 +1,172 @@
1
+ import type { ConditionContext, TourConditions } from '../../types/walkme.types'
2
+ import {
3
+ evaluateConditions,
4
+ evaluateRoleCondition,
5
+ evaluateFeatureFlagCondition,
6
+ evaluateCompletedToursCondition,
7
+ evaluateNotCompletedCondition,
8
+ } from '../../lib/conditions'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Fixtures
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function createContext(overrides: Partial<ConditionContext> = {}): ConditionContext {
15
+ return {
16
+ userRole: 'user',
17
+ featureFlags: [],
18
+ completedTourIds: [],
19
+ visitCount: 1,
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // evaluateConditions (integration)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe('evaluateConditions', () => {
29
+ it('returns true when conditions is undefined', () => {
30
+ expect(evaluateConditions(undefined, createContext())).toBe(true)
31
+ })
32
+
33
+ it('returns true when conditions is empty object', () => {
34
+ expect(evaluateConditions({}, createContext())).toBe(true)
35
+ })
36
+
37
+ it('returns true when all conditions pass', () => {
38
+ const conditions: TourConditions = {
39
+ userRole: ['admin', 'superadmin'],
40
+ featureFlags: ['feature-a'],
41
+ completedTours: ['onboarding'],
42
+ }
43
+ const ctx = createContext({
44
+ userRole: 'admin',
45
+ featureFlags: ['feature-a', 'feature-b'],
46
+ completedTourIds: ['onboarding'],
47
+ })
48
+ expect(evaluateConditions(conditions, ctx)).toBe(true)
49
+ })
50
+
51
+ it('returns false when role check fails (AND logic)', () => {
52
+ const conditions: TourConditions = {
53
+ userRole: ['admin'],
54
+ featureFlags: ['feature-a'],
55
+ }
56
+ const ctx = createContext({
57
+ userRole: 'user',
58
+ featureFlags: ['feature-a'],
59
+ })
60
+ expect(evaluateConditions(conditions, ctx)).toBe(false)
61
+ })
62
+
63
+ it('returns false when feature flag check fails', () => {
64
+ const conditions: TourConditions = {
65
+ featureFlags: ['feature-x'],
66
+ }
67
+ const ctx = createContext({ featureFlags: ['feature-y'] })
68
+ expect(evaluateConditions(conditions, ctx)).toBe(false)
69
+ })
70
+
71
+ it('returns false when completedTours check fails', () => {
72
+ const conditions: TourConditions = {
73
+ completedTours: ['required-tour'],
74
+ }
75
+ const ctx = createContext({ completedTourIds: [] })
76
+ expect(evaluateConditions(conditions, ctx)).toBe(false)
77
+ })
78
+
79
+ it('returns false when notCompletedTours check fails', () => {
80
+ const conditions: TourConditions = {
81
+ notCompletedTours: ['excluded-tour'],
82
+ }
83
+ const ctx = createContext({ completedTourIds: ['excluded-tour'] })
84
+ expect(evaluateConditions(conditions, ctx)).toBe(false)
85
+ })
86
+
87
+ it('evaluates custom condition function', () => {
88
+ const conditions: TourConditions = {
89
+ custom: (ctx) => ctx.visitCount > 3,
90
+ }
91
+ expect(evaluateConditions(conditions, createContext({ visitCount: 5 }))).toBe(true)
92
+ expect(evaluateConditions(conditions, createContext({ visitCount: 1 }))).toBe(false)
93
+ })
94
+
95
+ it('skips empty arrays in conditions', () => {
96
+ const conditions: TourConditions = {
97
+ userRole: [],
98
+ featureFlags: [],
99
+ completedTours: [],
100
+ notCompletedTours: [],
101
+ }
102
+ expect(evaluateConditions(conditions, createContext())).toBe(true)
103
+ })
104
+ })
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // evaluateRoleCondition
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('evaluateRoleCondition', () => {
111
+ it('returns true when user role is in allowed list', () => {
112
+ expect(evaluateRoleCondition(['admin', 'editor'], 'admin')).toBe(true)
113
+ })
114
+
115
+ it('returns false when user role is not in allowed list', () => {
116
+ expect(evaluateRoleCondition(['admin', 'editor'], 'user')).toBe(false)
117
+ })
118
+
119
+ it('returns false when user role is undefined', () => {
120
+ expect(evaluateRoleCondition(['admin'], undefined)).toBe(false)
121
+ })
122
+ })
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // evaluateFeatureFlagCondition
126
+ // ---------------------------------------------------------------------------
127
+
128
+ describe('evaluateFeatureFlagCondition', () => {
129
+ it('returns true when all required flags are active', () => {
130
+ expect(evaluateFeatureFlagCondition(['a', 'b'], ['a', 'b', 'c'])).toBe(true)
131
+ })
132
+
133
+ it('returns false when a required flag is missing', () => {
134
+ expect(evaluateFeatureFlagCondition(['a', 'b'], ['a'])).toBe(false)
135
+ })
136
+
137
+ it('returns true when required flags is empty', () => {
138
+ expect(evaluateFeatureFlagCondition([], ['a'])).toBe(true)
139
+ })
140
+ })
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // evaluateCompletedToursCondition
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe('evaluateCompletedToursCondition', () => {
147
+ it('returns true when all required tours are completed', () => {
148
+ expect(evaluateCompletedToursCondition(['t1', 't2'], ['t1', 't2', 't3'])).toBe(true)
149
+ })
150
+
151
+ it('returns false when a required tour is not completed', () => {
152
+ expect(evaluateCompletedToursCondition(['t1', 't2'], ['t1'])).toBe(false)
153
+ })
154
+ })
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // evaluateNotCompletedCondition
158
+ // ---------------------------------------------------------------------------
159
+
160
+ describe('evaluateNotCompletedCondition', () => {
161
+ it('returns true when none of the excluded tours are completed', () => {
162
+ expect(evaluateNotCompletedCondition(['t1', 't2'], ['t3'])).toBe(true)
163
+ })
164
+
165
+ it('returns false when any excluded tour is completed', () => {
166
+ expect(evaluateNotCompletedCondition(['t1', 't2'], ['t2'])).toBe(false)
167
+ })
168
+
169
+ it('returns true when excluded list is empty', () => {
170
+ expect(evaluateNotCompletedCondition([], ['t1'])).toBe(true)
171
+ })
172
+ })