flowboard-react 0.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.
Files changed (90) hide show
  1. package/FlowboardReact.podspec +20 -0
  2. package/LICENSE +20 -0
  3. package/README.md +122 -0
  4. package/android/build.gradle +67 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/flowboardreact/FlowboardReactModule.kt +15 -0
  7. package/android/src/main/java/com/flowboardreact/FlowboardReactPackage.kt +33 -0
  8. package/ios/FlowboardReact.h +5 -0
  9. package/ios/FlowboardReact.mm +21 -0
  10. package/lib/module/Flowboard.js +167 -0
  11. package/lib/module/Flowboard.js.map +1 -0
  12. package/lib/module/FlowboardProvider.js +52 -0
  13. package/lib/module/FlowboardProvider.js.map +1 -0
  14. package/lib/module/NativeFlowboardReact.js +5 -0
  15. package/lib/module/NativeFlowboardReact.js.map +1 -0
  16. package/lib/module/components/FlowboardFlow.js +389 -0
  17. package/lib/module/components/FlowboardFlow.js.map +1 -0
  18. package/lib/module/components/FlowboardRenderer.js +1684 -0
  19. package/lib/module/components/FlowboardRenderer.js.map +1 -0
  20. package/lib/module/components/widgets/sliderRegistry.js +48 -0
  21. package/lib/module/components/widgets/sliderRegistry.js.map +1 -0
  22. package/lib/module/core/analyticsManager.js +110 -0
  23. package/lib/module/core/analyticsManager.js.map +1 -0
  24. package/lib/module/core/assetPreloader.js +72 -0
  25. package/lib/module/core/assetPreloader.js.map +1 -0
  26. package/lib/module/core/clientContext.js +105 -0
  27. package/lib/module/core/clientContext.js.map +1 -0
  28. package/lib/module/core/fontAwesome.js +110 -0
  29. package/lib/module/core/fontAwesome.js.map +1 -0
  30. package/lib/module/core/onboardingRepository.js +62 -0
  31. package/lib/module/core/onboardingRepository.js.map +1 -0
  32. package/lib/module/core/resolverService.js +58 -0
  33. package/lib/module/core/resolverService.js.map +1 -0
  34. package/lib/module/index.js +5 -0
  35. package/lib/module/index.js.map +1 -0
  36. package/lib/module/package.json +1 -0
  37. package/lib/module/types/flowboard.js +4 -0
  38. package/lib/module/types/flowboard.js.map +1 -0
  39. package/lib/module/types/react-native-vector-icons.d.js +2 -0
  40. package/lib/module/types/react-native-vector-icons.d.js.map +1 -0
  41. package/lib/module/utils/flowboardUtils.js +379 -0
  42. package/lib/module/utils/flowboardUtils.js.map +1 -0
  43. package/lib/typescript/package.json +1 -0
  44. package/lib/typescript/src/Flowboard.d.ts +33 -0
  45. package/lib/typescript/src/Flowboard.d.ts.map +1 -0
  46. package/lib/typescript/src/FlowboardProvider.d.ts +5 -0
  47. package/lib/typescript/src/FlowboardProvider.d.ts.map +1 -0
  48. package/lib/typescript/src/NativeFlowboardReact.d.ts +7 -0
  49. package/lib/typescript/src/NativeFlowboardReact.d.ts.map +1 -0
  50. package/lib/typescript/src/components/FlowboardFlow.d.ts +14 -0
  51. package/lib/typescript/src/components/FlowboardFlow.d.ts.map +1 -0
  52. package/lib/typescript/src/components/FlowboardRenderer.d.ts +31 -0
  53. package/lib/typescript/src/components/FlowboardRenderer.d.ts.map +1 -0
  54. package/lib/typescript/src/components/widgets/sliderRegistry.d.ts +16 -0
  55. package/lib/typescript/src/components/widgets/sliderRegistry.d.ts.map +1 -0
  56. package/lib/typescript/src/core/analyticsManager.d.ts +42 -0
  57. package/lib/typescript/src/core/analyticsManager.d.ts.map +1 -0
  58. package/lib/typescript/src/core/assetPreloader.d.ts +8 -0
  59. package/lib/typescript/src/core/assetPreloader.d.ts.map +1 -0
  60. package/lib/typescript/src/core/clientContext.d.ts +27 -0
  61. package/lib/typescript/src/core/clientContext.d.ts.map +1 -0
  62. package/lib/typescript/src/core/fontAwesome.d.ts +8 -0
  63. package/lib/typescript/src/core/fontAwesome.d.ts.map +1 -0
  64. package/lib/typescript/src/core/onboardingRepository.d.ts +15 -0
  65. package/lib/typescript/src/core/onboardingRepository.d.ts.map +1 -0
  66. package/lib/typescript/src/core/resolverService.d.ts +11 -0
  67. package/lib/typescript/src/core/resolverService.d.ts.map +1 -0
  68. package/lib/typescript/src/index.d.ts +4 -0
  69. package/lib/typescript/src/index.d.ts.map +1 -0
  70. package/lib/typescript/src/types/flowboard.d.ts +34 -0
  71. package/lib/typescript/src/types/flowboard.d.ts.map +1 -0
  72. package/lib/typescript/src/utils/flowboardUtils.d.ts +31 -0
  73. package/lib/typescript/src/utils/flowboardUtils.d.ts.map +1 -0
  74. package/package.json +192 -0
  75. package/src/Flowboard.ts +223 -0
  76. package/src/FlowboardProvider.tsx +60 -0
  77. package/src/NativeFlowboardReact.ts +7 -0
  78. package/src/components/FlowboardFlow.tsx +513 -0
  79. package/src/components/FlowboardRenderer.tsx +1957 -0
  80. package/src/components/widgets/sliderRegistry.tsx +56 -0
  81. package/src/core/analyticsManager.ts +125 -0
  82. package/src/core/assetPreloader.ts +103 -0
  83. package/src/core/clientContext.ts +132 -0
  84. package/src/core/fontAwesome.ts +90 -0
  85. package/src/core/onboardingRepository.ts +79 -0
  86. package/src/core/resolverService.ts +69 -0
  87. package/src/index.tsx +11 -0
  88. package/src/types/flowboard.ts +50 -0
  89. package/src/types/react-native-vector-icons.d.ts +15 -0
  90. package/src/utils/flowboardUtils.ts +400 -0
