@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: 5000 }).then((waitResult) => {
377
+ waitForTarget(activeStep.target!, { timeout: 20000 }).then((waitResult) => {
372
378
  if (waitResult.found && waitResult.element) {
373
379
  applyTarget(waitResult.element)
374
- } else if (debug) {
380
+ } else {
375
381
  console.warn(
376
- `[WalkMe] Target "${activeStep.target}" not found, step may display without anchor`,
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 modal/floating steps that cover the page)
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 step types that cover the viewport
450
- if (activeStepType !== 'modal' && activeStepType !== 'floating') return
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: 16,
80
- padding: 8,
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: 12,
55
- padding: 8,
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
 
@@ -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 (debounced)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextsparkjs/plugin-walkme",
3
- "version": "0.1.0-beta.108",
3
+ "version": "0.1.0-beta.110",
4
4
  "private": false,
5
5
  "main": "./plugin.config.ts",
6
6
  "requiredPlugins": [],
@@ -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
  // ---------------------------------------------------------------------------