@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.
@@ -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 /post-in-app-notification-sdk-ack)
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,