@mobana/react-native-sdk 0.2.10

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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -0
  3. package/android/build.gradle +50 -0
  4. package/android/src/main/AndroidManifest.xml +6 -0
  5. package/android/src/main/java/ai/mobana/sdk/MobanaModule.kt +67 -0
  6. package/android/src/main/java/ai/mobana/sdk/MobanaPackage.kt +19 -0
  7. package/app.plugin.js +274 -0
  8. package/ios/Mobana.h +11 -0
  9. package/ios/Mobana.m +20 -0
  10. package/lib/commonjs/Mobana.js +676 -0
  11. package/lib/commonjs/Mobana.js.map +1 -0
  12. package/lib/commonjs/NativeMobana.js +53 -0
  13. package/lib/commonjs/NativeMobana.js.map +1 -0
  14. package/lib/commonjs/api.js +201 -0
  15. package/lib/commonjs/api.js.map +1 -0
  16. package/lib/commonjs/bridge/index.js +19 -0
  17. package/lib/commonjs/bridge/index.js.map +1 -0
  18. package/lib/commonjs/bridge/injectBridge.js +528 -0
  19. package/lib/commonjs/bridge/injectBridge.js.map +1 -0
  20. package/lib/commonjs/components/FlowWebView.js +676 -0
  21. package/lib/commonjs/components/FlowWebView.js.map +1 -0
  22. package/lib/commonjs/components/MobanaProvider.js +275 -0
  23. package/lib/commonjs/components/MobanaProvider.js.map +1 -0
  24. package/lib/commonjs/components/index.js +20 -0
  25. package/lib/commonjs/components/index.js.map +1 -0
  26. package/lib/commonjs/device.js +49 -0
  27. package/lib/commonjs/device.js.map +1 -0
  28. package/lib/commonjs/index.js +20 -0
  29. package/lib/commonjs/index.js.map +1 -0
  30. package/lib/commonjs/package.json +1 -0
  31. package/lib/commonjs/storage.js +277 -0
  32. package/lib/commonjs/storage.js.map +1 -0
  33. package/lib/commonjs/types.js +2 -0
  34. package/lib/commonjs/types.js.map +1 -0
  35. package/lib/module/Mobana.js +673 -0
  36. package/lib/module/Mobana.js.map +1 -0
  37. package/lib/module/NativeMobana.js +49 -0
  38. package/lib/module/NativeMobana.js.map +1 -0
  39. package/lib/module/api.js +194 -0
  40. package/lib/module/api.js.map +1 -0
  41. package/lib/module/bridge/index.js +4 -0
  42. package/lib/module/bridge/index.js.map +1 -0
  43. package/lib/module/bridge/injectBridge.js +523 -0
  44. package/lib/module/bridge/injectBridge.js.map +1 -0
  45. package/lib/module/components/FlowWebView.js +672 -0
  46. package/lib/module/components/FlowWebView.js.map +1 -0
  47. package/lib/module/components/MobanaProvider.js +270 -0
  48. package/lib/module/components/MobanaProvider.js.map +1 -0
  49. package/lib/module/components/index.js +5 -0
  50. package/lib/module/components/index.js.map +1 -0
  51. package/lib/module/device.js +45 -0
  52. package/lib/module/device.js.map +1 -0
  53. package/lib/module/index.js +53 -0
  54. package/lib/module/index.js.map +1 -0
  55. package/lib/module/storage.js +257 -0
  56. package/lib/module/storage.js.map +1 -0
  57. package/lib/module/types.js +2 -0
  58. package/lib/module/types.js.map +1 -0
  59. package/lib/typescript/Mobana.d.ts +209 -0
  60. package/lib/typescript/Mobana.d.ts.map +1 -0
  61. package/lib/typescript/NativeMobana.d.ts +11 -0
  62. package/lib/typescript/NativeMobana.d.ts.map +1 -0
  63. package/lib/typescript/api.d.ts +34 -0
  64. package/lib/typescript/api.d.ts.map +1 -0
  65. package/lib/typescript/bridge/index.d.ts +3 -0
  66. package/lib/typescript/bridge/index.d.ts.map +1 -0
  67. package/lib/typescript/bridge/injectBridge.d.ts +23 -0
  68. package/lib/typescript/bridge/injectBridge.d.ts.map +1 -0
  69. package/lib/typescript/components/FlowWebView.d.ts +38 -0
  70. package/lib/typescript/components/FlowWebView.d.ts.map +1 -0
  71. package/lib/typescript/components/MobanaProvider.d.ts +65 -0
  72. package/lib/typescript/components/MobanaProvider.d.ts.map +1 -0
  73. package/lib/typescript/components/index.d.ts +5 -0
  74. package/lib/typescript/components/index.d.ts.map +1 -0
  75. package/lib/typescript/device.d.ts +6 -0
  76. package/lib/typescript/device.d.ts.map +1 -0
  77. package/lib/typescript/index.d.ts +46 -0
  78. package/lib/typescript/index.d.ts.map +1 -0
  79. package/lib/typescript/storage.d.ts +68 -0
  80. package/lib/typescript/storage.d.ts.map +1 -0
  81. package/lib/typescript/types.d.ts +298 -0
  82. package/lib/typescript/types.d.ts.map +1 -0
  83. package/mobana.podspec +19 -0
  84. package/package.json +131 -0
  85. package/src/Mobana.ts +742 -0
  86. package/src/NativeMobana.ts +61 -0
  87. package/src/api.ts +259 -0
  88. package/src/bridge/index.ts +2 -0
  89. package/src/bridge/injectBridge.ts +542 -0
  90. package/src/components/FlowWebView.tsx +826 -0
  91. package/src/components/MobanaProvider.tsx +393 -0
  92. package/src/components/index.ts +4 -0
  93. package/src/device.ts +42 -0
  94. package/src/index.ts +66 -0
  95. package/src/storage.ts +262 -0
  96. package/src/types.ts +362 -0
