react-native-lumen 1.0.1 → 1.1.0
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/README.md +763 -231
- package/lib/module/components/TourOverlay.js +43 -3
- package/lib/module/components/TourOverlay.js.map +1 -1
- package/lib/module/components/TourProvider.js +318 -61
- package/lib/module/components/TourProvider.js.map +1 -1
- package/lib/module/components/TourTooltip.js +113 -73
- package/lib/module/components/TourTooltip.js.map +1 -1
- package/lib/module/components/TourZone.js +186 -119
- package/lib/module/components/TourZone.js.map +1 -1
- package/lib/module/constants/defaults.js +43 -0
- package/lib/module/constants/defaults.js.map +1 -1
- package/lib/module/context/TourContext.js +5 -0
- package/lib/module/context/TourContext.js.map +1 -0
- package/lib/module/hooks/useTour.js +1 -1
- package/lib/module/hooks/useTour.js.map +1 -1
- package/lib/module/hooks/useTourScrollView.js +71 -0
- package/lib/module/hooks/useTourScrollView.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/storage.js +188 -0
- package/lib/module/utils/storage.js.map +1 -0
- package/lib/typescript/src/components/TourOverlay.d.ts.map +1 -1
- package/lib/typescript/src/components/TourProvider.d.ts +21 -4
- package/lib/typescript/src/components/TourProvider.d.ts.map +1 -1
- package/lib/typescript/src/components/TourTooltip.d.ts.map +1 -1
- package/lib/typescript/src/components/TourZone.d.ts +19 -1
- package/lib/typescript/src/components/TourZone.d.ts.map +1 -1
- package/lib/typescript/src/constants/defaults.d.ts +10 -0
- package/lib/typescript/src/constants/defaults.d.ts.map +1 -1
- package/lib/typescript/src/context/TourContext.d.ts +3 -0
- package/lib/typescript/src/context/TourContext.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useTourScrollView.d.ts +65 -0
- package/lib/typescript/src/hooks/useTourScrollView.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types/index.d.ts +296 -1
- package/lib/typescript/src/types/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/storage.d.ts +51 -0
- package/lib/typescript/src/utils/storage.d.ts.map +1 -0
- package/package.json +173 -171
- package/src/components/TourOverlay.tsx +45 -2
- package/src/components/TourProvider.tsx +408 -56
- package/src/components/TourTooltip.tsx +144 -71
- package/src/components/TourZone.tsx +238 -140
- package/src/constants/defaults.ts +51 -0
- package/src/context/TourContext.ts +4 -0
- package/src/hooks/useTour.ts +1 -1
- package/src/hooks/useTourScrollView.ts +111 -0
- package/src/index.tsx +27 -0
- package/src/types/index.ts +306 -1
- package/src/utils/storage.ts +226 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React, {
|
|
2
|
-
createContext,
|
|
3
2
|
useState,
|
|
4
3
|
useCallback,
|
|
5
4
|
useMemo,
|
|
6
5
|
useRef,
|
|
6
|
+
useEffect,
|
|
7
7
|
type ComponentType,
|
|
8
8
|
} from 'react';
|
|
9
9
|
import {
|
|
@@ -12,29 +12,134 @@ import {
|
|
|
12
12
|
withTiming,
|
|
13
13
|
useAnimatedRef,
|
|
14
14
|
default as Animated,
|
|
15
|
+
type WithSpringConfig,
|
|
15
16
|
} from 'react-native-reanimated';
|
|
16
|
-
import { StyleSheet } from 'react-native';
|
|
17
|
+
import { StyleSheet, Dimensions } from 'react-native';
|
|
17
18
|
import type {
|
|
18
19
|
TourStep,
|
|
19
20
|
MeasureResult,
|
|
20
21
|
TourConfig,
|
|
21
22
|
InternalTourContextType,
|
|
23
|
+
ZoneStyle,
|
|
24
|
+
StorageAdapter,
|
|
25
|
+
StepsOrder,
|
|
22
26
|
} from '../types';
|
|
27
|
+
import { TourContext } from '../context/TourContext';
|
|
23
28
|
import { TourOverlay } from './TourOverlay';
|
|
24
29
|
import { TourTooltip } from './TourTooltip';
|
|
25
30
|
import {
|
|
26
31
|
DEFAULT_BACKDROP_OPACITY,
|
|
27
32
|
DEFAULT_SPRING_CONFIG,
|
|
33
|
+
DEFAULT_ZONE_STYLE,
|
|
34
|
+
resolveZoneStyle,
|
|
28
35
|
} from '../constants/defaults';
|
|
36
|
+
import {
|
|
37
|
+
detectStorage,
|
|
38
|
+
saveTourProgress,
|
|
39
|
+
loadTourProgress,
|
|
40
|
+
clearTourProgress,
|
|
41
|
+
} from '../utils/storage';
|
|
42
|
+
|
|
43
|
+
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Computes the zone geometry based on element bounds and zone style.
|
|
47
|
+
* Handles different shapes: rounded-rect, circle, pill.
|
|
48
|
+
*/
|
|
49
|
+
function computeZoneGeometry(
|
|
50
|
+
element: MeasureResult,
|
|
51
|
+
style: Required<ZoneStyle>
|
|
52
|
+
): {
|
|
53
|
+
x: number;
|
|
54
|
+
y: number;
|
|
55
|
+
width: number;
|
|
56
|
+
height: number;
|
|
57
|
+
borderRadius: number;
|
|
58
|
+
} {
|
|
59
|
+
const {
|
|
60
|
+
paddingTop,
|
|
61
|
+
paddingRight,
|
|
62
|
+
paddingBottom,
|
|
63
|
+
paddingLeft,
|
|
64
|
+
shape,
|
|
65
|
+
borderRadius,
|
|
66
|
+
} = style;
|
|
67
|
+
|
|
68
|
+
let sx: number, sy: number, sw: number, sh: number, sr: number;
|
|
69
|
+
|
|
70
|
+
switch (shape) {
|
|
71
|
+
case 'circle': {
|
|
72
|
+
// Create a circular zone that encompasses the element
|
|
73
|
+
const cx = element.x + element.width / 2;
|
|
74
|
+
const cy = element.y + element.height / 2;
|
|
75
|
+
// Use the larger dimension to create a circle, plus padding
|
|
76
|
+
const radius =
|
|
77
|
+
Math.max(element.width, element.height) / 2 + style.padding;
|
|
78
|
+
sx = cx - radius;
|
|
79
|
+
sy = cy - radius;
|
|
80
|
+
sw = radius * 2;
|
|
81
|
+
sh = radius * 2;
|
|
82
|
+
sr = radius;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case 'pill': {
|
|
86
|
+
// Pill shape with fully rounded ends
|
|
87
|
+
sx = element.x - paddingLeft;
|
|
88
|
+
sy = element.y - paddingTop;
|
|
89
|
+
sw = element.width + paddingLeft + paddingRight;
|
|
90
|
+
sh = element.height + paddingTop + paddingBottom;
|
|
91
|
+
sr = sh / 2; // Fully rounded based on height
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'rounded-rect':
|
|
95
|
+
default: {
|
|
96
|
+
sx = element.x - paddingLeft;
|
|
97
|
+
sy = element.y - paddingTop;
|
|
98
|
+
sw = element.width + paddingLeft + paddingRight;
|
|
99
|
+
sh = element.height + paddingTop + paddingBottom;
|
|
100
|
+
sr = borderRadius;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Clamp to screen bounds
|
|
106
|
+
sx = Math.max(0, Math.min(sx, SCREEN_WIDTH - sw));
|
|
107
|
+
sy = Math.max(0, Math.min(sy, SCREEN_HEIGHT - sh));
|
|
108
|
+
sw = Math.min(sw, SCREEN_WIDTH - sx);
|
|
109
|
+
sh = Math.min(sh, SCREEN_HEIGHT - sy);
|
|
29
110
|
|
|
30
|
-
|
|
111
|
+
// Ensure minimum size
|
|
112
|
+
const minSize = 40;
|
|
113
|
+
sw = Math.max(sw, minSize);
|
|
114
|
+
sh = Math.max(sh, minSize);
|
|
115
|
+
|
|
116
|
+
return { x: sx, y: sy, width: sw, height: sh, borderRadius: sr };
|
|
117
|
+
}
|
|
31
118
|
|
|
32
119
|
interface TourProviderProps {
|
|
33
120
|
children: React.ReactNode;
|
|
34
121
|
/**
|
|
35
|
-
* Optional custom steps order.
|
|
122
|
+
* Optional custom steps order. Supports two formats:
|
|
123
|
+
*
|
|
124
|
+
* **Flat array** (single-screen or simple multi-screen):
|
|
125
|
+
* ```
|
|
126
|
+
* stepsOrder={['bio', 'prompt', 'poll', 'filters', 'swipeableCards']}
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* **Screen-grouped object** (multi-screen tours):
|
|
130
|
+
* ```
|
|
131
|
+
* stepsOrder={{
|
|
132
|
+
* ProfileSelf: ['bio', 'prompt', 'poll'],
|
|
133
|
+
* HomeSwipe: ['filters'],
|
|
134
|
+
* SwipeableCards: ['swipeableCards'],
|
|
135
|
+
* }}
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* When using the object format, steps are flattened in the order the screens appear.
|
|
139
|
+
* The tour automatically waits when advancing to a step on an unmounted screen,
|
|
140
|
+
* and resumes when that step's TourZone mounts.
|
|
36
141
|
*/
|
|
37
|
-
stepsOrder?:
|
|
142
|
+
stepsOrder?: StepsOrder;
|
|
38
143
|
/**
|
|
39
144
|
* Initial overlay opacity. Default 0.5
|
|
40
145
|
*/
|
|
@@ -55,11 +160,57 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
55
160
|
}) => {
|
|
56
161
|
const [steps, setSteps] = useState<Record<string, TourStep>>({});
|
|
57
162
|
const [currentStep, setCurrentStep] = useState<string | null>(null);
|
|
163
|
+
const [hasSavedProgress, setHasSavedProgress] = useState(false);
|
|
58
164
|
|
|
59
165
|
// ref to access latest measurements without causing re-renders
|
|
60
166
|
const measurements = useRef<Record<string, MeasureResult>>({});
|
|
61
167
|
const containerRef = useAnimatedRef<any>();
|
|
62
168
|
|
|
169
|
+
// ─── Persistence Setup ─────────────────────────────────────────────────────
|
|
170
|
+
const persistenceConfig = config?.persistence;
|
|
171
|
+
const isPersistenceEnabled = persistenceConfig?.enabled ?? false;
|
|
172
|
+
const tourId = persistenceConfig?.tourId ?? 'default';
|
|
173
|
+
const autoResume = persistenceConfig?.autoResume ?? true;
|
|
174
|
+
const clearOnComplete = persistenceConfig?.clearOnComplete ?? true;
|
|
175
|
+
const maxAge = persistenceConfig?.maxAge;
|
|
176
|
+
|
|
177
|
+
// Get storage adapter (custom or auto-detected)
|
|
178
|
+
const storageAdapter = useMemo<StorageAdapter | null>(() => {
|
|
179
|
+
if (!isPersistenceEnabled) return null;
|
|
180
|
+
if (persistenceConfig?.storage) return persistenceConfig.storage;
|
|
181
|
+
const detected = detectStorage();
|
|
182
|
+
return detected.adapter;
|
|
183
|
+
}, [isPersistenceEnabled, persistenceConfig?.storage]);
|
|
184
|
+
|
|
185
|
+
// Check for saved progress on mount
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!isPersistenceEnabled || !storageAdapter) {
|
|
188
|
+
setHasSavedProgress(false);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const checkSavedProgress = async () => {
|
|
193
|
+
try {
|
|
194
|
+
const savedProgress = await loadTourProgress(storageAdapter, tourId);
|
|
195
|
+
if (savedProgress) {
|
|
196
|
+
// Check if progress is expired
|
|
197
|
+
if (maxAge && Date.now() - savedProgress.timestamp > maxAge) {
|
|
198
|
+
await clearTourProgress(storageAdapter, tourId);
|
|
199
|
+
setHasSavedProgress(false);
|
|
200
|
+
} else {
|
|
201
|
+
setHasSavedProgress(true);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
setHasSavedProgress(false);
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
setHasSavedProgress(false);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
checkSavedProgress();
|
|
212
|
+
}, [isPersistenceEnabled, storageAdapter, tourId, maxAge]);
|
|
213
|
+
|
|
63
214
|
// --- Shared Values for Animations (Zero Bridge Crossing) ---
|
|
64
215
|
// Initialize off-screen or 0
|
|
65
216
|
const targetX = useSharedValue(0);
|
|
@@ -68,6 +219,41 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
68
219
|
const targetHeight = useSharedValue(0);
|
|
69
220
|
const targetRadius = useSharedValue(10); // Default border radius
|
|
70
221
|
const opacity = useSharedValue(0); // 0 = hidden, 1 = visible
|
|
222
|
+
const zoneBorderWidth = useSharedValue(DEFAULT_ZONE_STYLE.borderWidth);
|
|
223
|
+
|
|
224
|
+
// Track current step's resolved zone style
|
|
225
|
+
const currentZoneStyle = useMemo<ZoneStyle | null>(() => {
|
|
226
|
+
if (!currentStep) return null;
|
|
227
|
+
const step = steps[currentStep];
|
|
228
|
+
if (!step) return null;
|
|
229
|
+
return resolveZoneStyle(config?.zoneStyle, step.zoneStyle);
|
|
230
|
+
}, [currentStep, steps, config?.zoneStyle]);
|
|
231
|
+
|
|
232
|
+
// Helper to get spring config for a step (supports per-step overrides)
|
|
233
|
+
const getSpringConfigForStep = useCallback(
|
|
234
|
+
(stepKey: string): WithSpringConfig => {
|
|
235
|
+
const step = steps[stepKey];
|
|
236
|
+
const stepStyle = step?.zoneStyle;
|
|
237
|
+
const baseConfig = config?.springConfig ?? DEFAULT_SPRING_CONFIG;
|
|
238
|
+
|
|
239
|
+
// Allow per-step spring overrides
|
|
240
|
+
if (
|
|
241
|
+
stepStyle?.springDamping !== undefined ||
|
|
242
|
+
stepStyle?.springStiffness !== undefined
|
|
243
|
+
) {
|
|
244
|
+
return {
|
|
245
|
+
damping: stepStyle.springDamping ?? baseConfig.damping,
|
|
246
|
+
stiffness: stepStyle.springStiffness ?? baseConfig.stiffness,
|
|
247
|
+
mass: baseConfig.mass,
|
|
248
|
+
overshootClamping: baseConfig.overshootClamping,
|
|
249
|
+
restDisplacementThreshold: baseConfig.restDisplacementThreshold,
|
|
250
|
+
restSpeedThreshold: baseConfig.restSpeedThreshold,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return baseConfig;
|
|
254
|
+
},
|
|
255
|
+
[steps, config?.springConfig]
|
|
256
|
+
);
|
|
71
257
|
|
|
72
258
|
// Helper to animate to a specific step's layout
|
|
73
259
|
const animateToStep = useCallback(
|
|
@@ -93,19 +279,25 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
93
279
|
return;
|
|
94
280
|
}
|
|
95
281
|
|
|
96
|
-
const
|
|
282
|
+
const step = steps[stepKey];
|
|
283
|
+
const resolvedStyle = resolveZoneStyle(
|
|
284
|
+
config?.zoneStyle,
|
|
285
|
+
step?.zoneStyle
|
|
286
|
+
);
|
|
287
|
+
const springConfig = getSpringConfigForStep(stepKey);
|
|
97
288
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
targetWidth.value = withSpring(measure.width, springConfig);
|
|
101
|
-
targetHeight.value = withSpring(measure.height, springConfig);
|
|
289
|
+
// Compute zone geometry based on style (handles shapes and padding)
|
|
290
|
+
const geo = computeZoneGeometry(measure, resolvedStyle);
|
|
102
291
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
292
|
+
targetX.value = withSpring(geo.x, springConfig);
|
|
293
|
+
targetY.value = withSpring(geo.y, springConfig);
|
|
294
|
+
targetWidth.value = withSpring(geo.width, springConfig);
|
|
295
|
+
targetHeight.value = withSpring(geo.height, springConfig);
|
|
296
|
+
targetRadius.value = withSpring(geo.borderRadius, springConfig);
|
|
297
|
+
zoneBorderWidth.value = withSpring(
|
|
298
|
+
resolvedStyle.borderWidth,
|
|
299
|
+
springConfig
|
|
300
|
+
);
|
|
109
301
|
|
|
110
302
|
// Ensure overlay is visible
|
|
111
303
|
opacity.value = withTiming(backdropOpacity, { duration: 300 });
|
|
@@ -120,14 +312,22 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
120
312
|
targetWidth,
|
|
121
313
|
targetHeight,
|
|
122
314
|
targetRadius,
|
|
315
|
+
zoneBorderWidth,
|
|
123
316
|
opacity,
|
|
124
|
-
|
|
317
|
+
getSpringConfigForStep,
|
|
125
318
|
steps,
|
|
319
|
+
config?.zoneStyle,
|
|
126
320
|
]
|
|
127
321
|
);
|
|
128
322
|
|
|
129
323
|
const registerStep = useCallback((step: TourStep) => {
|
|
130
324
|
setSteps((prev) => ({ ...prev, [step.key]: step }));
|
|
325
|
+
// If this step was pending (waiting for cross-screen mount), activate it
|
|
326
|
+
if (pendingStepRef.current === step.key) {
|
|
327
|
+
pendingStepRef.current = null;
|
|
328
|
+
setCurrentStep(step.key);
|
|
329
|
+
// Overlay opacity will be set by updateStepLayout when measurement arrives
|
|
330
|
+
}
|
|
131
331
|
}, []);
|
|
132
332
|
|
|
133
333
|
const unregisterStep = useCallback((key: string) => {
|
|
@@ -166,17 +366,25 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
166
366
|
measurements.current[key] = measure;
|
|
167
367
|
// If this step is currently active (e.g. scroll happened or resize), update shared values on the fly
|
|
168
368
|
if (currentStep === key) {
|
|
169
|
-
const
|
|
369
|
+
const step = steps[key];
|
|
370
|
+
const resolvedStyle = resolveZoneStyle(
|
|
371
|
+
config?.zoneStyle,
|
|
372
|
+
step?.zoneStyle
|
|
373
|
+
);
|
|
374
|
+
const springConfig = getSpringConfigForStep(key);
|
|
170
375
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
targetWidth.value = withSpring(measure.width, springConfig);
|
|
174
|
-
targetHeight.value = withSpring(measure.height, springConfig);
|
|
376
|
+
// Compute zone geometry based on style
|
|
377
|
+
const geo = computeZoneGeometry(measure, resolvedStyle);
|
|
175
378
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
379
|
+
targetX.value = withSpring(geo.x, springConfig);
|
|
380
|
+
targetY.value = withSpring(geo.y, springConfig);
|
|
381
|
+
targetWidth.value = withSpring(geo.width, springConfig);
|
|
382
|
+
targetHeight.value = withSpring(geo.height, springConfig);
|
|
383
|
+
targetRadius.value = withSpring(geo.borderRadius, springConfig);
|
|
384
|
+
zoneBorderWidth.value = withSpring(
|
|
385
|
+
resolvedStyle.borderWidth,
|
|
386
|
+
springConfig
|
|
387
|
+
);
|
|
180
388
|
|
|
181
389
|
// Ensure overlay is visible (fixes race condition where start() was called before measure)
|
|
182
390
|
opacity.value = withTiming(backdropOpacity, { duration: 300 });
|
|
@@ -189,15 +397,25 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
189
397
|
targetWidth,
|
|
190
398
|
targetHeight,
|
|
191
399
|
targetRadius,
|
|
400
|
+
zoneBorderWidth,
|
|
192
401
|
opacity,
|
|
193
402
|
backdropOpacity,
|
|
194
|
-
|
|
403
|
+
getSpringConfigForStep,
|
|
404
|
+
config?.zoneStyle,
|
|
195
405
|
steps,
|
|
196
406
|
]
|
|
197
407
|
);
|
|
198
408
|
|
|
409
|
+
// Flatten stepsOrder (supports both string[] and Record<string, string[]>)
|
|
410
|
+
const flatStepsOrder = useMemo<string[] | undefined>(() => {
|
|
411
|
+
if (!initialStepsOrder) return undefined;
|
|
412
|
+
if (Array.isArray(initialStepsOrder)) return initialStepsOrder;
|
|
413
|
+
// Object format: flatten values in key order
|
|
414
|
+
return Object.values(initialStepsOrder).flat();
|
|
415
|
+
}, [initialStepsOrder]);
|
|
416
|
+
|
|
199
417
|
const getOrderedSteps = useCallback(() => {
|
|
200
|
-
if (
|
|
418
|
+
if (flatStepsOrder) return flatStepsOrder;
|
|
201
419
|
// If order property exists on steps, sort by it.
|
|
202
420
|
const stepKeys = Object.keys(steps);
|
|
203
421
|
if (stepKeys.length > 0) {
|
|
@@ -212,65 +430,186 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
212
430
|
}
|
|
213
431
|
}
|
|
214
432
|
return stepKeys;
|
|
215
|
-
}, [
|
|
433
|
+
}, [flatStepsOrder, steps]);
|
|
434
|
+
|
|
435
|
+
// Pending step for cross-screen navigation
|
|
436
|
+
// When next/prev targets a step that isn't mounted yet, we store it here
|
|
437
|
+
// and resume when that step's TourZone registers.
|
|
438
|
+
const pendingStepRef = useRef<string | null>(null);
|
|
439
|
+
|
|
440
|
+
// Save progress when step changes
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
if (!isPersistenceEnabled || !storageAdapter || !currentStep) return;
|
|
443
|
+
|
|
444
|
+
const ordered = getOrderedSteps();
|
|
445
|
+
const stepIndex = ordered.indexOf(currentStep);
|
|
446
|
+
|
|
447
|
+
if (stepIndex >= 0) {
|
|
448
|
+
saveTourProgress(storageAdapter, tourId, currentStep, stepIndex).catch(
|
|
449
|
+
() => {
|
|
450
|
+
// Silently ignore save errors
|
|
451
|
+
}
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}, [
|
|
455
|
+
currentStep,
|
|
456
|
+
isPersistenceEnabled,
|
|
457
|
+
storageAdapter,
|
|
458
|
+
tourId,
|
|
459
|
+
getOrderedSteps,
|
|
460
|
+
]);
|
|
216
461
|
|
|
217
462
|
const start = useCallback(
|
|
218
|
-
(stepKey?: string) => {
|
|
463
|
+
async (stepKey?: string) => {
|
|
219
464
|
const ordered = getOrderedSteps();
|
|
220
|
-
|
|
465
|
+
|
|
466
|
+
let targetStep = stepKey;
|
|
467
|
+
|
|
468
|
+
// If no specific step and autoResume is enabled, try to restore from storage
|
|
469
|
+
if (!targetStep && isPersistenceEnabled && storageAdapter && autoResume) {
|
|
470
|
+
try {
|
|
471
|
+
const savedProgress = await loadTourProgress(storageAdapter, tourId);
|
|
472
|
+
if (savedProgress) {
|
|
473
|
+
// Check if progress is expired
|
|
474
|
+
if (maxAge && Date.now() - savedProgress.timestamp > maxAge) {
|
|
475
|
+
await clearTourProgress(storageAdapter, tourId);
|
|
476
|
+
setHasSavedProgress(false);
|
|
477
|
+
} else if (ordered.includes(savedProgress.currentStepKey)) {
|
|
478
|
+
// Verify the saved step still exists in order
|
|
479
|
+
targetStep = savedProgress.currentStepKey;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
// Ignore load errors, start from beginning
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const firstStep = targetStep || ordered[0];
|
|
221
488
|
if (firstStep) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
489
|
+
// Check if the target step is registered (mounted)
|
|
490
|
+
if (steps[firstStep]) {
|
|
491
|
+
setCurrentStep(firstStep);
|
|
492
|
+
setTimeout(() => animateToStep(firstStep), 0);
|
|
493
|
+
} else {
|
|
494
|
+
// Step not mounted yet (on a different screen) - set as pending
|
|
495
|
+
pendingStepRef.current = firstStep;
|
|
496
|
+
// Don't set currentStep or opacity - wait for TourZone to mount
|
|
497
|
+
}
|
|
230
498
|
}
|
|
231
499
|
},
|
|
232
|
-
[
|
|
500
|
+
[
|
|
501
|
+
getOrderedSteps,
|
|
502
|
+
animateToStep,
|
|
503
|
+
steps,
|
|
504
|
+
isPersistenceEnabled,
|
|
505
|
+
storageAdapter,
|
|
506
|
+
autoResume,
|
|
507
|
+
tourId,
|
|
508
|
+
maxAge,
|
|
509
|
+
]
|
|
233
510
|
);
|
|
234
511
|
|
|
235
512
|
const stop = useCallback(() => {
|
|
236
513
|
setCurrentStep(null);
|
|
237
514
|
opacity.value = withTiming(0, { duration: 300 });
|
|
515
|
+
// Note: We do NOT clear progress on stop - only on complete or explicit clearProgress
|
|
238
516
|
}, [opacity]);
|
|
239
517
|
|
|
518
|
+
// Clear progress helper
|
|
519
|
+
const clearProgress = useCallback(async () => {
|
|
520
|
+
if (!isPersistenceEnabled || !storageAdapter) return;
|
|
521
|
+
try {
|
|
522
|
+
await clearTourProgress(storageAdapter, tourId);
|
|
523
|
+
setHasSavedProgress(false);
|
|
524
|
+
} catch {
|
|
525
|
+
// Silently ignore clear errors
|
|
526
|
+
}
|
|
527
|
+
}, [isPersistenceEnabled, storageAdapter, tourId]);
|
|
528
|
+
|
|
240
529
|
const next = useCallback(() => {
|
|
241
530
|
if (!currentStep) return;
|
|
531
|
+
|
|
532
|
+
// Block navigation if current step has completed === false
|
|
533
|
+
const currentStepData = steps[currentStep];
|
|
534
|
+
if (currentStepData?.completed === false) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
242
538
|
const ordered = getOrderedSteps();
|
|
243
539
|
const currentIndex = ordered.indexOf(currentStep);
|
|
244
540
|
if (currentIndex < ordered.length - 1) {
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
541
|
+
const nextStepKey = ordered[currentIndex + 1];
|
|
542
|
+
if (nextStepKey) {
|
|
543
|
+
// Check if the next step is registered (mounted)
|
|
544
|
+
if (steps[nextStepKey]) {
|
|
545
|
+
setCurrentStep(nextStepKey);
|
|
546
|
+
// Don't call animateToStep here - it uses cached measurements that may be stale
|
|
547
|
+
// after scroll. The useFrameCallback in TourZone will handle position tracking
|
|
548
|
+
// using measure() with correct screen coordinates (pageX/pageY).
|
|
549
|
+
// Just ensure the overlay is visible.
|
|
550
|
+
opacity.value = withTiming(backdropOpacity, { duration: 300 });
|
|
551
|
+
} else {
|
|
552
|
+
// Step not mounted yet (on a different screen) - set as pending
|
|
553
|
+
pendingStepRef.current = nextStepKey;
|
|
554
|
+
setCurrentStep(null);
|
|
555
|
+
opacity.value = withTiming(0, { duration: 300 });
|
|
556
|
+
// Persist pending step so it can be resumed
|
|
557
|
+
if (isPersistenceEnabled && storageAdapter) {
|
|
558
|
+
const stepIndex = ordered.indexOf(nextStepKey);
|
|
559
|
+
saveTourProgress(
|
|
560
|
+
storageAdapter,
|
|
561
|
+
tourId,
|
|
562
|
+
nextStepKey,
|
|
563
|
+
stepIndex
|
|
564
|
+
).catch(() => {});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
253
567
|
} else {
|
|
254
568
|
stop();
|
|
255
569
|
}
|
|
256
570
|
} else {
|
|
257
|
-
|
|
571
|
+
// End of tour - clear progress if configured
|
|
572
|
+
if (isPersistenceEnabled && clearOnComplete && storageAdapter) {
|
|
573
|
+
clearTourProgress(storageAdapter, tourId)
|
|
574
|
+
.then(() => setHasSavedProgress(false))
|
|
575
|
+
.catch(() => {});
|
|
576
|
+
}
|
|
577
|
+
stop();
|
|
258
578
|
}
|
|
259
|
-
}, [
|
|
579
|
+
}, [
|
|
580
|
+
currentStep,
|
|
581
|
+
steps,
|
|
582
|
+
getOrderedSteps,
|
|
583
|
+
stop,
|
|
584
|
+
opacity,
|
|
585
|
+
backdropOpacity,
|
|
586
|
+
isPersistenceEnabled,
|
|
587
|
+
clearOnComplete,
|
|
588
|
+
storageAdapter,
|
|
589
|
+
tourId,
|
|
590
|
+
]);
|
|
260
591
|
|
|
261
592
|
const prev = useCallback(() => {
|
|
262
593
|
if (!currentStep) return;
|
|
263
594
|
const ordered = getOrderedSteps();
|
|
264
595
|
const currentIndex = ordered.indexOf(currentStep);
|
|
265
596
|
if (currentIndex > 0) {
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
597
|
+
const prevStepKey = ordered[currentIndex - 1];
|
|
598
|
+
if (prevStepKey) {
|
|
599
|
+
// Check if the previous step is registered (mounted)
|
|
600
|
+
if (steps[prevStepKey]) {
|
|
601
|
+
setCurrentStep(prevStepKey);
|
|
602
|
+
// Don't call animateToStep - let useFrameCallback handle position tracking
|
|
603
|
+
opacity.value = withTiming(backdropOpacity, { duration: 300 });
|
|
604
|
+
} else {
|
|
605
|
+
// Step not mounted (on a different screen) - set as pending
|
|
606
|
+
pendingStepRef.current = prevStepKey;
|
|
607
|
+
setCurrentStep(null);
|
|
608
|
+
opacity.value = withTiming(0, { duration: 300 });
|
|
609
|
+
}
|
|
271
610
|
}
|
|
272
611
|
}
|
|
273
|
-
}, [currentStep, getOrderedSteps, opacity, backdropOpacity]);
|
|
612
|
+
}, [currentStep, steps, getOrderedSteps, opacity, backdropOpacity]);
|
|
274
613
|
|
|
275
614
|
const scrollViewRef = useAnimatedRef<any>();
|
|
276
615
|
|
|
@@ -294,6 +633,9 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
294
633
|
// For now, let's just return the `scrollViewRef` we created.
|
|
295
634
|
}, []);
|
|
296
635
|
|
|
636
|
+
// Expose ordered step keys for tooltip and external use
|
|
637
|
+
const orderedStepKeys = useMemo(() => getOrderedSteps(), [getOrderedSteps]);
|
|
638
|
+
|
|
297
639
|
const value = useMemo<InternalTourContextType>(
|
|
298
640
|
() => ({
|
|
299
641
|
start,
|
|
@@ -310,11 +652,16 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
310
652
|
targetHeight,
|
|
311
653
|
targetRadius,
|
|
312
654
|
opacity,
|
|
655
|
+
zoneBorderWidth,
|
|
313
656
|
steps,
|
|
314
657
|
config,
|
|
315
658
|
containerRef,
|
|
316
659
|
scrollViewRef,
|
|
317
660
|
setScrollViewRef,
|
|
661
|
+
currentZoneStyle,
|
|
662
|
+
clearProgress,
|
|
663
|
+
hasSavedProgress,
|
|
664
|
+
orderedStepKeys,
|
|
318
665
|
}),
|
|
319
666
|
[
|
|
320
667
|
start,
|
|
@@ -331,11 +678,16 @@ export const TourProvider: React.FC<TourProviderProps> = ({
|
|
|
331
678
|
targetHeight,
|
|
332
679
|
targetRadius,
|
|
333
680
|
opacity,
|
|
681
|
+
zoneBorderWidth,
|
|
334
682
|
steps,
|
|
335
683
|
config,
|
|
336
684
|
containerRef,
|
|
337
685
|
scrollViewRef,
|
|
338
686
|
setScrollViewRef,
|
|
687
|
+
currentZoneStyle,
|
|
688
|
+
clearProgress,
|
|
689
|
+
hasSavedProgress,
|
|
690
|
+
orderedStepKeys,
|
|
339
691
|
]
|
|
340
692
|
);
|
|
341
693
|
|