@nextsparkjs/plugin-walkme 0.1.0-beta.108 → 0.1.0-beta.110
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.
|
@@ -71,6 +71,7 @@ export function WalkmeProvider({
|
|
|
71
71
|
conditionContext: externalConditionContext,
|
|
72
72
|
labels: userLabels,
|
|
73
73
|
userId,
|
|
74
|
+
serverSyncUrl,
|
|
74
75
|
}: WalkmeProviderProps) {
|
|
75
76
|
const labels = useMemo<WalkmeLabels>(
|
|
76
77
|
() => ({ ...DEFAULT_LABELS, ...userLabels }),
|
|
@@ -98,10 +99,11 @@ export function WalkmeProvider({
|
|
|
98
99
|
}, [rawTours, debug])
|
|
99
100
|
|
|
100
101
|
// Core state management
|
|
101
|
-
const { state, dispatch, storage } = useTourState(validatedTours, {
|
|
102
|
+
const { state, dispatch, storage, serverSyncPending } = useTourState(validatedTours, {
|
|
102
103
|
persistState,
|
|
103
104
|
debug,
|
|
104
105
|
userId,
|
|
106
|
+
serverSyncUrl,
|
|
105
107
|
})
|
|
106
108
|
|
|
107
109
|
// ---------------------------------------------------------------------------
|
|
@@ -234,6 +236,9 @@ export function WalkmeProvider({
|
|
|
234
236
|
|
|
235
237
|
const evaluateTriggers = useCallback(() => {
|
|
236
238
|
if (!state.initialized || state.activeTour || !autoStart) return
|
|
239
|
+
// Wait for server state before auto-triggering on fresh devices.
|
|
240
|
+
// Without this, tours flash for ~1s before server sync cancels them.
|
|
241
|
+
if (serverSyncPending) return
|
|
237
242
|
|
|
238
243
|
const triggerContext: TriggerEvaluationContext = {
|
|
239
244
|
currentRoute: pathname,
|
|
@@ -282,6 +287,7 @@ export function WalkmeProvider({
|
|
|
282
287
|
state.completedTours,
|
|
283
288
|
state.skippedTours,
|
|
284
289
|
autoStart,
|
|
290
|
+
serverSyncPending,
|
|
285
291
|
pathname,
|
|
286
292
|
storage,
|
|
287
293
|
externalConditionContext,
|
|
@@ -368,12 +374,12 @@ export function WalkmeProvider({
|
|
|
368
374
|
if (result.found && result.element) {
|
|
369
375
|
applyTarget(result.element)
|
|
370
376
|
} else {
|
|
371
|
-
waitForTarget(activeStep.target!, { timeout:
|
|
377
|
+
waitForTarget(activeStep.target!, { timeout: 20000 }).then((waitResult) => {
|
|
372
378
|
if (waitResult.found && waitResult.element) {
|
|
373
379
|
applyTarget(waitResult.element)
|
|
374
|
-
} else
|
|
380
|
+
} else {
|
|
375
381
|
console.warn(
|
|
376
|
-
`[WalkMe] Target "${activeStep.target}" not found
|
|
382
|
+
`[WalkMe] Target "${activeStep.target}" not found after 20s wait`,
|
|
377
383
|
)
|
|
378
384
|
}
|
|
379
385
|
})
|
|
@@ -438,7 +444,7 @@ export function WalkmeProvider({
|
|
|
438
444
|
}, [state.activeTour, nextStep, prevStep, skipTour])
|
|
439
445
|
|
|
440
446
|
// ---------------------------------------------------------------------------
|
|
441
|
-
// Body Scroll Lock (only for
|
|
447
|
+
// Body Scroll Lock (only for step types with overlay)
|
|
442
448
|
// ---------------------------------------------------------------------------
|
|
443
449
|
|
|
444
450
|
const activeStepType = getActiveStep(state)?.type
|
|
@@ -446,8 +452,9 @@ export function WalkmeProvider({
|
|
|
446
452
|
const isActive = state.activeTour?.status === 'active'
|
|
447
453
|
if (!isActive) return
|
|
448
454
|
|
|
449
|
-
// Only lock scroll for
|
|
450
|
-
|
|
455
|
+
// Only lock scroll for types that use an overlay (modal, floating, spotlight)
|
|
456
|
+
// Tooltip type has no overlay — scroll should remain free
|
|
457
|
+
if (!activeStepType || activeStepType === 'tooltip') return
|
|
451
458
|
|
|
452
459
|
const { style } = document.body
|
|
453
460
|
const originalOverflow = style.overflow
|
|
@@ -76,8 +76,8 @@ export const WalkmeSpotlight = memo(function WalkmeSpotlight({
|
|
|
76
76
|
|
|
77
77
|
const { refs, floatingStyles, isStable } = useStepPositioning(targetElement, {
|
|
78
78
|
placement: getPlacementFromPosition(step.position ?? 'bottom'),
|
|
79
|
-
offset:
|
|
80
|
-
padding:
|
|
79
|
+
offset: 24,
|
|
80
|
+
padding: 12,
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
useEffect(() => {
|
|
@@ -47,20 +47,44 @@ export const WalkmeTooltip = memo(function WalkmeTooltip({
|
|
|
47
47
|
}: WalkmeTooltipProps) {
|
|
48
48
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
49
49
|
|
|
50
|
-
const { refs, floatingStyles, arrowRef, placement, isStable } = useStepPositioning(
|
|
50
|
+
const { refs, floatingStyles, arrowRef, placement, middlewareData, isStable } = useStepPositioning(
|
|
51
51
|
targetElement,
|
|
52
52
|
{
|
|
53
53
|
placement: getPlacementFromPosition(step.position ?? 'auto'),
|
|
54
|
-
offset:
|
|
55
|
-
padding:
|
|
54
|
+
offset: 20,
|
|
55
|
+
padding: 12,
|
|
56
56
|
},
|
|
57
57
|
)
|
|
58
58
|
|
|
59
|
+
const arrowData = middlewareData.arrow as { x?: number; y?: number } | undefined
|
|
60
|
+
|
|
59
61
|
// Focus the tooltip when positioning has stabilized
|
|
60
62
|
useEffect(() => {
|
|
61
63
|
if (isStable) containerRef.current?.focus()
|
|
62
64
|
}, [isStable])
|
|
63
65
|
|
|
66
|
+
// Add a subtle highlight ring on the target element while tooltip is active
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!targetElement) return
|
|
69
|
+
const el = targetElement
|
|
70
|
+
const prev = {
|
|
71
|
+
outline: el.style.outline,
|
|
72
|
+
outlineOffset: el.style.outlineOffset,
|
|
73
|
+
borderRadius: el.style.borderRadius,
|
|
74
|
+
transition: el.style.transition,
|
|
75
|
+
}
|
|
76
|
+
el.style.outline = '2px solid var(--walkme-primary, oklch(0.588 0.243 264.376))'
|
|
77
|
+
el.style.outlineOffset = '4px'
|
|
78
|
+
el.style.borderRadius = '8px'
|
|
79
|
+
el.style.transition = 'outline 200ms ease-out'
|
|
80
|
+
return () => {
|
|
81
|
+
el.style.outline = prev.outline
|
|
82
|
+
el.style.outlineOffset = prev.outlineOffset
|
|
83
|
+
el.style.borderRadius = prev.borderRadius
|
|
84
|
+
el.style.transition = prev.transition
|
|
85
|
+
}
|
|
86
|
+
}, [targetElement])
|
|
87
|
+
|
|
64
88
|
if (typeof window === 'undefined') return null
|
|
65
89
|
if (!targetElement) return null
|
|
66
90
|
|
|
@@ -96,6 +120,8 @@ export const WalkmeTooltip = memo(function WalkmeTooltip({
|
|
|
96
120
|
style={{
|
|
97
121
|
backgroundColor: 'var(--walkme-bg, #ffffff)',
|
|
98
122
|
...getArrowBorders(placement),
|
|
123
|
+
...(arrowData?.x != null ? { left: arrowData.x } : {}),
|
|
124
|
+
...(arrowData?.y != null ? { top: arrowData.y } : {}),
|
|
99
125
|
}}
|
|
100
126
|
/>
|
|
101
127
|
|
package/hooks/useTourState.ts
CHANGED
|
@@ -1,33 +1,62 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useReducer, useEffect, useRef, useCallback } from 'react'
|
|
4
|
-
import type { Tour, WalkmeState, WalkmeAction } from '../types/walkme.types'
|
|
3
|
+
import { useReducer, useEffect, useRef, useCallback, useState } from 'react'
|
|
4
|
+
import type { Tour, TourState, WalkmeState, WalkmeAction } from '../types/walkme.types'
|
|
5
5
|
import { createInitialState, walkmeReducer } from '../lib/core'
|
|
6
6
|
import { createStorageAdapter } from '../lib/storage'
|
|
7
|
+
import { fetchServerState, saveServerState, mergeStates } from '../lib/server-sync'
|
|
7
8
|
|
|
8
9
|
interface UseTourStateOptions {
|
|
9
10
|
persistState: boolean
|
|
10
11
|
debug: boolean
|
|
11
12
|
userId?: string
|
|
13
|
+
/** API URL for server-side persistence. If omitted, server sync is disabled. */
|
|
14
|
+
serverSyncUrl?: string
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Internal hook that manages the core WalkMe state machine.
|
|
16
|
-
* Handles useReducer, localStorage persistence, and initial state loading.
|
|
19
|
+
* Handles useReducer, localStorage persistence, server sync, and initial state loading.
|
|
20
|
+
*
|
|
21
|
+
* Hybrid persistence strategy:
|
|
22
|
+
* 1. localStorage is the primary store (instant reads, no flash)
|
|
23
|
+
* 2. Server (users_metas) is the durable backup (cross-device sync)
|
|
24
|
+
* 3. On mount: load localStorage first, then merge server state in background
|
|
25
|
+
* 4. On change: save to localStorage immediately, debounced save to server
|
|
26
|
+
* 5. Server saves are BLOCKED until the initial server fetch completes
|
|
27
|
+
* (prevents empty local state from overwriting server data on new devices)
|
|
17
28
|
*/
|
|
18
29
|
export function useTourState(tours: Tour[], options: UseTourStateOptions) {
|
|
19
|
-
const { persistState, debug, userId } = options
|
|
30
|
+
const { persistState, debug, userId, serverSyncUrl } = options
|
|
20
31
|
const [state, dispatch] = useReducer(walkmeReducer, createInitialState())
|
|
21
32
|
const storageRef = useRef(createStorageAdapter(userId))
|
|
22
33
|
const initialized = useRef(false)
|
|
23
34
|
const prevUserIdRef = useRef(userId)
|
|
24
35
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
36
|
+
const serverDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
37
|
+
// Whether the initial server fetch has been kicked off
|
|
38
|
+
const serverSyncStartedRef = useRef(false)
|
|
39
|
+
// Whether the initial server fetch has resolved — server saves are blocked until true
|
|
40
|
+
const serverSyncDoneRef = useRef(false)
|
|
41
|
+
// Reactive flag: true while waiting for initial server fetch.
|
|
42
|
+
// Used by WalkmeProvider to delay auto-trigger evaluation on fresh devices
|
|
43
|
+
// so tours don't flash for ~1s before server state cancels them.
|
|
44
|
+
// CRITICAL: Must initialize to true when server sync will happen so the FIRST
|
|
45
|
+
// render already blocks triggers (setState from effects is too late).
|
|
46
|
+
const [serverSyncPending, setServerSyncPending] = useState(
|
|
47
|
+
() => Boolean(persistState && userId && serverSyncUrl),
|
|
48
|
+
)
|
|
25
49
|
|
|
26
50
|
// Re-create storage adapter when userId changes (e.g. session loads async)
|
|
27
51
|
useEffect(() => {
|
|
28
52
|
if (prevUserIdRef.current === userId) return
|
|
29
53
|
prevUserIdRef.current = userId
|
|
30
54
|
storageRef.current = createStorageAdapter(userId)
|
|
55
|
+
serverSyncStartedRef.current = false
|
|
56
|
+
serverSyncDoneRef.current = false
|
|
57
|
+
// If new userId exists and serverSyncUrl is configured, set pending.
|
|
58
|
+
// If userId cleared (logout), set to false to unblock triggers.
|
|
59
|
+
setServerSyncPending(Boolean(persistState && userId && serverSyncUrl))
|
|
31
60
|
|
|
32
61
|
// Re-restore persisted state from the new user-scoped storage
|
|
33
62
|
if (persistState) {
|
|
@@ -59,7 +88,7 @@ export function useTourState(tours: Tour[], options: UseTourStateOptions) {
|
|
|
59
88
|
dispatch({ type: 'SET_DEBUG', enabled: true })
|
|
60
89
|
}
|
|
61
90
|
|
|
62
|
-
// Restore persisted state
|
|
91
|
+
// Restore persisted state from localStorage (instant, no flash)
|
|
63
92
|
if (persistState) {
|
|
64
93
|
const storage = storageRef.current
|
|
65
94
|
const saved = storage.load()
|
|
@@ -82,6 +111,49 @@ export function useTourState(tours: Tour[], options: UseTourStateOptions) {
|
|
|
82
111
|
}
|
|
83
112
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
84
113
|
|
|
114
|
+
// Background server sync: fetch server state and merge with local.
|
|
115
|
+
// Runs once per userId after local state is restored.
|
|
116
|
+
// Server saves are blocked until this completes to prevent empty local
|
|
117
|
+
// state from overwriting valid server data on a new device/browser.
|
|
118
|
+
// Auto-trigger evaluation in WalkmeProvider is also delayed via
|
|
119
|
+
// serverSyncPending so tours don't flash before server state arrives.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!persistState || !userId || !serverSyncUrl || !state.initialized) return
|
|
122
|
+
if (serverSyncStartedRef.current) return
|
|
123
|
+
serverSyncStartedRef.current = true
|
|
124
|
+
setServerSyncPending(true)
|
|
125
|
+
|
|
126
|
+
fetchServerState(serverSyncUrl)
|
|
127
|
+
.then((serverState) => {
|
|
128
|
+
if (!serverState) return
|
|
129
|
+
|
|
130
|
+
const local = storageRef.current.load()
|
|
131
|
+
if (!local) return
|
|
132
|
+
|
|
133
|
+
const merged = mergeStates(local, serverState)
|
|
134
|
+
|
|
135
|
+
// Only dispatch if server had data the local store didn't
|
|
136
|
+
const hasNewCompleted = merged.completedTours.length > state.completedTours.length
|
|
137
|
+
const hasNewSkipped = merged.skippedTours.length > state.skippedTours.length
|
|
138
|
+
|
|
139
|
+
if (hasNewCompleted || hasNewSkipped) {
|
|
140
|
+
dispatch({
|
|
141
|
+
type: 'RESTORE_STATE',
|
|
142
|
+
completedTours: merged.completedTours,
|
|
143
|
+
skippedTours: merged.skippedTours,
|
|
144
|
+
tourHistory: merged.tourHistory as Record<string, TourState>,
|
|
145
|
+
// Cancel any active tour that was already completed/skipped on server
|
|
146
|
+
activeTour: null,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
.finally(() => {
|
|
151
|
+
// Unblock server saves and auto-trigger evaluation
|
|
152
|
+
serverSyncDoneRef.current = true
|
|
153
|
+
setServerSyncPending(false)
|
|
154
|
+
})
|
|
155
|
+
}, [persistState, userId, serverSyncUrl, state.initialized]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
156
|
+
|
|
85
157
|
// Re-register tour definitions when they change after initialization.
|
|
86
158
|
// This handles the case where tours arrive asynchronously (e.g. after API fetch)
|
|
87
159
|
// and the provider was already mounted with an empty tours array.
|
|
@@ -91,10 +163,11 @@ export function useTourState(tours: Tour[], options: UseTourStateOptions) {
|
|
|
91
163
|
dispatch({ type: 'UPDATE_TOURS', tours })
|
|
92
164
|
}, [tours]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
93
165
|
|
|
94
|
-
// Persist state changes to localStorage (
|
|
166
|
+
// Persist state changes to localStorage (100ms debounce) + server (500ms debounce)
|
|
95
167
|
useEffect(() => {
|
|
96
168
|
if (!persistState || !state.initialized) return
|
|
97
169
|
|
|
170
|
+
// --- localStorage save (fast, 100ms debounce) ---
|
|
98
171
|
if (debounceRef.current) {
|
|
99
172
|
clearTimeout(debounceRef.current)
|
|
100
173
|
}
|
|
@@ -114,13 +187,39 @@ export function useTourState(tours: Tour[], options: UseTourStateOptions) {
|
|
|
114
187
|
})
|
|
115
188
|
}, 100)
|
|
116
189
|
|
|
190
|
+
// --- Server save (slower, 500ms debounce, only durable fields) ---
|
|
191
|
+
// BLOCKED until initial server sync is done to prevent overwriting
|
|
192
|
+
// valid server data with empty local state on a fresh browser.
|
|
193
|
+
if (userId && serverSyncUrl && serverSyncDoneRef.current) {
|
|
194
|
+
if (serverDebounceRef.current) {
|
|
195
|
+
clearTimeout(serverDebounceRef.current)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const url = serverSyncUrl
|
|
199
|
+
serverDebounceRef.current = setTimeout(() => {
|
|
200
|
+
const existing = storageRef.current.load()
|
|
201
|
+
|
|
202
|
+
saveServerState(url, {
|
|
203
|
+
completedTours: state.completedTours,
|
|
204
|
+
skippedTours: state.skippedTours,
|
|
205
|
+
tourHistory: state.tourHistory,
|
|
206
|
+
visitCount: existing?.visitCount ?? 1,
|
|
207
|
+
firstVisitDate: existing?.firstVisitDate ?? new Date().toISOString(),
|
|
208
|
+
})
|
|
209
|
+
}, 500)
|
|
210
|
+
}
|
|
211
|
+
|
|
117
212
|
return () => {
|
|
118
213
|
if (debounceRef.current) {
|
|
119
214
|
clearTimeout(debounceRef.current)
|
|
120
215
|
}
|
|
216
|
+
if (serverDebounceRef.current) {
|
|
217
|
+
clearTimeout(serverDebounceRef.current)
|
|
218
|
+
}
|
|
121
219
|
}
|
|
122
220
|
}, [
|
|
123
221
|
persistState,
|
|
222
|
+
userId,
|
|
124
223
|
state.initialized,
|
|
125
224
|
state.completedTours,
|
|
126
225
|
state.skippedTours,
|
|
@@ -142,5 +241,7 @@ export function useTourState(tours: Tour[], options: UseTourStateOptions) {
|
|
|
142
241
|
state,
|
|
143
242
|
dispatch: stableDispatch,
|
|
144
243
|
storage: storageRef.current,
|
|
244
|
+
/** True while waiting for initial server state fetch (blocks auto-trigger) */
|
|
245
|
+
serverSyncPending,
|
|
145
246
|
}
|
|
146
247
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WalkMe Server Sync
|
|
5
|
+
*
|
|
6
|
+
* Client-side helpers for syncing tour state with the server.
|
|
7
|
+
* All operations are fire-and-forget — failures never block the UI.
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT: This module is theme-agnostic. The API URL is injected
|
|
10
|
+
* by the consumer (theme) — the plugin never hardcodes theme paths.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { StorageSchema } from '../types/walkme.types'
|
|
14
|
+
|
|
15
|
+
/** Subset of StorageSchema that gets persisted to the server */
|
|
16
|
+
export interface ServerState {
|
|
17
|
+
completedTours: string[]
|
|
18
|
+
skippedTours: string[]
|
|
19
|
+
tourHistory: Record<string, unknown>
|
|
20
|
+
visitCount: number
|
|
21
|
+
firstVisitDate: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch the user's tour state from the server.
|
|
26
|
+
* Returns null if no state exists or on any error.
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchServerState(apiUrl: string): Promise<ServerState | null> {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(apiUrl, { credentials: 'include' })
|
|
31
|
+
if (!res.ok) return null
|
|
32
|
+
|
|
33
|
+
const json = await res.json()
|
|
34
|
+
if (!json.success || !json.data) return null
|
|
35
|
+
|
|
36
|
+
return json.data as ServerState
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Save the user's tour state to the server.
|
|
44
|
+
* Fire-and-forget — errors are silently ignored.
|
|
45
|
+
*/
|
|
46
|
+
export async function saveServerState(apiUrl: string, state: ServerState): Promise<void> {
|
|
47
|
+
try {
|
|
48
|
+
await fetch(apiUrl, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
credentials: 'include',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify(state),
|
|
53
|
+
})
|
|
54
|
+
} catch {
|
|
55
|
+
// Silent failure — localStorage is the primary store
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Merge local state with server state.
|
|
61
|
+
* Strategy: union of completed/skipped arrays, local active tour preserved.
|
|
62
|
+
* Server tourHistory entries fill gaps (local wins on conflict).
|
|
63
|
+
*/
|
|
64
|
+
export function mergeStates(
|
|
65
|
+
local: StorageSchema,
|
|
66
|
+
server: ServerState,
|
|
67
|
+
): { completedTours: string[]; skippedTours: string[]; tourHistory: Record<string, unknown> } {
|
|
68
|
+
return {
|
|
69
|
+
completedTours: [...new Set([...local.completedTours, ...server.completedTours])],
|
|
70
|
+
skippedTours: [...new Set([...local.skippedTours, ...server.skippedTours])],
|
|
71
|
+
tourHistory: { ...server.tourHistory, ...local.tourHistory },
|
|
72
|
+
}
|
|
73
|
+
}
|
package/package.json
CHANGED
package/types/walkme.types.ts
CHANGED
|
@@ -267,6 +267,13 @@ export interface WalkmeProviderProps {
|
|
|
267
267
|
labels?: Partial<WalkmeLabels>
|
|
268
268
|
/** User ID for scoping localStorage persistence per user */
|
|
269
269
|
userId?: string
|
|
270
|
+
/**
|
|
271
|
+
* API URL for server-side state persistence (cross-device sync).
|
|
272
|
+
* Must support GET (fetch state) and POST (save state).
|
|
273
|
+
* If not provided, server sync is disabled — only localStorage is used.
|
|
274
|
+
* The theme provides this URL; the plugin never hardcodes theme paths.
|
|
275
|
+
*/
|
|
276
|
+
serverSyncUrl?: string
|
|
270
277
|
}
|
|
271
278
|
|
|
272
279
|
// ---------------------------------------------------------------------------
|