spotny-sdk 1.0.6 → 1.0.7

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/README.md CHANGED
@@ -269,14 +269,14 @@ Returns `Promise<Object>`.
269
269
 
270
270
  1. `initialize()` sends your `token`, `apiKey`, and `identifierId` to the Spotny backend, which issues a session JWT. The JWT is persisted locally and auto-refreshed when it expires.
271
271
  2. `startScanner()` begins monitoring for iBeacons with the Spotny UUID.
272
- 3. When a beacon is detected, the SDK immediately sends a `NEARBY` proximity event with only the `beacon_id` the backend resolves the associated screen.
273
- 4. The backend returns campaign data (`campaign_id`, `session_id`, `inQueue` flag) in the response.
274
- 5. The SDK stores this campaign data and includes it in subsequent events.
275
- 6. Additional `NEARBY` events are sent automatically as the user moves closer or further (when distance changes > 0.75m). All events send `beacon_id` — the backend handles screen resolution.
276
- 7. `IMPRESSION_HEARTBEAT` events are sent every 10 s when the user is within 2 m of a beacon with an active campaign (not queued).
272
+ 3. When a beacon is first detected, the SDK immediately sends a `NEARBY` proximity event with the `beacon_id` to log the user's presence.
273
+ 4. When the user gets within **3 meters**, the SDK calls `/distribute` with the `beacon_id` to fetch campaign data (`screen_id`, `campaign_id`, `session_id`, `inQueue` flag).
274
+ 5. The SDK stores this campaign data and uses it for subsequent impression events.
275
+ 6. Additional `NEARBY` events are sent automatically as the user moves closer or further (when distance changes > 0.75m).
276
+ 7. `IMPRESSION_HEARTBEAT` events are sent every 10 s when the user is within 2 m of a beacon with an active campaign (not queued). These events include `screen_id`, `campaign_id`, and `session_id`.
277
277
  8. On exit, `PROXIMITY_EXIT` is sent and all state is cleaned up.
278
278
 
279
- **The SDK is fully backend-driven:** It only sends `beacon_id` + distance. The backend owns the beacon screen mapping, so you can reassign beacons to different screens without updating the SDK.
279
+ **Two-stage tracking:** Proximity events are lightweight (beacon_id only) and sent immediately. Campaign data is fetched only when needed (at 3m), optimizing network usage while ensuring timely engagement tracking.
280
280
 
281
281
  All events are tied to the authenticated `identifierId` supplied during `initialize()` — no additional identity calls are required.
282
282
 
@@ -70,6 +70,8 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
70
70
  private val lastImpressionEventSent = mutableMapOf<String, Long>()
71
71
  private val proximityEventInProgress = mutableMapOf<String, Boolean>()
72
72
  private val impressionEventInProgress = mutableMapOf<String, Boolean>()
73
+ private val campaignFetchInProgress = mutableMapOf<String, Boolean>()
74
+ private val campaignFetched = mutableMapOf<String, Boolean>()
73
75
 
74
76
  // ── Module registration ───────────────────────────────────────────────────
75
77
 
@@ -310,6 +312,11 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
310
312
  }
311
313
  }
312
314
 
315
+ // Fetch campaign when distance <= 3m
316
+ if (distance <= 3.0 && campaignFetched[key] != true) {
317
+ fetchCampaign(key, major, minor)
318
+ }
319
+
313
320
  // Impression heartbeat when user is very close AND campaign exists
314
321
  val campaign = activeCampaigns[key]
315
322
  if (campaign?.campaignId != null && campaign.inQueue == false && distance <= impressionDistance) {
@@ -406,6 +413,8 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
406
413
  lastImpressionEventSent.remove(key)
407
414
  proximityEventInProgress.remove(key)
408
415
  impressionEventInProgress.remove(key)
416
+ campaignFetchInProgress.remove(key)
417
+ campaignFetched.remove(key)
409
418
  Log.d(TAG, "Cleaned up state for beacon $key")
410
419
  }
411
420
 
@@ -541,7 +550,7 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
541
550
  if (isImpression) impressionEventInProgress[key] = true
542
551
  else proximityEventInProgress[key] = true
543
552
 