package/src/Mobana.ts ADDED
@@ -0,0 +1,742 @@
1
+ import type {
2
+ MobanaConfig,
3
+ GetAttributionOptions,
4
+ Attribution,
5
+ ConversionEvent,
6
+ FlowResult,
7
+ FlowOptions,
8
+ FlowConfig,
9
+ } from './types';
10
+ import {
11
+ getInstallId,
12
+ getCachedResult,
13
+ setCachedResult,
14
+ clearAttribution,
15
+ queueConversion,
16
+ getConversionQueue,
17
+ clearConversionQueue,
18
+ getCachedFlow,
19
+ setCachedFlow,
20
+ clearAllCachedFlows,
21
+ clearLocalData,
22
+ } from './storage';
23
+ import { findAttribution, trackConversionApi, fetchFlow } from './api';
24
+ import { getDeviceInfo } from './device';
25
+ import { getInstallReferrer } from './NativeMobana';
26
+ import { getGlobalFlowContext } from './components/MobanaProvider';
27
+
28
+ const DEFAULT_ENDPOINT = 'https://{appId}.mobana.ai';
29
+ const DEFAULT_TIMEOUT = 10000;
30
+
31
+ /**
32
+ * Mobana SDK for React Native
33
+ *
34
+ * Simple, privacy-focused mobile app attribution, conversions, and remote flows.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * import { Mobana, MobanaProvider } from '@mobana/react-native-sdk';
39
+ *
40
+ * // Wrap your app with the provider (in App.tsx)
41
+ * function App() {
42
+ * return (
43
+ * <MobanaProvider>
44
+ * <YourApp />
45
+ * </MobanaProvider>
46
+ * );
47
+ * }
48
+ *
49
+ * // Initialize once on app start
50
+ * await Mobana.init({ appId: 'a1b2c3d4' });
51
+ *
52
+ * // Get attribution (handles caching, retries, Android Install Referrer)
53
+ * const attribution = await Mobana.getAttribution();
54
+ *
55
+ * // Track conversions
56
+ * Mobana.trackConversion('signup');
57
+ * Mobana.trackConversion('purchase', 49.99);
58
+ *
59
+ * // Show a flow
60
+ * const result = await Mobana.startFlow('onboarding');
61
+ * if (result.completed) {
62
+ * console.log('User completed onboarding!', result.data);
63
+ * }
64
+ * ```
65
+ */
66
+ class MobanaSDK {
67
+ private config: MobanaConfig | null = null;
68
+ private isConfigured = false;
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ private attributionPromise: Promise<Attribution<any> | null> | null = null;
71
+ // In-memory cache for attribution (faster than AsyncStorage)
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ private cachedAttribution: Attribution<any> | null = null;
74
+ private attributionChecked = false;
75
+
76
+ /**
77
+ * Initialize the SDK with your app settings
78
+ * Must be called before any other SDK methods
79
+ *
80
+ * @param config - Configuration options (appId is required)
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * // Basic initialization
85
+ * await Mobana.init({ appId: 'a1b2c3d4' });
86
+ *
87
+ * // With custom endpoint (for domain proxying)
88
+ * await Mobana.init({
89
+ * appId: 'a1b2c3d4',
90
+ * endpoint: 'https://myapp.com/d',
91
+ * });
92
+ *
93
+ * // With all options
94
+ * await Mobana.init({
95
+ * appId: 'a1b2c3d4',
96
+ * endpoint: 'https://myapp.com/d', // Optional
97
+ * enabled: userHasConsented, // Optional, default: true
98
+ * debug: __DEV__, // Optional, default: false
99
+ * });
100
+ * ```
101
+ */
102
+ async init(config: MobanaConfig): Promise<void> {
103
+ if (!config.appId) {
104
+ console.warn('[Mobana] appId is required');
105
+ return;
106
+ }
107
+ if (!config.appKey) {
108
+ console.warn('[Mobana] appKey is required');
109
+ return;
110
+ }
111
+
112
+ this.config = {
113
+ enabled: true,
114
+ debug: false,
115
+ ...config,
116
+ };
117
+ this.isConfigured = true;
118
+
119
+ // Eagerly generate/retrieve the install ID so it's ready before
120
+ // the first attribution or conversion call.
121
+ const installId = await getInstallId();
122
+
123
+ if (this.config.debug) {
124
+ console.log('[Mobana] Initialized:', {
125
+ appId: this.config.appId,
126
+ endpoint: this.config.endpoint,
127
+ enabled: this.config.enabled,
128
+ installId,
129
+ });
130
+ }
131
+
132
+ // Flush any queued conversions when SDK is initialized
133
+ await this.flushConversionQueue();
134
+ }
135
+
136
+ /**
137
+ * Enable or disable the SDK dynamically
138
+ * Useful for GDPR consent flows
139
+ *
140
+ * @param enabled - Whether the SDK should be enabled
141
+ */
142
+ setEnabled(enabled: boolean): void {
143
+ if (!this.config) {
144
+ console.warn('[Mobana] SDK not configured. Call init() first.');
145
+ return;
146
+ }
147
+
148
+ this.config.enabled = enabled;
149
+
150
+ if (this.config.debug) {
151
+ console.log(`[Mobana] ${enabled ? 'Enabled' : 'Disabled'}`);
152
+ }
153
+
154
+ if (enabled) {
155
+ this.flushConversionQueue();
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Get attribution data for this install
161
+ *
162
+ * Returns cached result if available, otherwise fetches from server.
163
+ * Never throws - returns null on error or no match.
164
+ *
165
+ * @param options - Optional settings for the attribution request
166
+ * @returns Attribution data or null if not available
167
+ *
168
+ * @example
169
+ * ```typescript
170
+ * const attribution = await Mobana.getAttribution();
171
+ *
172
+ * if (attribution) {
173
+ * YourAnalyticsProvider.track('App Installed', {
174
+ * source: attribution.utm_source,
175
+ * campaign: attribution.utm_campaign,
176
+ * });
177
+ *
178
+ * if (attribution.data?.promo) {
179
+ * applyPromoCode(attribution.data.promo);
180
+ * }
181
+ * }
182
+ * ```
183
+ *
184
+ * @example
185
+ * // With TypeScript generics for typed data
186
+ * interface MyDeeplinkData {
187
+ * promo?: string;
188
+ * referrer?: string;
189
+ * }
190
+ *
191
+ * const attribution = await Mobana.getAttribution<MyDeeplinkData>();
192
+ * // attribution.data is now typed as MyDeeplinkData
193
+ */
194
+ async getAttribution<T = Record<string, unknown>>(
195
+ options: GetAttributionOptions = {}
196
+ ): Promise<Attribution<T> | null> {
197
+ if (!this.isConfigured || !this.config) {
198
+ console.warn('[Mobana] SDK not configured. Call init() first.');
199
+ return null;
200
+ }
201
+
202
+ if (!this.config.enabled) {
203
+ if (this.config.debug) {
204
+ console.log('[Mobana] SDK disabled, returning null');
205
+ }
206
+ return null;
207
+ }
208
+
209
+ // Return in-memory cache if available (fastest)
210
+ if (this.attributionChecked) {
211
+ return this.cachedAttribution as Attribution<T> | null;
212
+ }
213
+
214
+ // Check AsyncStorage cache
215
+ const cached = await getCachedResult<T>();
216
+ if (cached) {
217
+ if (this.config.debug) {
218
+ console.log('[Mobana] Returning cached result, matched:', cached.matched);
219
+ }
220
+ // Update in-memory cache
221
+ this.attributionChecked = true;
222
+ this.cachedAttribution = cached.matched ? (cached.attribution ?? null) : null;
223
+ return this.cachedAttribution as Attribution<T> | null;
224
+ }
225
+
226
+ // Prevent duplicate concurrent requests
227
+ if (this.attributionPromise) {
228
+ return this.attributionPromise as Promise<Attribution<T> | null>;
229
+ }
230
+
231
+ this.attributionPromise = this.fetchAttribution<T>(options);
232
+ const result = await this.attributionPromise;
233
+ this.attributionPromise = null;
234
+
235
+ // Update in-memory cache
236
+ this.attributionChecked = true;
237
+ this.cachedAttribution = result;
238
+
239
+ return result;
240
+ }
241
+
242
+ /**
243
+ * Track a conversion event
244
+ *
245
+ * Conversions are linked to the original attribution via installId.
246
+ * If offline, conversions are queued and sent when back online.
247
+ * Never throws - silently handles errors.
248
+ *
249
+ * @param name - Conversion name (must be configured in app settings)
250
+ * @param value - Optional monetary value
251
+ * @param flowSessionId - Optional flow session ID to link conversion to a specific flow presentation
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * // Simple conversion
256
+ * Mobana.trackConversion('signup');
257
+ *
258
+ * // Conversion with value
259
+ * Mobana.trackConversion('purchase', 49.99);
260
+ *
261
+ * // Conversion linked to a flow session
262
+ * const result = await Mobana.startFlow('pre-purchase');
263
+ * // ... user makes purchase via paywall ...
264
+ * await Mobana.trackConversion('purchase', 49.99, result.sessionId);
265
+ * ```
266
+ */
267
+ async trackConversion(name: string, value?: number, flowSessionId?: string): Promise<void> {
268
+ if (!this.isConfigured || !this.config) {
269
+ if (this.config?.debug) {
270
+ console.log('[Mobana] SDK not configured, skipping conversion');
271
+ }
272
+ return;
273
+ }
274
+
275
+ if (!this.config.enabled) {
276
+ if (this.config.debug) {
277
+ console.log('[Mobana] SDK disabled, skipping conversion');
278
+ }
279
+ return;
280
+ }
281
+
282
+ const installId = await getInstallId();
283
+
284
+ const event: ConversionEvent = {
285
+ installId,
286
+ name,
287
+ value,
288
+ timestamp: Date.now(),
289
+ flowSessionId,
290
+ };
291
+
292
+ // Ensure Install record exists on server (created on first getAttribution call).
293
+ // This is fast after first call — returns from in-memory cache without network.
294
+ // We don't care about the result; conversions work for organic installs too.
295
+ await this.getAttribution();
296
+
297
+ // Try to send immediately
298
+ const success = await this.sendConversion(event);
299
+
300
+ if (!success) {
301
+ // Queue for later if failed (offline, etc.)
302
+ await queueConversion(event);
303
+
304
+ if (this.config.debug) {
305
+ console.log('[Mobana] Conversion queued for later');
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Reset all stored attribution data
312
+ * Useful for testing or when user logs out
313
+ *
314
+ * Note: This generates a new installId, so subsequent attributions
315
+ * will be treated as a new install.
316
+ */
317
+ async reset(): Promise<void> {
318
+ // Clear in-memory cache
319
+ this.cachedAttribution = null;
320
+ this.attributionChecked = false;
321
+ this.attributionPromise = null;
322
+
323
+ // Clear persistent storage
324
+ await clearAttribution();
325
+ await clearConversionQueue();
326
+ await clearAllCachedFlows();
327
+ await clearLocalData();
328
+
329
+ if (this.config?.debug) {
330
+ console.log('[Mobana] Reset complete');
331
+ }
332
+ }
333
+
334
+ // ============================================
335
+ // Flows
336
+ // ============================================
337
+
338
+ /**
339
+ * Start and display a flow
340
+ *
341
+ * Fetches the flow content (or uses cache) and presents it in a full-screen modal.
342
+ * The promise resolves when the user completes or dismisses the flow.
343
+ *
344
+ * Requires MobanaProvider to be mounted in your app.
345
+ *
346
+ * @param slug - Flow identifier (from dashboard)
347
+ * @param options - Optional flow configuration
348
+ * @returns Flow result with completion status and optional data
349
+ *
350
+ * @example
351
+ * ```typescript
352
+ * // Basic usage
353
+ * const result = await Mobana.startFlow('onboarding');
354
+ *
355
+ * if (result.completed) {
356
+ * console.log('Onboarding completed!', result.data);
357
+ * } else if (result.error) {
358
+ * console.log('Flow error:', result.error);
359
+ * }
360
+ *
361
+ * // With custom parameters
362
+ * const result = await Mobana.startFlow('welcome', {
363
+ * params: { userName: 'John', isPremium: true },
364
+ * onEvent: (name) => {
365
+ * analytics.track(name);
366
+ * },
367
+ * });
368
+ * ```
369
+ */
370
+ async startFlow(slug: string, options?: FlowOptions): Promise<FlowResult> {
371
+ // Check if SDK is configured
372
+ if (!this.isConfigured || !this.config) {
373
+ console.warn('[Mobana] SDK not configured. Call init() first.');
374
+ return { completed: false, dismissed: true, error: 'SDK_NOT_CONFIGURED' };
375
+ }
376
+
377
+ // Check if SDK is enabled
378
+ if (!this.config.enabled) {
379
+ if (this.config.debug) {
380
+ console.log('[Mobana] SDK disabled, cannot start flow');
381
+ }
382
+ return { completed: false, dismissed: true, error: 'SDK_NOT_CONFIGURED' };
383
+ }
384
+
385
+ // Check if provider is mounted
386
+ const flowContext = getGlobalFlowContext();
387
+ if (!flowContext?.isProviderMounted) {
388
+ console.warn(
389
+ '[Mobana] startFlow() called but MobanaProvider is not mounted. ' +
390
+ 'Wrap your app with <MobanaProvider> to enable flows.'
391
+ );
392
+ return { completed: false, dismissed: true, error: 'PROVIDER_NOT_MOUNTED' };
393
+ }
394
+
395
+ try {
396
+ const endpoint = this.getEndpoint();
397
+ const installId = await getInstallId();
398
+
399
+ // Ensure attribution is loaded (for passing to flow context).
400
+ // Fast after first call — returns from in-memory cache without network.
401
+ // We don't fail if attribution isn't matched; flows work for organic installs too.
402
+ await this.getAttribution();
403
+
404
+ // Check cache for this flow
405
+ const cached = await getCachedFlow(slug);
406
+
407
+ if (this.config.debug) {
408
+ console.log(`[Mobana] Starting flow: ${slug}`, {
409
+ hasCached: !!cached,
410
+ cachedVersionId: cached?.versionId,
411
+ });
412
+ }
413
+
414
+ // Fetch flow from server (with cache validation)
415
+ const response = await fetchFlow(
416
+ endpoint,
417
+ this.config.appKey,
418
+ slug,
419
+ installId,
420
+ cached?.versionId,
421
+ DEFAULT_TIMEOUT,
422
+ this.config.debug
423
+ );
424
+
425
+ // Handle network error
426
+ if (!response) {
427
+ // If we have a cached version, use it
428
+ if (cached) {
429
+ if (this.config.debug) {
430
+ console.log('[Mobana] Network error, using cached flow');
431
+ }
432
+ return this.presentFlowToUser(flowContext, {
433
+ slug,
434
+ config: cached,
435
+ installId,
436
+ endpoint,
437
+ appKey: this.config.appKey,
438
+ options,
439
+ });
440
+ }
441
+ return { completed: false, dismissed: true, error: 'NETWORK_ERROR' };
442
+ }
443
+
444
+ // Handle server errors
445
+ if (response.error) {
446
+ if (this.config.debug) {
447
+ console.log(`[Mobana] Flow error: ${response.error}`);
448
+ }
449
+ return { completed: false, dismissed: true, error: response.error as FlowResult['error'] };
450
+ }
451
+
452
+ // Determine flow content to use
453
+ let flowConfig: FlowConfig;
454
+
455
+ if (response.cached && cached) {
456
+ // Server confirmed our cached version is current
457
+ flowConfig = cached;
458
+ if (this.config.debug) {
459
+ console.log('[Mobana] Using cached flow (validated)');
460
+ }
461
+ } else if (response.versionId && response.html) {
462
+ // New content from server
463
+ flowConfig = {
464
+ versionId: response.versionId,
465
+ html: response.html,
466
+ css: response.css,
467
+ js: response.js,
468
+ };
469
+ // Cache for next time
470
+ await setCachedFlow(slug, flowConfig);
471
+ if (this.config.debug) {
472
+ console.log('[Mobana] Using fresh flow, cached for next time');
473
+ }
474
+ } else {
475
+ // Unexpected response
476
+ if (this.config.debug) {
477
+ console.log('[Mobana] Unexpected flow response');
478
+ }
479
+ return { completed: false, dismissed: true, error: 'SERVER_ERROR' };
480
+ }
481
+
482
+ // Present the flow
483
+ return this.presentFlowToUser(flowContext, {
484
+ slug,
485
+ config: flowConfig,
486
+ installId,
487
+ endpoint,
488
+ appKey: this.config.appKey,
489
+ options,
490
+ });
491
+ } catch (error) {
492
+ if (this.config.debug) {
493
+ console.log('[Mobana] Error starting flow:', error);
494
+ }
495
+ return { completed: false, dismissed: true, error: 'SERVER_ERROR' };
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Prefetch a flow for faster display later
501
+ *
502
+ * Downloads and caches the flow content without displaying it.
503
+ * Call this ahead of time if you know a flow will be shown soon.
504
+ *
505
+ * @param slug - Flow identifier (from dashboard)
506
+ *
507
+ * @example
508
+ * ```typescript
509
+ * // Prefetch during app startup
510
+ * Mobana.prefetchFlow('onboarding');
511
+ *
512
+ * // Later, when ready to show (will be instant if prefetched)
513
+ * const result = await Mobana.startFlow('onboarding');
514
+ * ```
515
+ */
516
+ async prefetchFlow(slug: string): Promise<void> {
517
+ if (!this.isConfigured || !this.config) {
518
+ return;
519
+ }
520
+
521
+ if (!this.config.enabled) {
522
+ return;
523
+ }
524
+
525
+ try {
526
+ const endpoint = this.getEndpoint();
527
+ const installId = await getInstallId();
528
+ const cached = await getCachedFlow(slug);
529
+
530
+ if (this.config.debug) {
531
+ console.log(`[Mobana] Prefetching flow: ${slug}`);
532
+ }
533
+
534
+ const response = await fetchFlow(
535
+ endpoint,
536
+ this.config.appKey,
537
+ slug,
538
+ installId,
539
+ cached?.versionId,
540
+ DEFAULT_TIMEOUT,
541
+ this.config.debug
542
+ );
543
+
544
+ if (response && !response.error && !response.cached && response.versionId && response.html) {
545
+ // Cache the new content
546
+ await setCachedFlow(slug, {
547
+ versionId: response.versionId,
548
+ html: response.html,
549
+ css: response.css,
550
+ js: response.js,
551
+ });
552
+ if (this.config.debug) {
553
+ console.log(`[Mobana] Flow "${slug}" prefetched and cached`);
554
+ }
555
+ } else if (response?.cached) {
556
+ if (this.config.debug) {
557
+ console.log(`[Mobana] Flow "${slug}" already cached and current`);
558
+ }
559
+ }
560
+ } catch (error) {
561
+ if (this.config.debug) {
562
+ console.log('[Mobana] Error prefetching flow:', error);
563
+ }
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Present a flow to the user via the provider
569
+ */
570
+ private presentFlowToUser(
571
+ flowContext: NonNullable<ReturnType<typeof getGlobalFlowContext>>,
572
+ params: {
573
+ slug: string;
574
+ config: FlowConfig;
575
+ installId: string;
576
+ endpoint: string;
577
+ appKey: string;
578
+ options?: FlowOptions;
579
+ }
580
+ ): Promise<FlowResult> {
581
+ return new Promise((resolve) => {
582
+ flowContext.presentFlow({
583
+ slug: params.slug,
584
+ config: params.config,
585
+ installId: params.installId,
586
+ endpoint: params.endpoint,
587
+ appKey: params.appKey,
588
+ attribution: this.cachedAttribution,
589
+ options: params.options,
590
+ resolve,
591
+ debug: this.config?.debug,
592
+ });
593
+ });
594
+ }
595
+
596
+ // ============================================
597
+ // Private methods
598
+ // ============================================
599
+
600
+ private getEndpoint(): string {
601
+ if (this.config?.endpoint) {
602
+ // Remove trailing slash
603
+ return this.config.endpoint.replace(/\/$/, '');
604
+ }
605
+
606
+ if (this.config?.appId) {
607
+ return DEFAULT_ENDPOINT.replace('{appId}', this.config.appId);
608
+ }
609
+
610
+ throw new Error('No endpoint configured');
611
+ }
612
+
613
+ private async fetchAttribution<T = Record<string, unknown>>(
614
+ options: GetAttributionOptions
615
+ ): Promise<Attribution<T> | null> {
616
+ const { timeout = DEFAULT_TIMEOUT } = options;
617
+
618
+ try {
619
+ const endpoint = this.getEndpoint();
620
+ const installId = await getInstallId();
621
+ const deviceInfo = getDeviceInfo();
622
+
623
+ if (this.config?.debug) {
624
+ console.log('[Mobana] Fetching attribution...');
625
+ console.log('[Mobana] Device info:', deviceInfo);
626
+ }
627
+
628
+ // Get Android Install Referrer for deterministic attribution
629
+ let dacid: string | null = null;
630
+ if (deviceInfo.platform === 'android') {
631
+ dacid = await getInstallReferrer();
632
+
633
+ if (this.config?.debug) {
634
+ console.log('[Mobana] Install Referrer dacid:', dacid || '(not available)');
635
+ }
636
+ }
637
+
638
+ // Make API request
639
+ const response = await findAttribution<T>(
640
+ endpoint,
641
+ this.config!.appKey,
642
+ installId,
643
+ deviceInfo,
644
+ dacid,
645
+ timeout,
646
+ this.config?.debug ?? false
647
+ );
648
+
649
+ // If no response (network error, timeout), don't cache - allow retry
650
+ if (!response) {
651
+ if (this.config?.debug) {
652
+ console.log('[Mobana] No response from server');
653
+ }
654
+ return null;
655
+ }
656
+
657
+ // Cache the response if server returned a valid response with matched key
658
+ // This prevents retrying on every startup
659
+ if (typeof response.matched === 'boolean') {
660
+ if (response.matched && response.attribution) {
661
+ // Build attribution object
662
+ const attribution: Attribution<T> = {
663
+ ...response.attribution,
664
+ confidence: response.confidence ?? 0,
665
+ };
666
+
667
+ // Cache matched result
668
+ await setCachedResult(true, attribution);
669
+
670
+ if (this.config?.debug) {
671
+ console.log('[Mobana] Attribution matched:', attribution);
672
+ }
673
+
674
+ return attribution;
675
+ } else {
676
+ // Cache unmatched result - prevents retry on next startup
677
+ await setCachedResult<T>(false);
678
+
679
+ if (this.config?.debug) {
680
+ console.log('[Mobana] No match found (cached)');
681
+ }
682
+
683
+ return null;
684
+ }
685
+ }
686
+
687
+ // Unexpected response format
688
+ if (this.config?.debug) {
689
+ console.log('[Mobana] Unexpected response format');
690
+ }
691
+ return null;
692
+ } catch (error) {
693
+ if (this.config?.debug) {
694
+ console.log('[Mobana] Error fetching attribution:', error);
695
+ }
696
+ return null;
697
+ }
698
+ }
699
+
700
+ private async sendConversion(event: ConversionEvent): Promise<boolean> {
701
+ try {
702
+ const endpoint = this.getEndpoint();
703
+ return await trackConversionApi(endpoint, this.config!.appKey, event, this.config?.debug ?? false);
704
+ } catch {
705
+ return false;
706
+ }
707
+ }
708
+
709
+ private async flushConversionQueue(): Promise<void> {
710
+ if (!this.config?.enabled) {
711
+ return;
712
+ }
713
+
714
+ const queue = await getConversionQueue();
715
+
716
+ if (queue.length === 0) {
717
+ return;
718
+ }
719
+
720
+ if (this.config?.debug) {
721
+ console.log(`[Mobana] Flushing ${queue.length} queued conversions`);
722
+ }
723
+
724
+ // Send all queued conversions
725
+ const results = await Promise.all(
726
+ queue.map((event) => this.sendConversion(event))
727
+ );
728
+
729
+ // Clear the queue, then re-queue only the failures (avoids duplicate sends)
730
+ await clearConversionQueue();
731
+ const failed = queue.filter((_, i) => !results[i]);
732
+ for (const event of failed) {
733
+ await queueConversion(event);
734
+ }
735
+ }
736
+ }
737
+
738
+ // Export class for testing (create fresh instances without shared state)
739
+ export { MobanaSDK };
740
+
741
+ // Export singleton instance
742
+ export const Mobana = new MobanaSDK();