react-native-lumen 1.0.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.
Files changed (50) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +231 -0
  3. package/lib/module/components/TourOverlay.js +134 -0
  4. package/lib/module/components/TourOverlay.js.map +1 -0
  5. package/lib/module/components/TourProvider.js +233 -0
  6. package/lib/module/components/TourProvider.js.map +1 -0
  7. package/lib/module/components/TourTooltip.js +233 -0
  8. package/lib/module/components/TourTooltip.js.map +1 -0
  9. package/lib/module/components/TourZone.js +246 -0
  10. package/lib/module/components/TourZone.js.map +1 -0
  11. package/lib/module/constants/animations.js +72 -0
  12. package/lib/module/constants/animations.js.map +1 -0
  13. package/lib/module/constants/defaults.js +14 -0
  14. package/lib/module/constants/defaults.js.map +1 -0
  15. package/lib/module/hooks/useTour.js +12 -0
  16. package/lib/module/hooks/useTour.js.map +1 -0
  17. package/lib/module/index.js +11 -0
  18. package/lib/module/index.js.map +1 -0
  19. package/lib/module/package.json +1 -0
  20. package/lib/module/types/index.js +4 -0
  21. package/lib/module/types/index.js.map +1 -0
  22. package/lib/typescript/package.json +1 -0
  23. package/lib/typescript/src/components/TourOverlay.d.ts +2 -0
  24. package/lib/typescript/src/components/TourOverlay.d.ts.map +1 -0
  25. package/lib/typescript/src/components/TourProvider.d.ts +21 -0
  26. package/lib/typescript/src/components/TourProvider.d.ts.map +1 -0
  27. package/lib/typescript/src/components/TourTooltip.d.ts +2 -0
  28. package/lib/typescript/src/components/TourTooltip.d.ts.map +1 -0
  29. package/lib/typescript/src/components/TourZone.d.ts +16 -0
  30. package/lib/typescript/src/components/TourZone.d.ts.map +1 -0
  31. package/lib/typescript/src/constants/animations.d.ts +34 -0
  32. package/lib/typescript/src/constants/animations.d.ts.map +1 -0
  33. package/lib/typescript/src/constants/defaults.d.ts +10 -0
  34. package/lib/typescript/src/constants/defaults.d.ts.map +1 -0
  35. package/lib/typescript/src/hooks/useTour.d.ts +2 -0
  36. package/lib/typescript/src/hooks/useTour.d.ts.map +1 -0
  37. package/lib/typescript/src/index.d.ts +9 -0
  38. package/lib/typescript/src/index.d.ts.map +1 -0
  39. package/lib/typescript/src/types/index.d.ts +135 -0
  40. package/lib/typescript/src/types/index.d.ts.map +1 -0
  41. package/package.json +171 -0
  42. package/src/components/TourOverlay.tsx +153 -0
  43. package/src/components/TourProvider.tsx +361 -0
  44. package/src/components/TourTooltip.tsx +252 -0
  45. package/src/components/TourZone.tsx +372 -0
  46. package/src/constants/animations.ts +71 -0
  47. package/src/constants/defaults.ts +15 -0
  48. package/src/hooks/useTour.ts +10 -0
  49. package/src/index.tsx +8 -0
  50. package/src/types/index.ts +142 -0
