@loyalytics/swan-react-native-sdk 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/index.js +187 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/index.js +188 -3
- package/lib/module/index.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +30 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/version.d.ts +1 -1
- package/lib/typescript/module/src/index.d.ts +30 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/version.d.ts +1 -1
- package/package.json +1 -1
package/lib/module/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
15
15
|
import Base64 from 'react-native-base64';
|
|
16
16
|
import uuid from 'react-native-uuid';
|
|
17
|
-
import { StyleSheet, View, Modal, PermissionsAndroid, Platform, AppState } from 'react-native';
|
|
17
|
+
import { StyleSheet, View, Modal, PermissionsAndroid, Platform, AppState, Linking } from 'react-native';
|
|
18
18
|
import { SDK_VERSION as PACKAGE_VERSION } from "./version.js";
|
|
19
19
|
import PopUpView from "./components/PopUpView.js";
|
|
20
20
|
import HeaderView from "./components/HeaderView.js";
|
|
@@ -130,6 +130,7 @@ export default class SwanSDK {
|
|
|
130
130
|
pushService = null;
|
|
131
131
|
pendingPushEventListeners = [];
|
|
132
132
|
appStateSubscription = null;
|
|
133
|
+
linkingSubscription = null;
|
|
133
134
|
|
|
134
135
|
// Track if initial notification was already handled to prevent duplicate ACKs
|
|
135
136
|
initialNotificationHandled = false;
|
|
@@ -308,6 +309,10 @@ export default class SwanSDK {
|
|
|
308
309
|
this.updateLocation().catch(error => {
|
|
309
310
|
Logger.warn('[SwanSDK] Location update failed:', error);
|
|
310
311
|
});
|
|
312
|
+
|
|
313
|
+
// Phase 5: Deep link listeners (non-blocking)
|
|
314
|
+
Logger.log('[SwanSDK] Phase 5: Setting up deep link listeners...');
|
|
315
|
+
this.setupDeepLinkListeners();
|
|
311
316
|
} catch (error) {
|
|
312
317
|
Logger.error('[SwanSDK] SDK Initialization failed:', error);
|
|
313
318
|
throw error;
|
|
@@ -588,6 +593,180 @@ export default class SwanSDK {
|
|
|
588
593
|
emitNotificationOpened(payload) {
|
|
589
594
|
this.emit(SwanSDK.EVENTS.NOTIFICATION_OPENED, payload);
|
|
590
595
|
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Parse a URL string and extract path and query parameters.
|
|
599
|
+
* Uses manual parsing to avoid dependency on URL API which has inconsistent
|
|
600
|
+
* support across React Native/Hermes versions for custom schemes.
|
|
601
|
+
* @internal
|
|
602
|
+
*/
|
|
603
|
+
parseDeepLinkUrl(url) {
|
|
604
|
+
try {
|
|
605
|
+
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
const trimmedUrl = url.trim();
|
|
609
|
+
|
|
610
|
+
// Split URL into base and query string
|
|
611
|
+
const questionMarkIndex = trimmedUrl.indexOf('?');
|
|
612
|
+
let basePart = '';
|
|
613
|
+
let queryString = '';
|
|
614
|
+
if (questionMarkIndex === -1) {
|
|
615
|
+
basePart = trimmedUrl;
|
|
616
|
+
} else {
|
|
617
|
+
basePart = trimmedUrl.substring(0, questionMarkIndex);
|
|
618
|
+
queryString = trimmedUrl.substring(questionMarkIndex + 1);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Extract path component
|
|
622
|
+
// For http/https URLs: strip scheme + host (https://example.com/path → /path)
|
|
623
|
+
// For custom scheme URLs: keep everything after :// (myapp://products/123 → /products/123)
|
|
624
|
+
let path = basePart;
|
|
625
|
+
const schemeEnd = basePart.indexOf('://');
|
|
626
|
+
if (schemeEnd !== -1) {
|
|
627
|
+
const scheme = basePart.substring(0, schemeEnd).toLowerCase();
|
|
628
|
+
const afterScheme = basePart.substring(schemeEnd + 3);
|
|
629
|
+
if (scheme === 'http' || scheme === 'https') {
|
|
630
|
+
// Strip host for web URLs
|
|
631
|
+
const firstSlash = afterScheme.indexOf('/');
|
|
632
|
+
path = firstSlash !== -1 ? afterScheme.substring(firstSlash) : '/';
|
|
633
|
+
} else {
|
|
634
|
+
// Custom scheme — entire afterScheme is the route
|
|
635
|
+
path = '/' + afterScheme;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Parse query string into key-value pairs
|
|
640
|
+
const params = {};
|
|
641
|
+
if (queryString.length > 0) {
|
|
642
|
+
// Strip fragment identifier if present
|
|
643
|
+
const hashIndex = queryString.indexOf('#');
|
|
644
|
+
const cleanQuery = hashIndex !== -1 ? queryString.substring(0, hashIndex) : queryString;
|
|
645
|
+
const pairs = cleanQuery.split('&');
|
|
646
|
+
for (const pair of pairs) {
|
|
647
|
+
const equalsIndex = pair.indexOf('=');
|
|
648
|
+
if (equalsIndex !== -1) {
|
|
649
|
+
const key = decodeURIComponent(pair.substring(0, equalsIndex));
|
|
650
|
+
const value = decodeURIComponent(pair.substring(equalsIndex + 1));
|
|
651
|
+
if (key.length > 0) {
|
|
652
|
+
params[key] = value;
|
|
653
|
+
}
|
|
654
|
+
} else if (pair.length > 0) {
|
|
655
|
+
params[decodeURIComponent(pair)] = '';
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
path,
|
|
661
|
+
params
|
|
662
|
+
};
|
|
663
|
+
} catch (error) {
|
|
664
|
+
Logger.error('[SwanSDK] Error parsing deep link URL:', error);
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Handle a deep link URL from email, SMS, WhatsApp, or other channels.
|
|
671
|
+
*
|
|
672
|
+
* Silently parses the URL, extracts any `swan_` prefixed query parameters,
|
|
673
|
+
* and tracks a 'deepLinkClicked' event to the backend for campaign attribution.
|
|
674
|
+
* The host app's existing deep link routing handles navigation — the SDK
|
|
675
|
+
* only handles attribution tracking.
|
|
676
|
+
*
|
|
677
|
+
* If no `swan_` parameters are found, the URL is not a SWAN campaign link
|
|
678
|
+
* and no tracking occurs.
|
|
679
|
+
*
|
|
680
|
+
* @param url - The full deep link URL string
|
|
681
|
+
* @internal - Called automatically by setupDeepLinkListeners
|
|
682
|
+
*/
|
|
683
|
+
handleDeepLink(url) {
|
|
684
|
+
try {
|
|
685
|
+
Logger.log('[SwanSDK] handleDeepLink called with URL:', url);
|
|
686
|
+
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const parsed = this.parseDeepLinkUrl(url);
|
|
690
|
+
if (!parsed) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Extract swan_ prefixed parameters
|
|
695
|
+
const swanParams = {};
|
|
696
|
+
for (const [key, value] of Object.entries(parsed.params)) {
|
|
697
|
+
if (key.startsWith('swan_')) {
|
|
698
|
+
swanParams[key] = value;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// If no SWAN parameters found, this is not a SWAN campaign link
|
|
703
|
+
if (Object.keys(swanParams).length === 0) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
Logger.log('[SwanSDK] Deep link attribution: found SWAN parameters:', JSON.stringify(swanParams));
|
|
707
|
+
|
|
708
|
+
// Track click via webhook API (same endpoint as push notification clicks)
|
|
709
|
+
const commId = swanParams['swan_comm_id'];
|
|
710
|
+
if (!commId) {
|
|
711
|
+
Logger.warn('[SwanSDK] Deep link missing swan_comm_id, skipping webhook tracking');
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const linkId = swanParams['swan_link_id'] || null;
|
|
715
|
+
|
|
716
|
+
// Queue as SWAN_NOTIFICATION_ACK with type: 'deepLink' so backend
|
|
717
|
+
// can distinguish from push notification ACKs (where commId is Firebase's messageId)
|
|
718
|
+
const ackData = {
|
|
719
|
+
commId,
|
|
720
|
+
event: 'clicked',
|
|
721
|
+
type: 'deepLink',
|
|
722
|
+
...(linkId && {
|
|
723
|
+
linkId
|
|
724
|
+
}),
|
|
725
|
+
timestamp: Date.now()
|
|
726
|
+
};
|
|
727
|
+
this.trackEvent('SWAN_NOTIFICATION_ACK', ackData).catch(error => {
|
|
728
|
+
Logger.error('[SwanSDK] Failed to send deep link click ACK:', error);
|
|
729
|
+
});
|
|
730
|
+
} catch (error) {
|
|
731
|
+
Logger.error('[SwanSDK] handleDeepLink: unexpected error:', error);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Set up deep link listeners using React Native Linking API.
|
|
737
|
+
* Handles both warm start (app already running) and cold start (app launched via deep link).
|
|
738
|
+
* Called automatically during SDK initialization.
|
|
739
|
+
* @internal
|
|
740
|
+
*/
|
|
741
|
+
setupDeepLinkListeners() {
|
|
742
|
+
try {
|
|
743
|
+
// Clean up existing listener if any
|
|
744
|
+
if (this.linkingSubscription) {
|
|
745
|
+
this.linkingSubscription.remove();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Listen for deep links when app is already running (warm start)
|
|
749
|
+
this.linkingSubscription = Linking.addEventListener('url', ({
|
|
750
|
+
url
|
|
751
|
+
}) => {
|
|
752
|
+
Logger.log('[SwanSDK] Deep link received (warm start):', url);
|
|
753
|
+
this.handleDeepLink(url);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Check for initial URL that launched the app (cold start)
|
|
757
|
+
Linking.getInitialURL().then(initialUrl => {
|
|
758
|
+
if (initialUrl) {
|
|
759
|
+
Logger.log('[SwanSDK] Deep link received (cold start):', initialUrl);
|
|
760
|
+
this.handleDeepLink(initialUrl);
|
|
761
|
+
}
|
|
762
|
+
}).catch(error => {
|
|
763
|
+
Logger.warn('[SwanSDK] Failed to get initial URL:', error);
|
|
764
|
+
});
|
|
765
|
+
Logger.log('[SwanSDK] Deep link listeners set up successfully');
|
|
766
|
+
} catch (error) {
|
|
767
|
+
Logger.error('[SwanSDK] Failed to set up deep link listeners:', error);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
591
770
|
static setLoggingEnabled(enabled) {
|
|
592
771
|
Logger.enableLogs(enabled);
|
|
593
772
|
}
|
|
@@ -1094,12 +1273,14 @@ export default class SwanSDK {
|
|
|
1094
1273
|
results.push(...enrichResults);
|
|
1095
1274
|
}
|
|
1096
1275
|
|
|
1097
|
-
// 5. Process Ack Events (Individual to /
|
|
1276
|
+
// 5. Process Ack Events (Individual to webhook/comms/mobile-push-tracking)
|
|
1098
1277
|
if (ackEvents.length > 0) {
|
|
1099
1278
|
const ackPromises = ackEvents.map(async event => {
|
|
1100
1279
|
const {
|
|
1101
1280
|
commId,
|
|
1102
|
-
event: ackType
|
|
1281
|
+
event: ackType,
|
|
1282
|
+
type,
|
|
1283
|
+
linkId
|
|
1103
1284
|
} = event.eventData.data;
|
|
1104
1285
|
const payload = {
|
|
1105
1286
|
commId,
|
|
@@ -1108,6 +1289,10 @@ export default class SwanSDK {
|
|
|
1108
1289
|
event: ackType,
|
|
1109
1290
|
deviceId
|
|
1110
1291
|
};
|
|
1292
|
+
|
|
1293
|
+
// Deep link ACKs include type and linkId for backend routing
|
|
1294
|
+
if (type) payload.type = type;
|
|
1295
|
+
if (linkId) payload.linkId = linkId;
|
|
1111
1296
|
const response = await this.sendToSwan(URLS.WEBHOOK_MOBILE_PUSH_URL[this.isProduction], payload);
|
|
1112
1297
|
return {
|
|
1113
1298
|
id: event.id,
|