@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.
@@ -144,6 +144,7 @@ class SwanSDK {
144
144
  pushService = null;
145
145
  pendingPushEventListeners = [];
146
146
  appStateSubscription = null;
147
+ linkingSubscription = null;
147
148
 
148
149
  // Track if initial notification was already handled to prevent duplicate ACKs
149
150
  initialNotificationHandled = false;
@@ -322,6 +323,10 @@ class SwanSDK {
322
323
  this.updateLocation().catch(error => {
323
324
  _Logger.default.warn('[SwanSDK] Location update failed:', error);
324
325
  });
326
+
327
+ // Phase 5: Deep link listeners (non-blocking)
328
+ _Logger.default.log('[SwanSDK] Phase 5: Setting up deep link listeners...');
329
+ this.setupDeepLinkListeners();
325
330
  } catch (error) {
326
331
  _Logger.default.error('[SwanSDK] SDK Initialization failed:', error);
327
332
  throw error;
@@ -602,6 +607,180 @@ class SwanSDK {
602
607
  emitNotificationOpened(payload) {
603
608
  this.emit(SwanSDK.EVENTS.NOTIFICATION_OPENED, payload);
604
609
  }
610
+
611
+ /**
612
+ * Parse a URL string and extract path and query parameters.
613
+ * Uses manual parsing to avoid dependency on URL API which has inconsistent
614
+ * support across React Native/Hermes versions for custom schemes.
615
+ * @internal
616
+ */
617
+ parseDeepLinkUrl(url) {
618
+ try {
619
+ if (!url || typeof url !== 'string' || url.trim().length === 0) {
620
+ return null;
621
+ }
622
+ const trimmedUrl = url.trim();
623
+
624
+ // Split URL into base and query string
625
+ const questionMarkIndex = trimmedUrl.indexOf('?');
626
+ let basePart = '';
627
+ let queryString = '';
628
+ if (questionMarkIndex === -1) {
629
+ basePart = trimmedUrl;
630
+ } else {
631
+ basePart = trimmedUrl.substring(0, questionMarkIndex);
632
+ queryString = trimmedUrl.substring(questionMarkIndex + 1);
633
+ }
634
+
635
+ // Extract path component
636
+ // For http/https URLs: strip scheme + host (https://example.com/path → /path)
637
+ // For custom scheme URLs: keep everything after :// (myapp://products/123 → /products/123)
638
+ let path = basePart;
639
+ const schemeEnd = basePart.indexOf('://');
640
+ if (schemeEnd !== -1) {
641
+ const scheme = basePart.substring(0, schemeEnd).toLowerCase();
642
+ const afterScheme = basePart.substring(schemeEnd + 3);
643
+ if (scheme === 'http' || scheme === 'https') {
644
+ // Strip host for web URLs
645
+ const firstSlash = afterScheme.indexOf('/');
646
+ path = firstSlash !== -1 ? afterScheme.substring(firstSlash) : '/';
647
+ } else {
648
+ // Custom scheme — entire afterScheme is the route
649
+ path = '/' + afterScheme;
650
+ }
651
+ }
652
+
653
+ // Parse query string into key-value pairs
654
+ const params = {};
655
+ if (queryString.length > 0) {
656
+ // Strip fragment identifier if present
657
+ const hashIndex = queryString.indexOf('#');
658
+ const cleanQuery = hashIndex !== -1 ? queryString.substring(0, hashIndex) : queryString;
659
+ const pairs = cleanQuery.split('&');
660
+ for (const pair of pairs) {
661
+ const equalsIndex = pair.indexOf('=');
662
+ if (equalsIndex !== -1) {
663
+ const key = decodeURIComponent(pair.substring(0, equalsIndex));
664
+ const value = decodeURIComponent(pair.substring(equalsIndex + 1));
665
+ if (key.length > 0) {
666
+ params[key] = value;
667
+ }
668
+ } else if (pair.length > 0) {
669
+ params[decodeURIComponent(pair)] = '';
670
+ }
671
+ }
672
+ }
673
+ return {
674
+ path,
675
+ params
676
+ };
677
+ } catch (error) {
678
+ _Logger.default.error('[SwanSDK] Error parsing deep link URL:', error);
679
+ return null;
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Handle a deep link URL from email, SMS, WhatsApp, or other channels.
685
+ *
686
+ * Silently parses the URL, extracts any `swan_` prefixed query parameters,
687
+ * and tracks a 'deepLinkClicked' event to the backend for campaign attribution.
688
+ * The host app's existing deep link routing handles navigation — the SDK
689
+ * only handles attribution tracking.
690
+ *
691
+ * If no `swan_` parameters are found, the URL is not a SWAN campaign link
692
+ * and no tracking occurs.
693
+ *
694
+ * @param url - The full deep link URL string
695
+ * @internal - Called automatically by setupDeepLinkListeners
696
+ */
697
+ handleDeepLink(url) {
698
+ try {
699
+ _Logger.default.log('[SwanSDK] handleDeepLink called with URL:', url);
700
+ if (!url || typeof url !== 'string' || url.trim().length === 0) {
701
+ return;
702
+ }
703
+ const parsed = this.parseDeepLinkUrl(url);
704
+ if (!parsed) {
705
+ return;
706
+ }
707
+
708
+ // Extract swan_ prefixed parameters
709
+ const swanParams = {};
710
+ for (const [key, value] of Object.entries(parsed.params)) {
711
+ if (key.startsWith('swan_')) {
712
+ swanParams[key] = value;
713
+ }
714
+ }
715
+
716
+ // If no SWAN parameters found, this is not a SWAN campaign link
717
+ if (Object.keys(swanParams).length === 0) {
718
+ return;
719
+ }
720
+ _Logger.default.log('[SwanSDK] Deep link attribution: found SWAN parameters:', JSON.stringify(swanParams));
721
+
722
+ // Track click via webhook API (same endpoint as push notification clicks)
723
+ const commId = swanParams['swan_comm_id'];
724
+ if (!commId) {
725
+ _Logger.default.warn('[SwanSDK] Deep link missing swan_comm_id, skipping webhook tracking');
726
+ return;
727
+ }
728
+ const linkId = swanParams['swan_link_id'] || null;
729
+
730
+ // Queue as SWAN_NOTIFICATION_ACK with type: 'deepLink' so backend
731
+ // can distinguish from push notification ACKs (where commId is Firebase's messageId)
732
+ const ackData = {
733
+ commId,
734
+ event: 'clicked',
735
+ type: 'deepLink',
736
+ ...(linkId && {
737
+ linkId
738
+ }),
739
+ timestamp: Date.now()
740
+ };
741
+ this.trackEvent('SWAN_NOTIFICATION_ACK', ackData).catch(error => {
742
+ _Logger.default.error('[SwanSDK] Failed to send deep link click ACK:', error);
743
+ });
744
+ } catch (error) {
745
+ _Logger.default.error('[SwanSDK] handleDeepLink: unexpected error:', error);
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Set up deep link listeners using React Native Linking API.
751
+ * Handles both warm start (app already running) and cold start (app launched via deep link).
752
+ * Called automatically during SDK initialization.
753
+ * @internal
754
+ */
755
+ setupDeepLinkListeners() {
756
+ try {
757
+ // Clean up existing listener if any
758
+ if (this.linkingSubscription) {
759
+ this.linkingSubscription.remove();
760
+ }
761
+
762
+ // Listen for deep links when app is already running (warm start)
763
+ this.linkingSubscription = _reactNative.Linking.addEventListener('url', ({
764
+ url
765
+ }) => {
766
+ _Logger.default.log('[SwanSDK] Deep link received (warm start):', url);
767
+ this.handleDeepLink(url);
768
+ });
769
+
770
+ // Check for initial URL that launched the app (cold start)
771
+ _reactNative.Linking.getInitialURL().then(initialUrl => {
772
+ if (initialUrl) {
773
+ _Logger.default.log('[SwanSDK] Deep link received (cold start):', initialUrl);
774
+ this.handleDeepLink(initialUrl);
775
+ }
776
+ }).catch(error => {
777
+ _Logger.default.warn('[SwanSDK] Failed to get initial URL:', error);
778
+ });
779
+ _Logger.default.log('[SwanSDK] Deep link listeners set up successfully');
780
+ } catch (error) {
781
+ _Logger.default.error('[SwanSDK] Failed to set up deep link listeners:', error);
782
+ }
783
+ }
605
784
  static setLoggingEnabled(enabled) {
606
785
  _Logger.default.enableLogs(enabled);
607
786
  }
@@ -1108,12 +1287,14 @@ class SwanSDK {
1108
1287
  results.push(...enrichResults);
1109
1288
  }
1110
1289
 
1111
- // 5. Process Ack Events (Individual to /post-in-app-notification-sdk-ack)
1290
+ // 5. Process Ack Events (Individual to webhook/comms/mobile-push-tracking)
1112
1291
  if (ackEvents.length > 0) {
1113
1292
  const ackPromises = ackEvents.map(async event => {
1114
1293
  const {
1115
1294
  commId,
1116
- event: ackType
1295
+ event: ackType,
1296
+ type,
1297
+ linkId
1117
1298
  } = event.eventData.data;
1118
1299
  const payload = {
1119
1300
  commId,
@@ -1122,6 +1303,10 @@ class SwanSDK {
1122
1303
  event: ackType,
1123
1304
  deviceId
1124
1305
  };
1306
+
1307
+ // Deep link ACKs include type and linkId for backend routing
1308
+ if (type) payload.type = type;
1309
+ if (linkId) payload.linkId = linkId;
1125
1310
  const response = await this.sendToSwan(_ApiUrls.default.WEBHOOK_MOBILE_PUSH_URL[this.isProduction], payload);
1126
1311
  return {
1127
1312
  id: event.id,