@datalyr/react-native 1.2.0 → 1.3.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 (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +30 -1
  3. package/android/build.gradle +54 -0
  4. package/android/src/main/AndroidManifest.xml +14 -0
  5. package/android/src/main/java/com/datalyr/reactnative/DatalyrNativeModule.java +423 -0
  6. package/android/src/main/java/com/datalyr/reactnative/DatalyrPackage.java +30 -0
  7. package/android/src/main/java/com/datalyr/reactnative/DatalyrPlayInstallReferrerModule.java +229 -0
  8. package/datalyr-react-native.podspec +2 -2
  9. package/ios/DatalyrNative.m +4 -0
  10. package/ios/DatalyrNative.swift +58 -1
  11. package/ios/DatalyrSKAdNetwork.m +52 -1
  12. package/lib/ConversionValueEncoder.d.ts +13 -1
  13. package/lib/ConversionValueEncoder.js +57 -23
  14. package/lib/datalyr-sdk.d.ts +34 -2
  15. package/lib/datalyr-sdk.js +90 -8
  16. package/lib/index.d.ts +4 -1
  17. package/lib/index.js +2 -1
  18. package/lib/integrations/apple-search-ads-integration.d.ts +43 -0
  19. package/lib/integrations/apple-search-ads-integration.js +106 -0
  20. package/lib/integrations/index.d.ts +4 -1
  21. package/lib/integrations/index.js +3 -1
  22. package/lib/integrations/meta-integration.d.ts +1 -0
  23. package/lib/integrations/meta-integration.js +4 -3
  24. package/lib/integrations/play-install-referrer.d.ts +74 -0
  25. package/lib/integrations/play-install-referrer.js +156 -0
  26. package/lib/integrations/tiktok-integration.d.ts +1 -0
  27. package/lib/integrations/tiktok-integration.js +4 -3
  28. package/lib/journey.d.ts +106 -0
  29. package/lib/journey.js +258 -0
  30. package/lib/native/DatalyrNativeBridge.d.ts +67 -2
  31. package/lib/native/DatalyrNativeBridge.js +80 -7
  32. package/lib/native/SKAdNetworkBridge.d.ts +21 -0
  33. package/lib/native/SKAdNetworkBridge.js +54 -0
  34. package/package.json +9 -3
  35. package/src/ConversionValueEncoder.ts +67 -26
  36. package/src/datalyr-sdk-expo.ts +98 -9
  37. package/src/datalyr-sdk.ts +109 -14
  38. package/src/expo.ts +8 -0
  39. package/src/index.ts +6 -1
  40. package/src/integrations/apple-search-ads-integration.ts +119 -0
  41. package/src/integrations/index.ts +4 -1
  42. package/src/integrations/meta-integration.ts +4 -3
  43. package/src/integrations/play-install-referrer.ts +203 -0
  44. package/src/integrations/tiktok-integration.ts +4 -3
  45. package/src/journey.ts +338 -0
  46. package/src/native/DatalyrNativeBridge.ts +137 -9
  47. package/src/native/SKAdNetworkBridge.ts +86 -2
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Apple Search Ads Attribution Integration
3
+ * Uses AdServices framework (iOS 14.3+) to capture attribution from App Store search ads
4
+ */
5
+
6
+ import { Platform } from 'react-native';
7
+ import { AppleSearchAdsNativeBridge, AppleSearchAdsAttribution, isNativeModuleAvailable } from '../native/DatalyrNativeBridge';
8
+
9
+ /**
10
+ * Apple Search Ads Integration class
11
+ * Fetches attribution data for users who installed via Apple Search Ads
12
+ */
13
+ export class AppleSearchAdsIntegration {
14
+ private attributionData: AppleSearchAdsAttribution | null = null;
15
+ private fetched: boolean = false;
16
+ private available: boolean = false;
17
+ private debug: boolean = false;
18
+
19
+ /**
20
+ * Initialize and fetch Apple Search Ads attribution
21
+ */
22
+ async initialize(debug: boolean = false): Promise<void> {
23
+ this.debug = debug;
24
+
25
+ // Only available on iOS via native module
26
+ if (Platform.OS !== 'ios') {
27
+ this.log('Apple Search Ads only available on iOS');
28
+ return;
29
+ }
30
+
31
+ this.available = isNativeModuleAvailable();
32
+
33
+ if (!this.available) {
34
+ this.log('Apple Search Ads native module not available');
35
+ return;
36
+ }
37
+
38
+ // Automatically fetch attribution on init
39
+ await this.fetchAttribution();
40
+ }
41
+
42
+ /**
43
+ * Fetch attribution data from Apple's AdServices API
44
+ * Call this during app initialization
45
+ */
46
+ async fetchAttribution(): Promise<AppleSearchAdsAttribution | null> {
47
+ if (!this.available) {
48
+ return null;
49
+ }
50
+
51
+ // Only fetch once
52
+ if (this.fetched) {
53
+ return this.attributionData;
54
+ }
55
+
56
+ try {
57
+ this.attributionData = await AppleSearchAdsNativeBridge.getAttribution();
58
+ this.fetched = true;
59
+
60
+ if (this.attributionData?.attribution) {
61
+ this.log('Apple Search Ads attribution found:', {
62
+ campaignId: this.attributionData.campaignId,
63
+ campaignName: this.attributionData.campaignName,
64
+ adGroupId: this.attributionData.adGroupId,
65
+ keyword: this.attributionData.keyword,
66
+ });
67
+ } else {
68
+ this.log('No Apple Search Ads attribution (user did not come from search ad)');
69
+ }
70
+
71
+ return this.attributionData;
72
+ } catch (error) {
73
+ this.logError('Failed to fetch Apple Search Ads attribution:', error);
74
+ this.fetched = true;
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get cached attribution data
81
+ */
82
+ getAttributionData(): AppleSearchAdsAttribution | null {
83
+ return this.attributionData;
84
+ }
85
+
86
+ /**
87
+ * Check if user came from Apple Search Ads
88
+ */
89
+ hasAttribution(): boolean {
90
+ return this.attributionData?.attribution === true;
91
+ }
92
+
93
+ /**
94
+ * Check if Apple Search Ads is available (iOS 14.3+)
95
+ */
96
+ isAvailable(): boolean {
97
+ return this.available;
98
+ }
99
+
100
+ /**
101
+ * Check if attribution has been fetched
102
+ */
103
+ hasFetched(): boolean {
104
+ return this.fetched;
105
+ }
106
+
107
+ private log(message: string, data?: any): void {
108
+ if (this.debug) {
109
+ console.log(`[Datalyr/AppleSearchAds] ${message}`, data || '');
110
+ }
111
+ }
112
+
113
+ private logError(message: string, error: any): void {
114
+ console.error(`[Datalyr/AppleSearchAds] ${message}`, error);
115
+ }
116
+ }
117
+
118
+ // Export singleton instance
119
+ export const appleSearchAdsIntegration = new AppleSearchAdsIntegration();
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Platform SDK Integrations
3
- * Meta (Facebook) and TikTok SDK wrappers for deferred deep linking and event forwarding
3
+ * Meta (Facebook), TikTok, Apple Search Ads, and Google Play Install Referrer SDK wrappers
4
4
  */
5
5
 
6
6
  export { MetaIntegration, metaIntegration } from './meta-integration';
7
7
  export { TikTokIntegration, tiktokIntegration } from './tiktok-integration';
8
+ export { AppleSearchAdsIntegration, appleSearchAdsIntegration } from './apple-search-ads-integration';
9
+ export { playInstallReferrerIntegration } from './play-install-referrer';
10
+ export type { PlayInstallReferrer } from './play-install-referrer';
@@ -20,14 +20,15 @@ export class MetaIntegration {
20
20
 
21
21
  /**
22
22
  * Initialize Meta SDK with configuration
23
+ * Supported on both iOS and Android via native modules
23
24
  */
24
25
  async initialize(config: MetaConfig, debug: boolean = false): Promise<void> {
25
26
  this.debug = debug;
26
27
  this.config = config;
27
28
 
28
- // Only available on iOS via native module
29
- if (Platform.OS !== 'ios') {
30
- this.log('Meta SDK only available on iOS');
29
+ // Only available on iOS and Android via native modules
30
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
31
+ this.log('Meta SDK only available on iOS and Android');
31
32
  return;
32
33
  }
33
34
 
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Google Play Install Referrer Integration
3
+ *
4
+ * Provides Android install attribution via the Google Play Install Referrer API.
5
+ * This captures UTM parameters and click IDs passed through Google Play Store.
6
+ *
7
+ * How it works:
8
+ * 1. User clicks ad/link with UTM parameters
9
+ * 2. Google Play Store stores the referrer URL
10
+ * 3. On first app launch, SDK retrieves the referrer
11
+ * 4. Attribution data (utm_source, utm_medium, gclid, etc.) is extracted
12
+ *
13
+ * Requirements:
14
+ * - Android only (returns null on iOS)
15
+ * - Requires Google Play Install Referrer Library in build.gradle:
16
+ * implementation 'com.android.installreferrer:installreferrer:2.2'
17
+ *
18
+ * Attribution data captured:
19
+ * - referrer_url: Full referrer URL from Play Store
20
+ * - referrer_click_timestamp: When the referrer link was clicked
21
+ * - install_begin_timestamp: When the install began
22
+ * - gclid: Google Ads click ID (if present)
23
+ * - utm_source, utm_medium, utm_campaign, etc.
24
+ */
25
+
26
+ import { Platform, NativeModules } from 'react-native';
27
+ import { debugLog, errorLog } from '../utils';
28
+
29
+ export interface PlayInstallReferrer {
30
+ // Raw referrer URL from Play Store
31
+ referrerUrl: string;
32
+ // Timestamp when the referrer link was clicked (ms)
33
+ referrerClickTimestamp: number;
34
+ // Timestamp when the install began (ms)
35
+ installBeginTimestamp: number;
36
+ // Timestamp when install was completed (ms)
37
+ installCompleteTimestamp?: number;
38
+ // Google Ads click ID
39
+ gclid?: string;
40
+ // UTM Parameters
41
+ utmSource?: string;
42
+ utmMedium?: string;
43
+ utmCampaign?: string;
44
+ utmTerm?: string;
45
+ utmContent?: string;
46
+ // Additional parameters
47
+ [key: string]: any;
48
+ }
49
+
50
+ interface PlayInstallReferrerModule {
51
+ getInstallReferrer(): Promise<PlayInstallReferrer | null>;
52
+ isAvailable(): Promise<boolean>;
53
+ }
54
+
55
+ const { DatalyrPlayInstallReferrer } = NativeModules as {
56
+ DatalyrPlayInstallReferrer?: PlayInstallReferrerModule;
57
+ };
58
+
59
+ /**
60
+ * Google Play Install Referrer Integration
61
+ *
62
+ * Retrieves install attribution data from Google Play Store.
63
+ * Only available on Android.
64
+ */
65
+ class PlayInstallReferrerIntegration {
66
+ private referrerData: PlayInstallReferrer | null = null;
67
+ private initialized = false;
68
+
69
+ /**
70
+ * Check if Play Install Referrer is available
71
+ */
72
+ isAvailable(): boolean {
73
+ return Platform.OS === 'android' && !!DatalyrPlayInstallReferrer;
74
+ }
75
+
76
+ /**
77
+ * Initialize and fetch install referrer data
78
+ * Should be called once on first app launch
79
+ */
80
+ async initialize(): Promise<void> {
81
+ if (this.initialized) return;
82
+
83
+ if (!this.isAvailable()) {
84
+ debugLog('[PlayInstallReferrer] Not available (iOS or native module missing)');
85
+ return;
86
+ }
87
+
88
+ try {
89
+ this.referrerData = await this.fetchInstallReferrer();
90
+ this.initialized = true;
91
+
92
+ if (this.referrerData) {
93
+ debugLog('[PlayInstallReferrer] Install referrer fetched:', {
94
+ utmSource: this.referrerData.utmSource,
95
+ utmMedium: this.referrerData.utmMedium,
96
+ hasGclid: !!this.referrerData.gclid,
97
+ });
98
+ }
99
+ } catch (error) {
100
+ errorLog('[PlayInstallReferrer] Failed to initialize:', error as Error);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Fetch install referrer from Play Store
106
+ */
107
+ async fetchInstallReferrer(): Promise<PlayInstallReferrer | null> {
108
+ if (!this.isAvailable()) {
109
+ return null;
110
+ }
111
+
112
+ try {
113
+ const referrer = await DatalyrPlayInstallReferrer!.getInstallReferrer();
114
+
115
+ if (!referrer) {
116
+ return null;
117
+ }
118
+
119
+ // Parse UTM parameters from referrer URL
120
+ const parsed = this.parseReferrerUrl(referrer.referrerUrl);
121
+
122
+ return {
123
+ ...referrer,
124
+ ...parsed,
125
+ };
126
+ } catch (error) {
127
+ errorLog('[PlayInstallReferrer] Error fetching referrer:', error as Error);
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Parse referrer URL to extract UTM parameters and click IDs
134
+ */
135
+ private parseReferrerUrl(referrerUrl: string): Partial<PlayInstallReferrer> {
136
+ const params: Partial<PlayInstallReferrer> = {};
137
+
138
+ if (!referrerUrl) return params;
139
+
140
+ try {
141
+ // Referrer URL is URL-encoded, decode it first
142
+ const decoded = decodeURIComponent(referrerUrl);
143
+ const searchParams = new URLSearchParams(decoded);
144
+
145
+ // Extract UTM parameters
146
+ params.utmSource = searchParams.get('utm_source') || undefined;
147
+ params.utmMedium = searchParams.get('utm_medium') || undefined;
148
+ params.utmCampaign = searchParams.get('utm_campaign') || undefined;
149
+ params.utmTerm = searchParams.get('utm_term') || undefined;
150
+ params.utmContent = searchParams.get('utm_content') || undefined;
151
+
152
+ // Extract click IDs
153
+ params.gclid = searchParams.get('gclid') || undefined;
154
+
155
+ // Store any additional parameters
156
+ searchParams.forEach((value, key) => {
157
+ if (!key.startsWith('utm_') && key !== 'gclid') {
158
+ params[key] = value;
159
+ }
160
+ });
161
+
162
+ debugLog('[PlayInstallReferrer] Parsed referrer URL:', params);
163
+ } catch (error) {
164
+ errorLog('[PlayInstallReferrer] Error parsing referrer URL:', error as Error);
165
+ }
166
+
167
+ return params;
168
+ }
169
+
170
+ /**
171
+ * Get cached install referrer data
172
+ */
173
+ getReferrerData(): PlayInstallReferrer | null {
174
+ return this.referrerData;
175
+ }
176
+
177
+ /**
178
+ * Get attribution data in standard format
179
+ */
180
+ getAttributionData(): Record<string, any> {
181
+ if (!this.referrerData) return {};
182
+
183
+ return {
184
+ // Install referrer specific
185
+ install_referrer_url: this.referrerData.referrerUrl,
186
+ referrer_click_timestamp: this.referrerData.referrerClickTimestamp,
187
+ install_begin_timestamp: this.referrerData.installBeginTimestamp,
188
+
189
+ // Standard attribution fields
190
+ gclid: this.referrerData.gclid,
191
+ utm_source: this.referrerData.utmSource,
192
+ utm_medium: this.referrerData.utmMedium,
193
+ utm_campaign: this.referrerData.utmCampaign,
194
+ utm_term: this.referrerData.utmTerm,
195
+ utm_content: this.referrerData.utmContent,
196
+
197
+ // Source indicators
198
+ attribution_source: 'play_install_referrer',
199
+ };
200
+ }
201
+ }
202
+
203
+ export const playInstallReferrerIntegration = new PlayInstallReferrerIntegration();
@@ -46,14 +46,15 @@ export class TikTokIntegration {
46
46
 
47
47
  /**
48
48
  * Initialize TikTok SDK with configuration
49
+ * Supported on both iOS and Android via native modules
49
50
  */
50
51
  async initialize(config: TikTokConfig, debug: boolean = false): Promise<void> {
51
52
  this.debug = debug;
52
53
  this.config = config;
53
54
 
54
- // Only available on iOS via native module
55
- if (Platform.OS !== 'ios') {
56
- this.log('TikTok SDK only available on iOS');
55
+ // Only available on iOS and Android via native modules
56
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
57
+ this.log('TikTok SDK only available on iOS and Android');
57
58
  return;
58
59
  }
59
60
 
package/src/journey.ts ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Journey Tracking Module for React Native
3
+ * Mirrors the Web SDK's journey tracking capabilities:
4
+ * - First-touch attribution with 90-day expiration
5
+ * - Last-touch attribution with 90-day expiration
6
+ * - Up to 30 touchpoints stored
7
+ */
8
+
9
+ import { Storage, debugLog, errorLog } from './utils';
10
+
11
+ // Storage keys for journey data
12
+ const JOURNEY_STORAGE_KEYS = {
13
+ FIRST_TOUCH: '@datalyr/first_touch',
14
+ LAST_TOUCH: '@datalyr/last_touch',
15
+ JOURNEY: '@datalyr/journey',
16
+ };
17
+
18
+ // 90-day attribution window (matching web SDK)
19
+ const ATTRIBUTION_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
20
+
21
+ // Maximum touchpoints to store
22
+ const MAX_TOUCHPOINTS = 30;
23
+
24
+ /**
25
+ * Attribution data for a touch
26
+ */
27
+ export interface TouchAttribution {
28
+ timestamp: number;
29
+ expires_at: number;
30
+ captured_at: number;
31
+
32
+ // Source attribution
33
+ source?: string;
34
+ medium?: string;
35
+ campaign?: string;
36
+ term?: string;
37
+ content?: string;
38
+
39
+ // Click IDs
40
+ clickId?: string;
41
+ clickIdType?: string;
42
+ fbclid?: string;
43
+ gclid?: string;
44
+ ttclid?: string;
45
+ gbraid?: string;
46
+ wbraid?: string;
47
+
48
+ // LYR tag
49
+ lyr?: string;
50
+
51
+ // Context
52
+ landingPage?: string;
53
+ referrer?: string;
54
+ }
55
+
56
+ /**
57
+ * A single touchpoint in the customer journey
58
+ */
59
+ export interface TouchPoint {
60
+ timestamp: number;
61
+ sessionId: string;
62
+ source?: string;
63
+ medium?: string;
64
+ campaign?: string;
65
+ clickIdType?: string;
66
+ }
67
+
68
+ /**
69
+ * Journey manager for tracking customer touchpoints
70
+ */
71
+ export class JourneyManager {
72
+ private firstTouch: TouchAttribution | null = null;
73
+ private lastTouch: TouchAttribution | null = null;
74
+ private journey: TouchPoint[] = [];
75
+ private initialized = false;
76
+
77
+ /**
78
+ * Initialize journey tracking by loading persisted data
79
+ */
80
+ async initialize(): Promise<void> {
81
+ if (this.initialized) return;
82
+
83
+ try {
84
+ debugLog('Initializing journey manager...');
85
+
86
+ // Load first touch
87
+ const savedFirstTouch = await Storage.getItem<TouchAttribution>(JOURNEY_STORAGE_KEYS.FIRST_TOUCH);
88
+ if (savedFirstTouch && !this.isExpired(savedFirstTouch)) {
89
+ this.firstTouch = savedFirstTouch;
90
+ } else if (savedFirstTouch) {
91
+ // Expired, clear it
92
+ await Storage.removeItem(JOURNEY_STORAGE_KEYS.FIRST_TOUCH);
93
+ }
94
+
95
+ // Load last touch
96
+ const savedLastTouch = await Storage.getItem<TouchAttribution>(JOURNEY_STORAGE_KEYS.LAST_TOUCH);
97
+ if (savedLastTouch && !this.isExpired(savedLastTouch)) {
98
+ this.lastTouch = savedLastTouch;
99
+ } else if (savedLastTouch) {
100
+ await Storage.removeItem(JOURNEY_STORAGE_KEYS.LAST_TOUCH);
101
+ }
102
+
103
+ // Load journey
104
+ const savedJourney = await Storage.getItem<TouchPoint[]>(JOURNEY_STORAGE_KEYS.JOURNEY);
105
+ if (savedJourney) {
106
+ this.journey = savedJourney;
107
+ }
108
+
109
+ this.initialized = true;
110
+ debugLog('Journey manager initialized', {
111
+ hasFirstTouch: !!this.firstTouch,
112
+ hasLastTouch: !!this.lastTouch,
113
+ touchpointCount: this.journey.length,
114
+ });
115
+ } catch (error) {
116
+ errorLog('Failed to initialize journey manager:', error as Error);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Check if attribution has expired
122
+ */
123
+ private isExpired(attribution: TouchAttribution): boolean {
124
+ return Date.now() >= attribution.expires_at;
125
+ }
126
+
127
+ /**
128
+ * Store first touch attribution (only if not already set or expired)
129
+ */
130
+ async storeFirstTouch(attribution: Partial<TouchAttribution>): Promise<void> {
131
+ try {
132
+ // Only store if no valid first touch exists
133
+ if (this.firstTouch && !this.isExpired(this.firstTouch)) {
134
+ debugLog('First touch already exists, not overwriting');
135
+ return;
136
+ }
137
+
138
+ const now = Date.now();
139
+ this.firstTouch = {
140
+ ...attribution,
141
+ timestamp: attribution.timestamp || now,
142
+ captured_at: now,
143
+ expires_at: now + ATTRIBUTION_WINDOW_MS,
144
+ } as TouchAttribution;
145
+
146
+ await Storage.setItem(JOURNEY_STORAGE_KEYS.FIRST_TOUCH, this.firstTouch);
147
+ debugLog('First touch stored:', this.firstTouch);
148
+ } catch (error) {
149
+ errorLog('Failed to store first touch:', error as Error);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Get first touch attribution (null if expired)
155
+ */
156
+ getFirstTouch(): TouchAttribution | null {
157
+ if (this.firstTouch && this.isExpired(this.firstTouch)) {
158
+ this.firstTouch = null;
159
+ Storage.removeItem(JOURNEY_STORAGE_KEYS.FIRST_TOUCH).catch(() => {});
160
+ }
161
+ return this.firstTouch;
162
+ }
163
+
164
+ /**
165
+ * Store last touch attribution (always updates)
166
+ */
167
+ async storeLastTouch(attribution: Partial<TouchAttribution>): Promise<void> {
168
+ try {
169
+ const now = Date.now();
170
+ this.lastTouch = {
171
+ ...attribution,
172
+ timestamp: attribution.timestamp || now,
173
+ captured_at: now,
174
+ expires_at: now + ATTRIBUTION_WINDOW_MS,
175
+ } as TouchAttribution;
176
+
177
+ await Storage.setItem(JOURNEY_STORAGE_KEYS.LAST_TOUCH, this.lastTouch);
178
+ debugLog('Last touch stored:', this.lastTouch);
179
+ } catch (error) {
180
+ errorLog('Failed to store last touch:', error as Error);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Get last touch attribution (null if expired)
186
+ */
187
+ getLastTouch(): TouchAttribution | null {
188
+ if (this.lastTouch && this.isExpired(this.lastTouch)) {
189
+ this.lastTouch = null;
190
+ Storage.removeItem(JOURNEY_STORAGE_KEYS.LAST_TOUCH).catch(() => {});
191
+ }
192
+ return this.lastTouch;
193
+ }
194
+
195
+ /**
196
+ * Add a touchpoint to the customer journey
197
+ */
198
+ async addTouchpoint(sessionId: string, attribution: Partial<TouchAttribution>): Promise<void> {
199
+ try {
200
+ const touchpoint: TouchPoint = {
201
+ timestamp: Date.now(),
202
+ sessionId,
203
+ source: attribution.source,
204
+ medium: attribution.medium,
205
+ campaign: attribution.campaign,
206
+ clickIdType: attribution.clickIdType,
207
+ };
208
+
209
+ this.journey.push(touchpoint);
210
+
211
+ // Keep only last MAX_TOUCHPOINTS
212
+ if (this.journey.length > MAX_TOUCHPOINTS) {
213
+ this.journey = this.journey.slice(-MAX_TOUCHPOINTS);
214
+ }
215
+
216
+ await Storage.setItem(JOURNEY_STORAGE_KEYS.JOURNEY, this.journey);
217
+ debugLog('Touchpoint added, total:', this.journey.length);
218
+ } catch (error) {
219
+ errorLog('Failed to add touchpoint:', error as Error);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Get customer journey (all touchpoints)
225
+ */
226
+ getJourney(): TouchPoint[] {
227
+ return [...this.journey];
228
+ }
229
+
230
+ /**
231
+ * Record attribution from a deep link or install
232
+ * Updates first-touch (if not set), last-touch, and adds touchpoint
233
+ */
234
+ async recordAttribution(sessionId: string, attribution: Partial<TouchAttribution>): Promise<void> {
235
+ // Only process if we have meaningful attribution data
236
+ const hasAttribution = attribution.source || attribution.clickId || attribution.campaign || attribution.lyr;
237
+
238
+ if (!hasAttribution) {
239
+ debugLog('No attribution data to record');
240
+ return;
241
+ }
242
+
243
+ // Store first touch if not set
244
+ if (!this.getFirstTouch()) {
245
+ await this.storeFirstTouch(attribution);
246
+ }
247
+
248
+ // Always update last touch
249
+ await this.storeLastTouch(attribution);
250
+
251
+ // Add touchpoint
252
+ await this.addTouchpoint(sessionId, attribution);
253
+ }
254
+
255
+ /**
256
+ * Get attribution data for events (mirrors Web SDK format)
257
+ */
258
+ getAttributionData(): Record<string, any> {
259
+ const firstTouch = this.getFirstTouch();
260
+ const lastTouch = this.getLastTouch();
261
+ const journey = this.getJourney();
262
+
263
+ return {
264
+ // First touch (with snake_case and camelCase aliases)
265
+ first_touch_source: firstTouch?.source,
266
+ first_touch_medium: firstTouch?.medium,
267
+ first_touch_campaign: firstTouch?.campaign,
268
+ first_touch_timestamp: firstTouch?.timestamp,
269
+ firstTouchSource: firstTouch?.source,
270
+ firstTouchMedium: firstTouch?.medium,
271
+ firstTouchCampaign: firstTouch?.campaign,
272
+
273
+ // Last touch
274
+ last_touch_source: lastTouch?.source,
275
+ last_touch_medium: lastTouch?.medium,
276
+ last_touch_campaign: lastTouch?.campaign,
277
+ last_touch_timestamp: lastTouch?.timestamp,
278
+ lastTouchSource: lastTouch?.source,
279
+ lastTouchMedium: lastTouch?.medium,
280
+ lastTouchCampaign: lastTouch?.campaign,
281
+
282
+ // Journey metrics
283
+ touchpoint_count: journey.length,
284
+ touchpointCount: journey.length,
285
+ days_since_first_touch: firstTouch?.timestamp
286
+ ? Math.floor((Date.now() - firstTouch.timestamp) / 86400000)
287
+ : 0,
288
+ daysSinceFirstTouch: firstTouch?.timestamp
289
+ ? Math.floor((Date.now() - firstTouch.timestamp) / 86400000)
290
+ : 0,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Clear all journey data (for testing/reset)
296
+ */
297
+ async clearJourney(): Promise<void> {
298
+ this.firstTouch = null;
299
+ this.lastTouch = null;
300
+ this.journey = [];
301
+
302
+ await Promise.all([
303
+ Storage.removeItem(JOURNEY_STORAGE_KEYS.FIRST_TOUCH),
304
+ Storage.removeItem(JOURNEY_STORAGE_KEYS.LAST_TOUCH),
305
+ Storage.removeItem(JOURNEY_STORAGE_KEYS.JOURNEY),
306
+ ]);
307
+
308
+ debugLog('Journey data cleared');
309
+ }
310
+
311
+ /**
312
+ * Get journey summary for debugging
313
+ */
314
+ getJourneySummary(): {
315
+ hasFirstTouch: boolean;
316
+ hasLastTouch: boolean;
317
+ touchpointCount: number;
318
+ daysSinceFirstTouch: number;
319
+ sources: string[];
320
+ } {
321
+ const firstTouch = this.getFirstTouch();
322
+ const journey = this.getJourney();
323
+ const sources = [...new Set(journey.map(t => t.source).filter(Boolean))] as string[];
324
+
325
+ return {
326
+ hasFirstTouch: !!firstTouch,
327
+ hasLastTouch: !!this.getLastTouch(),
328
+ touchpointCount: journey.length,
329
+ daysSinceFirstTouch: firstTouch?.timestamp
330
+ ? Math.floor((Date.now() - firstTouch.timestamp) / 86400000)
331
+ : 0,
332
+ sources,
333
+ };
334
+ }
335
+ }
336
+
337
+ // Export singleton instance
338
+ export const journeyManager = new JourneyManager();