544
- // Build payload — all events now use beacon_id, backend resolves screen_id
553
+ // Build payload — proximity events send beacon_id only, impressions include campaign data
545
554
  val payload = mutableMapOf<String, Any?>(
546
555
  "event_type" to eventType,
547
556
  "beacon_id" to key,
@@ -555,71 +564,74 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
555
564
  impressionEventInProgress[key] = false
556
565
  return
557
566
  }
567
+ payload["screen_id"] = campaign.screenId
558
568
  campaign.campaignId?.let { payload["campaign_id"] = it }
559
569
  campaign.sessionId?.let { payload["session_id"] = it }
560
- } else {
561
- // Proximity events also send campaign/session if available
562
- activeCampaigns[key]?.let { campaign ->
563
- campaign.campaignId?.let { payload["campaign_id"] = it }
564
- campaign.sessionId?.let { payload["session_id"] = it }
565
- }
566
570
  }
567
571
 
568
572
  post(endpoint, payload) { status, body ->
569
573
  if (status in 200..299) {
570
574
  Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
571
-
572
- // Parse campaign_id, session_id from proximity response
573
- if (!isImpression) {
574
- try {
575
- val json = parseJsonObject(body)
576
- val dataObj = json?.get("data") as? Map<*, *>
577
-
575
+ } else if (status == 429) {
576
+ val penalty = System.currentTimeMillis() + 10_000L
577
+ if (isImpression) lastImpressionEventSent[key] = penalty
578
+ else lastProximityEventSent[key] = penalty
579
+ Log.w(TAG, "$eventType rate-limited (429)")
580
+ } else {
581
+ Log.w(TAG, "$eventType failed — status $status")
582
+ }
583
+ if (isImpression) impressionEventInProgress[key] = false
584
+ else proximityEventInProgress[key] = false
585
+ }
586
+ }
587
+
588
+ private fun fetchCampaign(key: String, major: Int, minor: Int) {
589
+ if (campaignFetchInProgress[key] == true) return
590
+ if (campaignFetched[key] == true) return
591
+
592
+ campaignFetchInProgress[key] = true
593
+
594
+ val payload = mapOf("beacon_id" to key)
595
+
596
+ post("$apiBasePath/distribute", payload) { status, body ->
597
+ campaignFetchInProgress[key] = false
598
+
599
+ if (status in 200..299) {
600
+ try {
601
+ val json = parseJsonObject(body)
602
+ val dataObj = json?.get("data") as? Map<*, *>
603
+
604
+ if (dataObj != null) {
605
+ val screenId = (dataObj["screen_id"] as? Number)?.toInt() ?: 0
578
606
  var campaignId: Int? = null
579
607
  var sessionId: String? = null
580
608
  var inQueue = false
581
609
 
582
- // Extract session_id from event object
583
- val eventObj = dataObj?.get("event") as? Map<*, *>
584
- if (eventObj != null) {
585
- sessionId = eventObj["session_id"] as? String
586
- }
587
-
588
- // Extract from campaign object
589
- val campaignObj = dataObj?.get("campaign") as? Map<*, *>
610
+ val campaignObj = dataObj["campaign"] as? Map<*, *>
590
611
  if (campaignObj != null) {
591
612
  campaignId = (campaignObj["id"] as? Number)?.toInt()
613
+ sessionId = campaignObj["session_id"] as? String
592
614
  inQueue = campaignObj["inQueue"] as? Boolean ?: false
593
615
  }
594
616
 
595
- // Store campaign data
596
- // Parse major/minor from key
597
- val parts = key.split("_")
598
- val major = parts.getOrNull(0)?.toIntOrNull() ?: 0
599
- val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0
600
-
601
617
  activeCampaigns[key] = CampaignData(
618
+ screenId = screenId,
602
619
  campaignId = campaignId,
603
620
  sessionId = sessionId,
604
621
  inQueue = inQueue,
605
622
  major = major,
606
623
  minor = minor
607
624
  )
608
- Log.d(TAG, "Stored campaign — campaignId=$campaignId, sessionId=$sessionId, inQueue=$inQueue")
609
- } catch (e: Exception) {
610
- Log.w(TAG, "Error parsing proximity response: ${e.message}")
625
+ campaignFetched[key] = true
626
+
627
+ Log.d(TAG, "Campaign fetched screenId=$screenId, campaignId=$campaignId, sessionId=$sessionId, inQueue=$inQueue")
611
628
  }
629
+ } catch (e: Exception) {
630
+ Log.w(TAG, "Error parsing campaign response: ${e.message}")
612
631
  }
613
- } else if (status == 429) {
614
- val penalty = System.currentTimeMillis() + 10_000L
615
- if (isImpression) lastImpressionEventSent[key] = penalty
616
- else lastProximityEventSent[key] = penalty
617
- Log.w(TAG, "$eventType rate-limited (429)")
618
632
  } else {
619
- Log.w(TAG, "$eventType failed — status $status")
633
+ Log.w(TAG, "Campaign fetch failed — status $status")
620
634
  }
621
- if (isImpression) impressionEventInProgress[key] = false
622
- else proximityEventInProgress[key] = false
623
635
  }
624
636
  }
625
637
 
@@ -20,8 +20,9 @@ public typealias SpotnyEventCallback = @convention(block) (_ name: String, _ bod
20
20
  // MARK: - Internal data structures
21
21
 
22
22
  private struct CampaignData {
23
+ let screenId: Int
23
24
  let campaignId: Int? // nil when no active campaign
24
- let sessionId: String? // set after first proximity response
25
+ let sessionId: String? // set after campaign fetch response
25
26
  let inQueue: Bool // campaign is queued — skip impressions
26
27
  let major: Int
27
28
  let minor: Int
@@ -92,6 +93,8 @@ public class SpotnyBeaconScanner: NSObject {
92
93
  private var lastImpressionEventSent: [String: Date] = [:]
93
94
  private var proximityEventInProgress: [String: Bool] = [:]
94
95
  private var impressionEventInProgress: [String: Bool] = [:]
96
+ private var campaignFetchInProgress: [String: Bool] = [:]
97
+ private var campaignFetched: [String: Bool] = [:]
95
98
 
96
99
  // MARK: - Init
97
100
 
@@ -593,7 +596,7 @@ public class SpotnyBeaconScanner: NSObject {
593
596
  if isImpression { impressionEventInProgress[key] = true }
594
597
  else { proximityEventInProgress[key] = true }
595
598
 
596
- // Build payload — all events now use beacon_id, backend resolves screen_id
599
+ // Build payload — proximity events send beacon_id only, impressions include campaign data
597
600
  var payload: [String: Any] = [
598
601
  "event_type": eventType,
599
602
  "beacon_id": key,
@@ -608,14 +611,9 @@ public class SpotnyBeaconScanner: NSObject {
608
611
  impressionEventInProgress[key] = false
609
612
  return
610
613
  }
614
+ payload["screen_id"] = campaign.screenId
611
615
  if let cid = campaign.campaignId { payload["campaign_id"] = cid }
612
616
  if let sid = campaign.sessionId { payload["session_id"] = sid }
613
- } else {
614
- // Proximity events also send campaign/session if available
615
- if let campaign = activeCampaigns[key] {
616
- if let cid = campaign.campaignId { payload["campaign_id"] = cid }
617
- if let sid = campaign.sessionId { payload["session_id"] = sid }
618
- }
619
617
  }
620
618
 
621
619
  post(endpoint: endpoint, payload: payload) { [weak self] result in
@@ -625,43 +623,6 @@ public class SpotnyBeaconScanner: NSObject {
625
623
  if 200...299 ~= status {
626
624
  print("✅ SpotnySDK: \(eventType) sent — distance \(String(format: "%.2f", distance))m")
627
625
  logToFile("✅ \(eventType) sent — beacon \(key) @ \(String(format: "%.2f", distance))m")
628
-
629
- // Parse campaign_id, session_id from proximity response
630
- if !isImpression,
631
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
632
- let dObj = json["data"] as? [String: Any] {
633
-
634
- var campaignId: Int?
635
- var sessionId: String?
636
- var inQueue = false
637
-
638
- // Extract session_id from event object
639
- if let ev = dObj["event"] as? [String: Any] {
640
- sessionId = ev["session_id"] as? String
641
- }
642
-
643
- // Extract from campaign object
644
- if let campaignObj = dObj["campaign"] as? [String: Any] {
645
- campaignId = campaignObj["id"] as? Int
646
- inQueue = campaignObj["inQueue"] as? Bool ?? false
647
- }
648
-
649
- // Store campaign data
650
- // Parse major/minor from key
651
- let parts = key.components(separatedBy: "_")
652
- let major = parts.count > 0 ? Int(parts[0]) ?? 0 : 0
653
- let minor = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
654
-
655
- let updated = CampaignData(
656
- campaignId: campaignId,
657
- sessionId: sessionId,
658
- inQueue: inQueue,
659
- major: major,
660
- minor: minor
661
- )
662
- self.activeCampaigns[key] = updated
663
- print("✅ SpotnySDK: Stored campaign — campaignId=\(campaignId ?? 0), sessionId=\(sessionId ?? "nil"), inQueue=\(inQueue)")
664
- }
665
626
  } else if status == 429 {
666
627
  let penalty = Date().addingTimeInterval(10)
667
628
  if isImpression { self.lastImpressionEventSent[key] = penalty }
@@ -678,6 +639,63 @@ public class SpotnyBeaconScanner: NSObject {
678
639
  }
679
640
  }
680
641
 
642
+ private func fetchCampaign(key: String, major: Int, minor: Int) {
643
+ guard campaignFetchInProgress[key] != true else { return }
644
+ guard campaignFetched[key] != true else { return }
645
+
646
+ campaignFetchInProgress[key] = true
647
+
648
+ let payload: [String: Any] = ["beacon_id": key]
649
+
650
+ post(endpoint: "\(apiBasePath)/distribute", payload: payload) { [weak self] result in
651
+ guard let self = self else { return }
652
+ self.campaignFetchInProgress[key] = false
653
+
654
+ switch result {
655
+ case .success(let (status, data)):
656
+ if 200...299 ~= status {
657
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
658
+ let dObj = json["data"] as? [String: Any] else {
659
+ print("⚠️ SpotnySDK: Campaign fetch — invalid response format")
660
+ return
661
+ }
662
+
663
+ var screenId: Int = 0
664
+ var campaignId: Int?
665
+ var sessionId: String?
666
+ var inQueue = false
667
+
668
+ if let sid = dObj["screen_id"] as? Int {
669
+ screenId = sid
670
+ }
671
+
672
+ if let campaignObj = dObj["campaign"] as? [String: Any] {
673
+ campaignId = campaignObj["id"] as? Int
674
+ sessionId = campaignObj["session_id"] as? String
675
+ inQueue = campaignObj["inQueue"] as? Bool ?? false
676
+ }
677
+
678
+ let campaign = CampaignData(
679
+ screenId: screenId,
680
+ campaignId: campaignId,
681
+ sessionId: sessionId,
682
+ inQueue: inQueue,
683
+ major: major,
684
+ minor: minor
685
+ )
686
+ self.activeCampaigns[key] = campaign
687
+ self.campaignFetched[key] = true
688
+
689
+ print("✅ SpotnySDK: Campaign fetched — screenId=\(screenId), campaignId=\(campaignId ?? 0), sessionId=\(sessionId ?? "nil"), inQueue=\(inQueue)")
690
+ } else {
691
+ print("❌ SpotnySDK: Campaign fetch failed — status \(status)")
692
+ }
693
+ case .failure(let error):
694
+ print("❌ SpotnySDK: Campaign fetch error — \(error.localizedDescription)")
695
+ }
696
+ }
697
+ }
698
+
681
699
  private func sendProximity(eventType: String, key: String, distance: Double) {
682
700
  sendTracking(eventType: eventType, key: key, distance: distance,
683
701
  endpoint: "\(apiBasePath)/proximity")
@@ -698,6 +716,8 @@ public class SpotnyBeaconScanner: NSObject {
698
716
  lastImpressionEventSent.removeValue(forKey: key)
699
717
  proximityEventInProgress.removeValue(forKey: key)
700
718
  impressionEventInProgress.removeValue(forKey: key)
719
+ campaignFetchInProgress.removeValue(forKey: key)
720
+ campaignFetched.removeValue(forKey: key)
701
721
  print("🧹 SpotnySDK: Cleaned up state for beacon \(key)")
702
722
  }
703
723
 
@@ -800,6 +820,11 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
800
820
  lastProximityDistance[key] = distance
801
821
  lastProximityEventSent[key] = now
802
822
  }
823
+
824
+ // Fetch campaign when distance <= 3m
825
+ if distance <= 3.0 && campaignFetched[key] != true {
826
+ fetchCampaign(key: key, major: major, minor: minor)
827
+ }
803
828
 
804
829
  // Impression heartbeat when user is very close AND campaign exists
805
830
  if let campaign = activeCampaigns[key],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spotny-sdk",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Beacon Scanner",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",