@@ -0,0 +1,361 @@
1
+ import React, {
2
+ createContext,
3
+ useState,
4
+ useCallback,
5
+ useMemo,
6
+ useRef,
7
+ type ComponentType,
8
+ } from 'react';
9
+ import {
10
+ useSharedValue,
11
+ withSpring,
12
+ withTiming,
13
+ useAnimatedRef,
14
+ default as Animated,
15
+ } from 'react-native-reanimated';
16
+ import { StyleSheet } from 'react-native';
17
+ import type {
18
+ TourStep,
19
+ MeasureResult,
20
+ TourConfig,
21
+ InternalTourContextType,
22
+ } from '../types';
23
+ import { TourOverlay } from './TourOverlay';
24
+ import { TourTooltip } from './TourTooltip';
25
+ import {
26
+ DEFAULT_BACKDROP_OPACITY,
27
+ DEFAULT_SPRING_CONFIG,
28
+ } from '../constants/defaults';
29
+
30
+ export const TourContext = createContext<InternalTourContextType | null>(null);
31
+
32
+ interface TourProviderProps {
33
+ children: React.ReactNode;
34
+ /**
35
+ * Optional custom steps order. If provided, the tour will follow this array of keys.
36
+ */
37
+ stepsOrder?: string[];
38
+ /**
39
+ * Initial overlay opacity. Default 0.5
40
+ */
41
+ backdropOpacity?: number;
42
+ /**
43
+ * Global configuration for the tour.
44
+ */
45
+ config?: TourConfig;
46
+ }
47
+
48
+ const AnimatedView = Animated.View as unknown as ComponentType<any>;
49
+
50
+ export const TourProvider: React.FC<TourProviderProps> = ({
51
+ children,
52
+ stepsOrder: initialStepsOrder,
53
+ backdropOpacity = DEFAULT_BACKDROP_OPACITY,
54
+ config,
55
+ }) => {
56
+ const [steps, setSteps] = useState<Record<string, TourStep>>({});
57
+ const [currentStep, setCurrentStep] = useState<string | null>(null);
58
+
59
+ // ref to access latest measurements without causing re-renders
60
+ const measurements = useRef<Record<string, MeasureResult>>({});
61
+ const containerRef = useAnimatedRef<any>();
62
+
63
+ // --- Shared Values for Animations (Zero Bridge Crossing) ---
64
+ // Initialize off-screen or 0
65
+ const targetX = useSharedValue(0);
66
+ const targetY = useSharedValue(0);
67
+ const targetWidth = useSharedValue(0);
68
+ const targetHeight = useSharedValue(0);
69
+ const targetRadius = useSharedValue(10); // Default border radius
70
+ const opacity = useSharedValue(0); // 0 = hidden, 1 = visible
71
+
72
+ // Helper to animate to a specific step's layout
73
+ const animateToStep = useCallback(
74
+ (stepKey: string) => {
75
+ const measure = measurements.current[stepKey];
76
+ if (measure) {
77
+ // Validate measurements before animating
78
+ if (
79
+ !measure.width ||
80
+ !measure.height ||
81
+ measure.width <= 0 ||
82
+ measure.height <= 0 ||
83
+ isNaN(measure.x) ||
84
+ isNaN(measure.y) ||
85
+ isNaN(measure.width) ||
86
+ isNaN(measure.height)
87
+ ) {
88
+ console.warn(
89
+ '[TourProvider] Invalid measurements for step:',
90
+ stepKey,
91
+ measure
92
+ );
93
+ return;
94
+ }
95
+
96
+ const springConfig = config?.springConfig ?? DEFAULT_SPRING_CONFIG;
97
+
98
+ targetX.value = withSpring(measure.x, springConfig);
99
+ targetY.value = withSpring(measure.y, springConfig);
100
+ targetWidth.value = withSpring(measure.width, springConfig);
101
+ targetHeight.value = withSpring(measure.height, springConfig);
102
+
103
+ // If measure result has radius or step meta has radius, use it.
104
+ // For now defaulting to 10 or meta reading if we passed it back in measure?
105
+ // Let's assume meta is in steps state.
106
+ const step = steps[stepKey];
107
+ const radius = step?.meta?.borderRadius ?? 10;
108
+ targetRadius.value = withSpring(radius, springConfig);
109
+
110
+ // Ensure overlay is visible
111
+ opacity.value = withTiming(backdropOpacity, { duration: 300 });
112
+ } else {
113
+ console.warn('[TourProvider] No measurements found for step:', stepKey);
114
+ }
115
+ },
116
+ [
117
+ backdropOpacity,
118
+ targetX,
119
+ targetY,
120
+ targetWidth,
121
+ targetHeight,
122
+ targetRadius,
123
+ opacity,
124
+ config?.springConfig,
125
+ steps,
126
+ ]
127
+ );
128
+
129
+ const registerStep = useCallback((step: TourStep) => {
130
+ setSteps((prev) => ({ ...prev, [step.key]: step }));
131
+ }, []);
132
+
133
+ const unregisterStep = useCallback((key: string) => {
134
+ setSteps((prev) => {
135
+ const newSteps = { ...prev };
136
+ delete newSteps[key];
137
+ return newSteps;
138
+ });
139
+ }, []);
140
+
141
+ const updateStepLayout = useCallback(
142
+ (key: string, measure: MeasureResult) => {
143
+ // Validate measurements before storing
144
+ if (
145
+ !measure.width ||
146
+ !measure.height ||
147
+ measure.width <= 0 ||
148
+ measure.height <= 0 ||
149
+ isNaN(measure.x) ||
150
+ isNaN(measure.y) ||
151
+ isNaN(measure.width) ||
152
+ isNaN(measure.height) ||
153
+ !isFinite(measure.x) ||
154
+ !isFinite(measure.y) ||
155
+ !isFinite(measure.width) ||
156
+ !isFinite(measure.height)
157
+ ) {
158
+ console.warn(
159
+ '[TourProvider] Invalid measurement update for step:',
160
+ key,
161
+ measure
162
+ );
163
+ return;
164
+ }
165
+
166
+ measurements.current[key] = measure;
167
+ // If this step is currently active (e.g. scroll happened or resize), update shared values on the fly
168
+ if (currentStep === key) {
169
+ const springConfig = config?.springConfig ?? DEFAULT_SPRING_CONFIG;
170
+
171
+ targetX.value = withSpring(measure.x, springConfig);
172
+ targetY.value = withSpring(measure.y, springConfig);
173
+ targetWidth.value = withSpring(measure.width, springConfig);
174
+ targetHeight.value = withSpring(measure.height, springConfig);
175
+
176
+ // Update radius if available
177
+ const step = steps[key];
178
+ const radius = step?.meta?.borderRadius ?? 10;
179
+ targetRadius.value = withSpring(radius, springConfig);
180
+
181
+ // Ensure overlay is visible (fixes race condition where start() was called before measure)
182
+ opacity.value = withTiming(backdropOpacity, { duration: 300 });
183
+ }
184
+ },
185
+ [
186
+ currentStep,
187
+ targetX,
188
+ targetY,
189
+ targetWidth,
190
+ targetHeight,
191
+ targetRadius,
192
+ opacity,
193
+ backdropOpacity,
194
+ config?.springConfig,
195
+ steps,
196
+ ]
197
+ );
198
+
199
+ const getOrderedSteps = useCallback(() => {
200
+ if (initialStepsOrder) return initialStepsOrder;
201
+ // If order property exists on steps, sort by it.
202
+ const stepKeys = Object.keys(steps);
203
+ if (stepKeys.length > 0) {
204
+ // Check if any step has order
205
+ const hasOrder = stepKeys.some(
206
+ (key) => typeof steps[key]?.order === 'number'
207
+ );
208
+ if (hasOrder) {
209
+ return stepKeys.sort(
210
+ (a, b) => (steps[a]?.order ?? 0) - (steps[b]?.order ?? 0)
211
+ );
212
+ }
213
+ }
214
+ return stepKeys;
215
+ }, [initialStepsOrder, steps]);
216
+
217
+ const start = useCallback(
218
+ (stepKey?: string) => {
219
+ const ordered = getOrderedSteps();
220
+ const firstStep = stepKey || ordered[0];
221
+ if (firstStep) {
222
+ setCurrentStep(firstStep);
223
+ // We need to wait for layout if it's not ready?
224
+ // Assuming layout is ready since components are mounted.
225
+ // But if we start immediately on mount, might be tricky.
226
+ // For now assume standard flow.
227
+ // requestAnimationFrame to ensure state update propagates if needed,
228
+ // but simple call is usually fine.
229
+ setTimeout(() => animateToStep(firstStep), 0);
230
+ }
231
+ },
232
+ [getOrderedSteps, animateToStep]
233
+ );
234
+
235
+ const stop = useCallback(() => {
236
+ setCurrentStep(null);
237
+ opacity.value = withTiming(0, { duration: 300 });
238
+ }, [opacity]);
239
+
240
+ const next = useCallback(() => {
241
+ if (!currentStep) return;
242
+ const ordered = getOrderedSteps();
243
+ const currentIndex = ordered.indexOf(currentStep);
244
+ if (currentIndex < ordered.length - 1) {
245
+ const nextStep = ordered[currentIndex + 1];
246
+ if (nextStep) {
247
+ setCurrentStep(nextStep);
248
+ // Don't call animateToStep here - it uses cached measurements that may be stale
249
+ // after scroll. The useFrameCallback in TourZone will handle position tracking
250
+ // using measure() with correct screen coordinates (pageX/pageY).
251
+ // Just ensure the overlay is visible.
252
+ opacity.value = withTiming(backdropOpacity, { duration: 300 });
253
+ } else {
254
+ stop();
255
+ }
256
+ } else {
257
+ stop(); // End of tour
258
+ }
259
+ }, [currentStep, getOrderedSteps, stop, opacity, backdropOpacity]);
260
+
261
+ const prev = useCallback(() => {
262
+ if (!currentStep) return;
263
+ const ordered = getOrderedSteps();
264
+ const currentIndex = ordered.indexOf(currentStep);
265
+ if (currentIndex > 0) {
266
+ const prevStep = ordered[currentIndex - 1];
267
+ if (prevStep) {
268
+ setCurrentStep(prevStep);
269
+ // Don't call animateToStep - let useFrameCallback handle position tracking
270
+ opacity.value = withTiming(backdropOpacity, { duration: 300 });
271
+ }
272
+ }
273
+ }, [currentStep, getOrderedSteps, opacity, backdropOpacity]);
274
+
275
+ const scrollViewRef = useAnimatedRef<any>();
276
+
277
+ const setScrollViewRef = useCallback((_ref: any) => {
278
+ // If user passes a ref, we might want to sync it?
279
+ // Or we just provide this function for them to give us the ref.
280
+ // With useAnimatedRef, we can assign it if it's a function or object?
281
+ // Actually, safest is to let them assign our ref to their component.
282
+ // But they might have their own ref.
283
+ // Let's assume they call this with their ref.
284
+ // BUT useAnimatedRef cannot easily accept an external ref object to "become".
285
+ // Pattern: They should use the ref we give them, OR we wrap their component?
286
+ // Simpler: We just expose 'scrollViewRef' from context, and they attach it.
287
+ // So 'setScrollViewRef' might be redundant if we just say "here is the ref, use it".
288
+ // But if they have their own, they can't usage two refs easily without merging.
289
+ // Let's stick to exposing `scrollViewRef` from context that they MUST use.
290
+ // But wait, the interface says `setScrollViewRef`.
291
+ // Let's keep `setScrollViewRef` as a no-op or a way to manually set it if needed (not RecAnimated friendly).
292
+ // Actually, let's just expose `scrollViewRef` and `registerScrollView` which essentially does nothing if we expect them to use the ref object.
293
+ // Let's make `setScrollViewRef` actually do something if possible, or just document "Use exposed scrollViewRef".
294
+ // For now, let's just return the `scrollViewRef` we created.
295
+ }, []);
296
+
297
+ const value = useMemo<InternalTourContextType>(
298
+ () => ({
299
+ start,
300
+ stop,
301
+ next,
302
+ prev,
303
+ registerStep,
304
+ unregisterStep,
305
+ updateStepLayout,
306
+ currentStep,
307
+ targetX,
308
+ targetY,
309
+ targetWidth,
310
+ targetHeight,
311
+ targetRadius,
312
+ opacity,
313
+ steps,
314
+ config,
315
+ containerRef,
316
+ scrollViewRef,
317
+ setScrollViewRef,
318
+ }),
319
+ [
320
+ start,
321
+ stop,
322
+ next,
323
+ prev,
324
+ registerStep,
325
+ unregisterStep,
326
+ updateStepLayout,
327
+ currentStep,
328
+ targetX,
329
+ targetY,
330
+ targetWidth,
331
+ targetHeight,
332
+ targetRadius,
333
+ opacity,
334
+ steps,
335
+ config,
336
+ // containerRef is stable
337
+ scrollViewRef,
338
+ setScrollViewRef,
339
+ ]
340
+ );
341
+
342
+ return (
343
+ <TourContext.Provider value={value}>
344
+ <AnimatedView
345
+ ref={containerRef}
346
+ style={styles.container}
347
+ collapsable={false}
348
+ >
349
+ {children}
350
+ <TourOverlay />
351
+ <TourTooltip />
352
+ </AnimatedView>
353
+ </TourContext.Provider>
354
+ );
355
+ };
356
+
357
+ const styles = StyleSheet.create({
358
+ container: {
359
+ flex: 1,
360
+ },
361
+ });
@@ -0,0 +1,252 @@
1
+ import { useMemo, memo, useState, type ComponentType } from 'react';
2
+ import {
3
+ StyleSheet,
4
+ Text,
5
+ View,
6
+ Dimensions,
7
+ TouchableOpacity,
8
+ } from 'react-native';
9
+ import Animated, {
10
+ useAnimatedStyle,
11
+ useSharedValue,
12
+ interpolate,
13
+ Extrapolation,
14
+ } from 'react-native-reanimated';
15
+ import { useTour } from '../hooks/useTour';
16
+ import type { CardProps, InternalTourContextType } from '../types';
17
+ import { DEFAULT_LABELS } from '../constants/defaults';
18
+
19
+ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
20
+
21
+ export const TourTooltip = memo(() => {
22
+ const {
23
+ targetX,
24
+ targetY,
25
+ targetWidth,
26
+ targetHeight,
27
+ currentStep,
28
+ steps,
29
+ next,
30
+ prev,
31
+ stop,
32
+ opacity,
33
+ config,
34
+ } = useTour() as InternalTourContextType;
35
+
36
+ const currentStepData = currentStep ? steps[currentStep] : null;
37
+
38
+ const tooltipHeight = useSharedValue(150);
39
+ const [tooltipWidth] = useState(280);
40
+
41
+ const orderedSteps = useMemo(() => {
42
+ const keys = Object.keys(steps);
43
+ if (keys.length > 0) {
44
+ return keys.sort(
45
+ (a, b) => (steps[a]?.order ?? 0) - (steps[b]?.order ?? 0)
46
+ );
47
+ }
48
+ return keys;
49
+ }, [steps]);
50
+
51
+ const currentIndex = currentStep ? orderedSteps.indexOf(currentStep) : -1;
52
+ const totalSteps = orderedSteps.length;
53
+ const isFirst = currentIndex === 0;
54
+ const isLast = currentIndex === totalSteps - 1;
55
+
56
+ const tooltipStyle = useAnimatedStyle(() => {
57
+ 'worklet';
58
+
59
+ const safeTargetX = targetX.value || 0;
60
+ const safeTargetY = targetY.value || 0;
61
+ const safeTargetWidth = Math.max(targetWidth.value || 0, 1);
62
+ const safeTargetHeight = Math.max(targetHeight.value || 0, 1);
63
+ const safeTooltipHeight = tooltipHeight.value || 150;
64
+
65
+ // FIX: Aggressive Interpolation
66
+ // Map the input value [0 -> 0.6] to output [0 -> 1].
67
+ // This ensures that even if 'opacity.value' stops at 0.7 (backdrop level),
68
+ // the tooltip is already fully opaque (1.0).
69
+ const activeOpacity = interpolate(
70
+ opacity.value,
71
+ [0, 0.6],
72
+ [0, 1],
73
+ Extrapolation.CLAMP
74
+ );
75
+
76
+ const spaceAbove = safeTargetY;
77
+ const spaceBelow = SCREEN_HEIGHT - (safeTargetY + safeTargetHeight);
78
+
79
+ const shouldPlaceAbove =
80
+ (spaceAbove > spaceBelow && spaceAbove > safeTooltipHeight + 30) ||
81
+ (safeTargetY > SCREEN_HEIGHT / 2 && spaceAbove > safeTooltipHeight + 20);
82
+
83
+ const horizontalCenter = safeTargetX + safeTargetWidth / 2;
84
+ const left = horizontalCenter - tooltipWidth / 2;
85
+
86
+ const clampedLeft = Math.max(
87
+ 12,
88
+ Math.min(SCREEN_WIDTH - tooltipWidth - 12, left)
89
+ );
90
+
91
+ const style: any = {
92
+ position: 'absolute',
93
+ width: tooltipWidth,
94
+ left: clampedLeft,
95
+ opacity: activeOpacity,
96
+ // Add explicit background here for Reanimated to treat it as a solid block layer
97
+ backgroundColor: 'white',
98
+ transform: [{ translateY: interpolate(activeOpacity, [0, 1], [10, 0]) }],
99
+ };
100
+
101
+ if (shouldPlaceAbove) {
102
+ style.top = Math.max(10, safeTargetY - safeTooltipHeight - 20);
103
+ style.bottom = undefined;
104
+ } else {
105
+ style.top = safeTargetY + safeTargetHeight + 20;
106
+ style.bottom = undefined;
107
+ }
108
+
109
+ return style;
110
+ });
111
+
112
+ if (!currentStepData) return null;
113
+
114
+ const AnimatedView = Animated.View as unknown as ComponentType<any>;
115
+
116
+ const handleTooltipLayout = (event: any) => {
117
+ const { height } = event.nativeEvent.layout;
118
+ if (height > 0) {
119
+ tooltipHeight.value = height;
120
+ }
121
+ };
122
+
123
+ // Custom Render
124
+ if (config?.renderCard) {
125
+ const cardProps: CardProps = {
126
+ step: currentStepData,
127
+ currentStepIndex: currentIndex,
128
+ totalSteps,
129
+ next,
130
+ prev,
131
+ stop,
132
+ isFirst,
133
+ isLast,
134
+ labels: config.labels,
135
+ };
136
+ return (
137
+ <AnimatedView
138
+ style={[
139
+ styles.container,
140
+ tooltipStyle,
141
+ // Reset styles for custom render so the user has full control
142
+ {
143
+ backgroundColor: 'transparent',
144
+ shadowOpacity: 0,
145
+ elevation: 0,
146
+ padding: 0,
147
+ borderRadius: 0,
148
+ },
149
+ ]}
150
+ onLayout={handleTooltipLayout}
151
+ >
152
+ {config.renderCard(cardProps)}
153
+ </AnimatedView>
154
+ );
155
+ }
156
+
157
+ // Default Render
158
+ const labels = { ...DEFAULT_LABELS, ...config?.labels };
159
+ const labelNext = isLast ? labels.finish : labels.next;
160
+ const labelSkip = labels.skip;
161
+
162
+ return (
163
+ <AnimatedView
164
+ // Combined styles: Container (Shadows) + CardStyle (White BG) + TooltipStyle (Position/Opacity)
165
+ style={[styles.container, styles.cardStyle, tooltipStyle]}
166
+ onLayout={handleTooltipLayout}
167
+ >
168
+ <View style={styles.header}>
169
+ <Text style={styles.title}>{currentStepData.name || 'Step'}</Text>
170
+ <Text style={styles.stepIndicator}>
171
+ {currentIndex + 1} / {totalSteps}
172
+ </Text>
173
+ </View>
174
+ <Text style={styles.description}>{currentStepData.description}</Text>
175
+
176
+ <View style={styles.footer}>
177
+ {!isLast && (
178
+ <TouchableOpacity onPress={stop} style={styles.buttonText}>
179
+ <Text style={styles.skipText}>{labelSkip}</Text>
180
+ </TouchableOpacity>
181
+ )}
182
+ {isLast && <View style={{ width: 10 }} />}
183
+
184
+ <TouchableOpacity onPress={next} style={styles.buttonPrimary}>
185
+ <Text style={styles.primaryButtonText}>{labelNext}</Text>
186
+ </TouchableOpacity>
187
+ </View>
188
+ </AnimatedView>
189
+ );
190
+ });
191
+
192
+ const styles = StyleSheet.create({
193
+ container: {
194
+ // Shadow Props
195
+ shadowColor: '#000',
196
+ shadowOffset: { width: 0, height: 4 },
197
+ shadowOpacity: 0.3,
198
+ shadowRadius: 5,
199
+ elevation: 8,
200
+ zIndex: 999,
201
+ },
202
+ cardStyle: {
203
+ backgroundColor: 'white', // Ensure this is solid white
204
+ borderRadius: 12,
205
+ padding: 20,
206
+ minHeight: 120,
207
+ },
208
+ header: {
209
+ flexDirection: 'row',
210
+ justifyContent: 'space-between',
211
+ marginBottom: 8,
212
+ },
213
+ stepIndicator: {
214
+ fontSize: 12,
215
+ color: '#999',
216
+ },
217
+ title: {
218
+ fontSize: 18,
219
+ fontWeight: 'bold',
220
+ color: '#000',
221
+ flex: 1,
222
+ },
223
+ description: {
224
+ fontSize: 15,
225
+ color: '#444',
226
+ marginBottom: 20,
227
+ lineHeight: 22,
228
+ },
229
+ footer: {
230
+ flexDirection: 'row',
231
+ justifyContent: 'space-between',
232
+ alignItems: 'center',
233
+ },
234
+ buttonText: {
235
+ padding: 8,
236
+ },
237
+ skipText: {
238
+ color: '#666',
239
+ fontWeight: '600',
240
+ },
241
+ buttonPrimary: {
242
+ backgroundColor: '#007AFF',
243
+ paddingVertical: 10,
244
+ paddingHorizontal: 20,
245
+ borderRadius: 25,
246
+ },
247
+ primaryButtonText: {
248
+ color: '#fff',
249
+ fontWeight: 'bold',
250
+ fontSize: 14,
251
+ },
252
+ });