@@ -0,0 +1,513 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Alert, Keyboard, Platform, StyleSheet, View } from 'react-native';
3
+ import PagerView, {
4
+ type PagerViewOnPageSelectedEvent,
5
+ } from 'react-native-pager-view';
6
+ import { Linking } from 'react-native';
7
+ import InAppReview from 'react-native-in-app-review';
8
+ import {
9
+ openSettings,
10
+ PERMISSIONS,
11
+ request,
12
+ requestNotifications,
13
+ RESULTS,
14
+ } from 'react-native-permissions';
15
+ import { AssetPreloader } from '../core/assetPreloader';
16
+ import { AnalyticsManager, OnboardingOutcome } from '../core/analyticsManager';
17
+ import { OnboardingRepository } from '../core/onboardingRepository';
18
+ import FlowboardRenderer from './FlowboardRenderer';
19
+ import {
20
+ SliderRegistry,
21
+ SliderRegistryProvider,
22
+ } from './widgets/sliderRegistry';
23
+ import type {
24
+ CustomActionBuilder,
25
+ CustomScreenBuilder,
26
+ FlowboardContext,
27
+ FlowboardData,
28
+ OnboardingEndCallback,
29
+ OnStepChangeCallback,
30
+ } from '../types/flowboard';
31
+
32
+ const styles = StyleSheet.create({
33
+ container: { flex: 1, backgroundColor: 'transparent' },
34
+ fullFlex: { flex: 1 },
35
+ });
36
+
37
+ type FlowboardFlowProps = {
38
+ data: FlowboardData;
39
+ customScreenBuilder?: CustomScreenBuilder;
40
+ customActionBuilder?: CustomActionBuilder;
41
+ onOnboardEnd?: OnboardingEndCallback;
42
+ onStepChange?: OnStepChangeCallback;
43
+ initialStep?: number;
44
+ initialFormData?: Record<string, any>;
45
+ onClose?: () => void;
46
+ };
47
+
48
+ export default function FlowboardFlow(props: FlowboardFlowProps) {
49
+ const {
50
+ data,
51
+ customScreenBuilder,
52
+ customActionBuilder,
53
+ onOnboardEnd,
54
+ onStepChange,
55
+ initialStep = 0,
56
+ initialFormData = {},
57
+ onClose,
58
+ } = props;
59
+
60
+ const screens = Array.isArray(data.screens) ? data.screens : [];
61
+ const flowId = data.flow_id as string | undefined;
62
+ const repository = useMemo(() => new OnboardingRepository(), []);
63
+ const assetPreloader = useMemo(() => new AssetPreloader(), []);
64
+ const sliderRegistry = useMemo(() => new SliderRegistry(), []);
65
+ const pagerRef = useRef<PagerView>(null);
66
+ const [currentIndex, setCurrentIndex] = useState(() =>
67
+ resolveInitialPage(initialStep, screens.length)
68
+ );
69
+ const [formData, setFormData] = useState<Record<string, any>>({
70
+ ...initialFormData,
71
+ });
72
+ const completedRef = useRef(false);
73
+ const flowStartTimeRef = useRef(Date.now());
74
+
75
+ useEffect(() => {
76
+ AnalyticsManager.instance.startSession({ flowData: data });
77
+ AnalyticsManager.instance.trackOnboardStarted();
78
+
79
+ requestAnimationFrame(() => {
80
+ preloadAssets(currentIndex);
81
+ notifyStepChange(currentIndex);
82
+ });
83
+
84
+ return () => {
85
+ if (!completedRef.current) {
86
+ trackCompletion(OnboardingOutcome.dismissed, currentIndex);
87
+ }
88
+ AnalyticsManager.instance.endSession();
89
+ assetPreloader.clear();
90
+ };
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, []);
93
+
94
+ useEffect(() => {
95
+ if (pagerRef.current) {
96
+ pagerRef.current.setPageWithoutAnimation(currentIndex);
97
+ }
98
+ }, [currentIndex]);
99
+
100
+ const notifyStepChange = (index: number) => {
101
+ if (index < 0 || index >= screens.length) return;
102
+ const screenData = screens[index] as Record<string, any>;
103
+ const pageId = screenData.id ?? 'unknown';
104
+
105
+ AnalyticsManager.instance.trackScreenView({
106
+ stepId: pageId,
107
+ stepIndex: index,
108
+ stepName: screenData.name,
109
+ });
110
+
111
+ if (onStepChange) {
112
+ onStepChange(pageId, index, { ...formData });
113
+ }
114
+
115
+ persistProgress(index);
116
+ preloadAssets(index);
117
+ };
118
+
119
+ const persistProgress = (index: number) => {
120
+ if (!flowId) return;
121
+ repository
122
+ .saveProgress({ flowId, stepIndex: index, formData: { ...formData } })
123
+ .catch(() => null);
124
+ };
125
+
126
+ const resetStoredProgress = () => {
127
+ repository.clearProgress().catch(() => null);
128
+ };
129
+
130
+ const preloadAssets = (index: number) => {
131
+ const currentScreen = screens[index];
132
+ if (currentScreen) {
133
+ assetPreloader.preloadScreenAssets(currentScreen).catch(() => null);
134
+ }
135
+ const nextScreen = screens[index + 1];
136
+ if (nextScreen) {
137
+ assetPreloader.preloadScreenAssets(nextScreen).catch(() => null);
138
+ }
139
+ };
140
+
141
+ const trackCompletion = (outcome: OnboardingOutcome, index: number) => {
142
+ if (completedRef.current) return;
143
+ completedRef.current = true;
144
+ assetPreloader.clear();
145
+
146
+ const duration = Date.now() - flowStartTimeRef.current;
147
+ let finalStepId = 'unknown';
148
+ if (screens[index]) {
149
+ finalStepId = screens[index].id ?? 'unknown';
150
+ } else if (screens[0]) {
151
+ finalStepId = screens[0].id ?? 'unknown';
152
+ }
153
+
154
+ AnalyticsManager.instance.trackOnboardEnded({
155
+ outcome,
156
+ totalDurationMs: duration,
157
+ finalStepId,
158
+ });
159
+ };
160
+
161
+ const createContext = (index: number): FlowboardContext => ({
162
+ context: null,
163
+ currentIndex: index,
164
+ totalScreens: screens.length,
165
+ formData: { ...formData },
166
+ screenData: screens[index] as Record<string, any>,
167
+ onNext: () => handleAction('next', index, screens.length),
168
+ onPrevious: () => handleAction('previous', index, screens.length),
169
+ onFinish: () => handleAction('finish', index, screens.length),
170
+ });
171
+
172
+ const handleAction = (
173
+ action: string,
174
+ index: number,
175
+ totalScreens: number,
176
+ dataPayload?: Record<string, any>
177
+ ) => {
178
+ Keyboard.dismiss();
179
+
180
+ if (action === 'next') {
181
+ const currentScreen = screens[index] as Record<string, any>;
182
+ const invalidFields = validateScreen(currentScreen, formData);
183
+ if (invalidFields.length > 0) {
184
+ Alert.alert(
185
+ 'Validation',
186
+ 'Please fill in all required fields properly.'
187
+ );
188
+ return;
189
+ }
190
+
191
+ if (index < totalScreens - 1) {
192
+ setCurrentIndex(index + 1);
193
+ } else {
194
+ trackCompletion(OnboardingOutcome.completed, index);
195
+ onOnboardEnd?.({ ...formData });
196
+ resetStoredProgress();
197
+ onClose?.();
198
+ }
199
+ return;
200
+ }
201
+
202
+ if (action === 'previous') {
203
+ if (index > 0) {
204
+ setCurrentIndex(index - 1);
205
+ }
206
+ return;
207
+ }
208
+
209
+ if (action === 'finish') {
210
+ trackCompletion(OnboardingOutcome.completed, index);
211
+ onOnboardEnd?.({ ...formData });
212
+ resetStoredProgress();
213
+ onClose?.();
214
+ return;
215
+ }
216
+
217
+ if (action === 'deeplink' || action === 'weblink') {
218
+ handleLinkAction(index, dataPayload);
219
+ return;
220
+ }
221
+
222
+ if (action === 'rate_app') {
223
+ handleRateAppAction(index, totalScreens);
224
+ return;
225
+ }
226
+
227
+ if (action === 'request_permission') {
228
+ handlePermissionAction(index, totalScreens, dataPayload);
229
+ return;
230
+ }
231
+
232
+ const customHandler = customActionBuilder;
233
+ if (!customHandler) return;
234
+ const ctx = createContext(index);
235
+ customHandler(action, ctx, dataPayload);
236
+ };
237
+
238
+ const handleLinkAction = async (
239
+ index: number,
240
+ dataPayload?: Record<string, any>
241
+ ) => {
242
+ const screenData = screens[index] as Record<string, any>;
243
+ let url = dataPayload?.url ?? screenData?.url;
244
+ if (!url) return;
245
+ try {
246
+ const canOpen = await Linking.canOpenURL(url);
247
+ if (canOpen) {
248
+ await Linking.openURL(url);
249
+ }
250
+ } catch {
251
+ // ignore
252
+ }
253
+ };
254
+
255
+ const handleRateAppAction = async (index: number, totalScreens: number) => {
256
+ try {
257
+ if (InAppReview.isAvailable()) {
258
+ InAppReview.RequestInAppReview();
259
+ }
260
+ } catch {
261
+ // ignore
262
+ }
263
+
264
+ if (index < totalScreens - 1) {
265
+ setCurrentIndex(index + 1);
266
+ } else {
267
+ trackCompletion(OnboardingOutcome.completed, index);
268
+ onOnboardEnd?.({ ...formData });
269
+ resetStoredProgress();
270
+ onClose?.();
271
+ }
272
+ };
273
+
274
+ const handlePermissionAction = async (
275
+ index: number,
276
+ totalScreens: number,
277
+ dataPayload?: Record<string, any>
278
+ ) => {
279
+ const permissionKey = String(
280
+ dataPayload?.actionPermission ??
281
+ dataPayload?.permission ??
282
+ dataPayload?.permissionType ??
283
+ ''
284
+ )
285
+ .trim()
286
+ .toLowerCase();
287
+
288
+ if (!permissionKey) {
289
+ Alert.alert('Permission', 'Permission type is missing for this action.');
290
+ return;
291
+ }
292
+
293
+ const result = await requestPermission(permissionKey);
294
+
295
+ if (result === RESULTS.UNAVAILABLE) {
296
+ Alert.alert(
297
+ 'Permission',
298
+ `${formatPermissionName(permissionKey)} permission is not supported.`
299
+ );
300
+ return;
301
+ }
302
+
303
+ if (result === RESULTS.BLOCKED) {
304
+ Alert.alert(
305
+ 'Permission',
306
+ `${formatPermissionName(
307
+ permissionKey
308
+ )} permission is permanently denied. Enable it in Settings.`,
309
+ [
310
+ { text: 'Cancel', style: 'cancel' },
311
+ { text: 'Settings', onPress: () => openSettings().catch(() => null) },
312
+ ]
313
+ );
314
+ return;
315
+ }
316
+
317
+ if (result === RESULTS.GRANTED || result === RESULTS.LIMITED) {
318
+ handleAction('next', index, totalScreens);
319
+ return;
320
+ }
321
+
322
+ Alert.alert(
323
+ 'Permission',
324
+ `${formatPermissionName(
325
+ permissionKey
326
+ )} permission is required to continue.`
327
+ );
328
+ };
329
+
330
+ const handleInputChange = (id: string, value: any, index: number) => {
331
+ setFormData((prev) => {
332
+ const next = { ...prev, [id]: value };
333
+ return next;
334
+ });
335
+ persistProgress(index);
336
+ };
337
+
338
+ return (
339
+ <View style={styles.container}>
340
+ <PagerView
341
+ ref={pagerRef}
342
+ style={styles.fullFlex}
343
+ scrollEnabled={false}
344
+ initialPage={currentIndex}
345
+ onPageSelected={(event: PagerViewOnPageSelectedEvent) => {
346
+ const nextIndex = event.nativeEvent.position;
347
+ setCurrentIndex(nextIndex);
348
+ notifyStepChange(nextIndex);
349
+ }}
350
+ >
351
+ {screens.map((screen, index) => {
352
+ const screenData = screen as Record<string, any>;
353
+ const screenType = screenData.type;
354
+
355
+ if (screenType === 'custom' && customScreenBuilder) {
356
+ const ctx = createContext(index);
357
+ return (
358
+ <View key={`screen-${index}`} style={styles.fullFlex}>
359
+ {customScreenBuilder(ctx)}
360
+ </View>
361
+ );
362
+ }
363
+
364
+ return (
365
+ <View key={`screen-${index}`} style={styles.fullFlex}>
366
+ <SliderRegistryProvider registry={sliderRegistry}>
367
+ <FlowboardRenderer
368
+ screenData={screenData}
369
+ formData={formData}
370
+ onInputChange={(id, value) =>
371
+ handleInputChange(id, value, index)
372
+ }
373
+ onAction={(action, payload) =>
374
+ handleAction(action, index, screens.length, payload)
375
+ }
376
+ currentIndex={index}
377
+ totalScreens={screens.length}
378
+ />
379
+ </SliderRegistryProvider>
380
+ </View>
381
+ );
382
+ })}
383
+ </PagerView>
384
+ </View>
385
+ );
386
+ }
387
+
388
+ function resolveInitialPage(desired: number, total: number): number {
389
+ if (!total) return 0;
390
+ if (desired <= 0) return 0;
391
+ if (desired >= total) return total - 1;
392
+ return desired;
393
+ }
394
+
395
+ function validateScreen(
396
+ screenData: Record<string, any>,
397
+ formData: Record<string, any>
398
+ ): string[] {
399
+ const invalidFields: string[] = [];
400
+ const children = Array.isArray(screenData.children)
401
+ ? screenData.children
402
+ : [];
403
+
404
+ const checkWidget = (widget: Record<string, any>) => {
405
+ const id = widget.id;
406
+ const props = widget.properties ?? {};
407
+ const childrenJson = widget.children;
408
+ const childJson = widget.child;
409
+
410
+ if (id && props.required === true) {
411
+ const value = formData[id];
412
+ let isValid = true;
413
+
414
+ if (value === null || value === undefined) {
415
+ isValid = false;
416
+ } else if (typeof value === 'string' && value.trim() === '') {
417
+ isValid = false;
418
+ } else if (Array.isArray(value) && value.length === 0) {
419
+ isValid = false;
420
+ }
421
+
422
+ if (
423
+ isValid &&
424
+ typeof value === 'string' &&
425
+ props.regex &&
426
+ String(props.regex).length > 0
427
+ ) {
428
+ try {
429
+ const reg = new RegExp(props.regex);
430
+ if (!reg.test(value)) {
431
+ isValid = false;
432
+ }
433
+ } catch {
434
+ // ignore invalid regex
435
+ }
436
+ }
437
+
438
+ if (!isValid) invalidFields.push(id);
439
+ }
440
+
441
+ if (Array.isArray(childrenJson)) {
442
+ childrenJson.forEach((child) => {
443
+ if (child && typeof child === 'object') checkWidget(child);
444
+ });
445
+ }
446
+ if (childJson && typeof childJson === 'object') {
447
+ checkWidget(childJson);
448
+ }
449
+ };
450
+
451
+ children.forEach((child) => {
452
+ if (child && typeof child === 'object') checkWidget(child);
453
+ });
454
+
455
+ return invalidFields;
456
+ }
457
+
458
+ async function requestPermission(permissionKey: string) {
459
+ switch (permissionKey) {
460
+ case 'push_notifications':
461
+ case 'notifications': {
462
+ const result = await requestNotifications(['alert', 'sound', 'badge']);
463
+ return result.status;
464
+ }
465
+ case 'contacts':
466
+ return request(
467
+ PERMISSIONS.IOS.CONTACTS ?? PERMISSIONS.ANDROID.READ_CONTACTS
468
+ );
469
+ case 'location':
470
+ return request(
471
+ Platform.select({
472
+ ios: PERMISSIONS.IOS.LOCATION_WHEN_IN_USE,
473
+ android: PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION,
474
+ }) as any
475
+ );
476
+ case 'camera':
477
+ return request(
478
+ Platform.select({
479
+ ios: PERMISSIONS.IOS.CAMERA,
480
+ android: PERMISSIONS.ANDROID.CAMERA,
481
+ }) as any
482
+ );
483
+ case 'microphone':
484
+ return request(
485
+ Platform.select({
486
+ ios: PERMISSIONS.IOS.MICROPHONE,
487
+ android: PERMISSIONS.ANDROID.RECORD_AUDIO,
488
+ }) as any
489
+ );
490
+ case 'photos':
491
+ case 'photo_library':
492
+ return request(
493
+ Platform.select({
494
+ ios: PERMISSIONS.IOS.PHOTO_LIBRARY,
495
+ android:
496
+ PERMISSIONS.ANDROID.READ_MEDIA_IMAGES ??
497
+ PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE,
498
+ }) as any
499
+ );
500
+ default:
501
+ return RESULTS.UNAVAILABLE;
502
+ }
503
+ }
504
+
505
+ function formatPermissionName(key: string): string {
506
+ return key
507
+ .replace(/_/g, ' ')
508
+ .trim()
509
+ .split(' ')
510
+ .filter(Boolean)
511
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
512
+ .join(' ');
513
+ }