spotny-sdk 1.0.3 → 1.0.5

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
@@ -255,11 +255,11 @@ await setDebounceInterval(10); // emit at most every 10 s
255
255
 
256
256
  #### `clearDebounceCache()`
257
257
 
258
- Resets internal campaign-fetch cooldown state. Useful during testing.
258
+ Resets internal timing state. Useful during testing.
259
259
 
260
260
  #### `getDebounceStatus()`
261
261
 
262
- Returns internal per-beacon timing state. Useful for debugging stuck campaigns.
262
+ Returns internal per-beacon timing state. Useful for debugging.
263
263
 
264
264
  Returns `Promise<Object>`.
265
265
 
@@ -267,12 +267,14 @@ Returns `Promise<Object>`.
267
267
 
268
268
  ## How It Works
269
269
 
270
- 1. `initialize()` sends your `token`, `apiKey`, and `userId` to the Spotny backend, which issues a session JWT. The JWT is persisted locally and auto-refreshed when it expires.
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 fetches the associated campaign from the Spotny backend.
273
- 4. `NEARBY` events are sent automatically as the user moves closer or further.
274
- 5. `IMPRESSION_HEARTBEAT` events are sent every 10 s when the user is within 2 m of an active campaign.
275
- 6. On exit, `PROXIMITY_EXIT` is sent and all state is cleaned up.
272
+ 3. When a beacon is detected, the SDK immediately sends a `NEARBY` proximity event with the `beacon_id`.
273
+ 4. The backend resolves the beacon to a `screen_id` and returns campaign data (`campaign_id`, `session_id`).
274
+ 5. The SDK stores this campaign data and uses it for subsequent 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.
277
+ 8. On exit, `PROXIMITY_EXIT` is sent and all state is cleaned up.
276
278
 
277
279
  All events are tied to the authenticated `identifierId` supplied during `initialize()` — no additional identity calls are required.
278
280
 
@@ -301,289 +303,3 @@ All events are tied to the authenticated `identifierId` supplied during `initial
301
303
  ## License
302
304
 
303
305
  MIT
304
-
305
- ---
306
-
307
- ## Installation
308
-
309
- ```sh
310
- npm install spotny-sdk
311
- # or
312
- yarn add spotny-sdk
313
- ```
314
-
315
- ### iOS
316
-
317
- ```sh
318
- cd ios && pod install
319
- ```
320
-
321
- Add the following keys to your `Info.plist`:
322
-
323
- ```xml
324
- <key>NSLocationWhenInUseUsageDescription</key>
325
- <string>We use your location to detect nearby points of interest.</string>
326
- <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
327
- <string>We use your location in the background to detect nearby points of interest.</string>
328
- <key>NSBluetoothAlwaysUsageDescription</key>
329
- <string>We use Bluetooth to detect nearby points of interest.</string>
330
- ```
331
-
332
- Enable **Background Modes** in Xcode → your app target → **Signing & Capabilities** → Background Modes:
333
-
334
- - ✅ Location updates
335
- - ✅ Uses Bluetooth LE accessories
336
-
337
- ### Android
338
-
339
- Add to `AndroidManifest.xml`:
340
-
341
- ```xml
342
- <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
343
- <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
344
- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
345
- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
346
- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
347
- ```
348
-
349
- ---
350
-
351
- ## Quick Start
352
-
353
- ```tsx
354
- import { useEffect } from 'react';
355
- import {
356
- initialize,
357
- startScanner,
358
- stopScanner,
359
- addBeaconsRangedListener,
360
- addBeaconRegionListener,
361
- } from 'spotny-sdk';
362
-
363
- export default function App() {
364
- useEffect(() => {
365
- // 1. Initialize once before anything else
366
- initialize({
367
- token: 'YOUR_SDK_TOKEN', // required — from your Spotny dashboard
368
- source: 'nike',
369
- maxDetectionDistance: 10,
370
- });
371
-
372
- // 2. Listen for nearby beacons
373
- const beaconSub = addBeaconsRangedListener(({ beacons, region }) => {
374
- beacons.forEach((b) =>
375
- console.log(
376
- `${b.major}/${b.minor} — ${b.distance.toFixed(1)}m (${b.proximity})`
377
- )
378
- );
379
- });
380
-
381
- // 3. Listen for region enter/exit
382
- const regionSub = addBeaconRegionListener(({ event, region }) => {
383
- console.log(`${event.toUpperCase()}: ${region}`);
384
- });
385
-
386
- // 4. Start scanning
387
- startScanner();
388
-
389
- return () => {
390
- stopScanner();
391
- beaconSub.remove();
392
- regionSub.remove();
393
- };
394
- }, []);
395
-
396
- return null;
397
- }
398
- ```
399
-
400
- ---
401
-
402
- ## API Reference
403
-
404
- ### Initialization
405
-
406
- #### `initialize(config)`
407
-
408
- **Must be called before any other SDK function.** Configures the SDK with your brand settings.
409
-
410
- | Option | Type | Default | Description |
411
- | -------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------- |
412
- | `token` | `string` | **required** | SDK token issued by Spotny for your app. Verified against the backend — rejects with `UNAUTHORIZED` if invalid. |
413
- | `source` | `string` | – | Your brand or app identifier (e.g. `'nike'`). Sent with every tracking event. |
414
- | `maxDetectionDistance` | `number` | `8.0` | Maximum detection radius in metres. Beacons beyond this are ignored. |
415
- | `distanceCorrectionFactor` | `number` | `0.5` | Multiplier applied to raw RSSI distance. Tune for your beacon TX power. |
416
-
417
- Returns `Promise<string>`. **Rejects** if the token is missing, invalid, or the network request fails — handle the error before calling `startScanner()`.
418
-
419
- ```ts
420
- await initialize({
421
- token: 'YOUR_SDK_TOKEN',
422
- source: 'nike',
423
- maxDetectionDistance: 10,
424
- distanceCorrectionFactor: 0.5,
425
- });
426
- ```
427
-
428
- ---
429
-
430
- ### Core Scanning
431
-
432
- #### `startScanner()`
433
-
434
- Starts iBeacon scanning. The SDK manages a fully anonymous `device_id` internally (stored in Keychain on iOS, SharedPreferences on Android — persists across launches).
435
-
436
- Returns `Promise<string>`.
437
-
438
- ```ts
439
- await startScanner();
440
- ```
441
-
442
- ---
443
-
444
- #### `stopScanner()`
445
-
446
- Stops scanning and cleans up all per-beacon state (sends `PROXIMITY_EXIT` events for any active beacons).
447
-
448
- Returns `Promise<string>`.
449
-
450
- ---
451
-
452
- #### `isScanning()`
453
-
454
- Returns `Promise<boolean>` — `true` if the scanner is currently active.
455
-
456
- ---
457
-
458
- ### Permissions
459
-
460
- #### `requestNotificationPermissions()` _(iOS only)_
461
-
462
- Prompts the user for local notification permission (`alert`, `sound`, `badge`).
463
-
464
- Returns `Promise<'granted' | 'denied'>`.
465
-
466
- ---
467
-
468
- ### Event Listeners
469
-
470
- #### `addBeaconsRangedListener(callback)`
471
-
472
- Fires when the set of nearby beacons changes, or every `debounceInterval` seconds (default 5 s) if nothing changed. The event is **deduplicated** — it does not fire every ranging cycle if proximity is unchanged.
473
-
474
- ```ts
475
- const sub = addBeaconsRangedListener(({ beacons, region }) => {
476
- beacons.forEach((b) => {
477
- console.log(`Major ${b.major} / Minor ${b.minor}`);
478
- console.log(`Distance: ${b.distance.toFixed(2)}m — ${b.proximity}`);
479
- console.log(`RSSI: ${b.rssi} dBm`);
480
- });
481
- });
482
-
483
- sub.remove(); // call on cleanup
484
- ```
485
-
486
- Each beacon in the array:
487
-
488
- | Field | Type | Description |
489
- | ----------- | -------- | -------------------------------------------------------------- |
490
- | `uuid` | `string` | Beacon proximity UUID |
491
- | `major` | `number` | Beacon major value |
492
- | `minor` | `number` | Beacon minor value |
493
- | `distance` | `number` | Estimated distance in metres (after correction factor applied) |
494
- | `rssi` | `number` | Raw signal strength in dBm |
495
- | `proximity` | `string` | `'immediate'` \| `'near'` \| `'far'` \| `'unknown'` |
496
-
497
- ---
498
-
499
- #### `addBeaconRegionListener(callback)`
500
-
501
- Fires when the user enters or exits a beacon region. Works in foreground, background, and terminated state (iOS).
502
-
503
- ```ts
504
- const sub = addBeaconRegionListener(({ event, region, state }) => {
505
- if (event === 'enter') console.log('Entered region', region);
506
- if (event === 'exit') console.log('Left region', region);
507
- if (event === 'determined') console.log('State for', region, '→', state);
508
- });
509
-
510
- sub.remove(); // call on cleanup
511
- ```
512
-
513
- | Field | Type | Description |
514
- | -------- | -------- | ------------------------------------------------------------------ |
515
- | `event` | `string` | `'enter'` \| `'exit'` \| `'determined'` |
516
- | `region` | `string` | Region identifier |
517
- | `state` | `string` | `'inside'` \| `'outside'` \| `'unknown'` (`determined` event only) |
518
-
519
- ---
520
-
521
- ### Debug Helpers
522
-
523
- #### `getDebugLogs()`
524
-
525
- Returns the on-device debug log file as a string. Includes campaign fetch results and proximity events sent.
526
-
527
- Returns `Promise<string>`.
528
-
529
- #### `clearDebugLogs()`
530
-
531
- Clears the on-device debug log file.
532
-
533
- Returns `Promise<string>`.
534
-
535
- #### `setDebounceInterval(seconds)`
536
-
537
- Sets how often the `onBeaconsRanged` event is force-emitted even if proximity hasn't changed (default: `5`).
538
-
539
- ```ts
540
- await setDebounceInterval(10); // emit at most every 10 s
541
- ```
542
-
543
- #### `clearDebounceCache()`
544
-
545
- Resets internal campaign-fetch cooldown state. Useful during testing.
546
-
547
- #### `getDebounceStatus()`
548
-
549
- Returns internal per-beacon timing state. Useful for debugging stuck campaigns.
550
-
551
- Returns `Promise<Object>`.
552
-
553
- ---
554
-
555
- ## How It Works
556
-
557
- 1. `initialize()` sets your brand config.
558
- 2. `startScanner()` begins monitoring for iBeacons with the Spotny UUID.
559
- 3. When a beacon is detected, the SDK fetches the associated campaign from the Spotny backend.
560
- 4. `NEARBY` events are sent automatically as the user moves closer or further.
561
- 5. `IMPRESSION_HEARTBEAT` events are sent every 10 s when the user is within 2 m of an active campaign.
562
- 6. On exit, `PROXIMITY_EXIT` is sent and all state is cleaned up.
563
-
564
- The SDK is **fully anonymous** — it generates a persistent `device_id` (Keychain on iOS, SharedPreferences on Android) that survives app reinstalls. No user identity is collected or sent.
565
-
566
- ---
567
-
568
- ## Platform Support
569
-
570
- | Feature | iOS | Android |
571
- | ------------------------ | --- | ------- |
572
- | iBeacon ranging | ✅ | ✅ |
573
- | Region enter/exit | ✅ | ✅ |
574
- | Background scanning | ✅ | ✅ |
575
- | Terminated-state wakeup | ✅ | ✅ |
576
- | Keychain device ID | ✅ | – |
577
- | Notification permissions | ✅ | – |
578
-
579
- ---
580
-
581
- ## Contributing
582
-
583
- - [Development workflow](CONTRIBUTING.md#development-workflow)
584
- - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
585
- - [Code of conduct](CODE_OF_CONDUCT.md)
586
-
587
- ## License
588
-
589
- MIT
@@ -58,7 +58,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
58
58
  private var sdkCredential: String? = null
59
59
 
60
60
  // ── Timing constants ──────────────────────────────────────────────────────
61
- private var campaignFetchCooldown = 5_000L // ms
62
61
  private var proximityDistanceThreshold = 0.75
63
62
  private var impressionEventInterval = 10_000L // ms
64
63
  private val impressionDistance = 2.0
@@ -69,8 +68,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
69
68
  private val lastProximityEventSent = mutableMapOf<String, Long>()
70
69
  private val lastProximityDistance = mutableMapOf<String, Double>()
71
70
  private val lastImpressionEventSent = mutableMapOf<String, Long>()
72
- private val lastCampaignFetchAttempt = mutableMapOf<String, Long>()
73
- private val fetchInProgress = mutableMapOf<String, Boolean>()
74
71
  private val proximityEventInProgress = mutableMapOf<String, Boolean>()
75
72
  private val impressionEventInProgress = mutableMapOf<String, Boolean>()
76
73
 
@@ -224,7 +221,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
224
221
  }
225
222
 
226
223
  override fun clearDebounceCache(promise: Promise) {
227
- lastCampaignFetchAttempt.clear(); fetchInProgress.clear()
228
224
  promise.resolve("Debounce cache cleared")
229
225
  }
230
226
 
@@ -233,8 +229,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
233
229
  val map = WritableNativeMap()
234
230
  for ((key, _) in activeCampaigns) {
235
231
  val entry = WritableNativeMap()
236
- lastCampaignFetchAttempt[key]?.let { entry.putDouble("lastFetchAttempt", it.toDouble()) }
237
- fetchInProgress[key]?.let { entry.putBoolean("fetchInProgress", it) }
238
232
  lastProximityEventSent[key]?.let { entry.putDouble("lastProximityEvent", it.toDouble()) }
239
233
  lastImpressionEventSent[key]?.let { entry.putDouble("lastImpressionEvent", it.toDouble()) }
240
234
  map.putMap(key, entry)
@@ -299,30 +293,31 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
299
293
 
300
294
  if (distance <= 0 || distance > maxDetectionDistance) continue
301
295
 
302
- if (activeCampaigns.containsKey(key)) {
303
- val isFirst = !lastProximityEventSent.containsKey(key)
304
- if (isFirst) {
296
+ val isFirst = !lastProximityEventSent.containsKey(key)
297
+
298
+ if (isFirst) {
299
+ // First detection — send proximity immediately
300
+ sendProximity("NEARBY", key, distance)
301
+ lastProximityDistance[key] = distance
302
+ lastProximityEventSent[key] = now
303
+ } else if (distance >= 1.0) {
304
+ val lastDist = lastProximityDistance[key] ?: 0.0
305
+ if (abs(distance - lastDist) >= proximityDistanceThreshold) {
306
+ // Distance changed significantly — send proximity update
305
307
  sendProximity("NEARBY", key, distance)
306
308
  lastProximityDistance[key] = distance
307
309
  lastProximityEventSent[key] = now
308
- } else if (distance >= 1.0) {
309
- val lastDist = lastProximityDistance[key] ?: 0.0
310
- if (abs(distance - lastDist) >= proximityDistanceThreshold) {
311
- sendProximity("NEARBY", key, distance)
312
- lastProximityDistance[key] = distance
313
- lastProximityEventSent[key] = now
314
- }
315
310
  }
316
- val campaign = activeCampaigns[key]
317
- if (campaign?.campaignId != null && campaign.inQueue == false && distance <= impressionDistance) {
318
- val lastImp = lastImpressionEventSent[key]
319
- if (lastImp == null || now - lastImp >= impressionEventInterval) {
320
- sendImpression(key, distance)
321
- lastImpressionEventSent[key] = now
322
- }
311
+ }
312
+
313
+ // Impression heartbeat when user is very close AND campaign exists
314
+ val campaign = activeCampaigns[key]
315
+ if (campaign?.campaignId != null && campaign.inQueue == false && distance <= impressionDistance) {
316
+ val lastImp = lastImpressionEventSent[key]
317
+ if (lastImp == null || now - lastImp >= impressionEventInterval) {
318
+ sendImpression(key, distance)
319
+ lastImpressionEventSent[key] = now
323
320
  }
324
- } else {
325
- fetchCampaign(major, minor)
326
321
  }
327
322
  }
328
323
  }
@@ -409,8 +404,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
409
404
  lastProximityEventSent.remove(key)
410
405
  lastProximityDistance.remove(key)
411
406
  lastImpressionEventSent.remove(key)
412
- lastCampaignFetchAttempt.remove(key)
413
- fetchInProgress.remove(key)
414
407
  proximityEventInProgress.remove(key)
415
408
  impressionEventInProgress.remove(key)
416
409
  Log.d(TAG, "Cleaned up state for beacon $key")
@@ -533,51 +526,6 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
533
526
  return sb.toString()
534
527
  }
535
528
 
536
- // ── Campaign fetching ─────────────────────────────────────────────────────
537
-
538
- private fun fetchCampaign(major: Int, minor: Int) {
539
- val key = beaconKey(major, minor)
540
- if (activeCampaigns.containsKey(key)) return
541
- if (fetchInProgress[key] == true) return
542
-
543
- val last = lastCampaignFetchAttempt[key]
544
- if (last != null && System.currentTimeMillis() - last < campaignFetchCooldown) return
545
-
546
- lastCampaignFetchAttempt[key] = System.currentTimeMillis()
547
- fetchInProgress[key] = true
548
-
549
- val payload = mutableMapOf<String, Any?>("beacon_id" to key)
550
-
551
- post("$apiBasePath/distribute", payload) { status, body ->
552
- fetchInProgress[key] = false
553
- if (status != 200) {
554
- Log.w(TAG, "Campaign fetch status $status for beacon $key")
555
- return@post
556
- }
557
- try {
558
- val json = parseJsonObject(body) ?: return@post
559
- val dataObj = json["data"] as? Map<*, *> ?: return@post
560
- val screen = dataObj["screen"] as? Map<*, *> ?: return@post
561
- val screenId = (screen["id"] as? Number)?.toInt() ?: return@post
562
-
563
- var campaignId: Int? = null
564
- var inQueue = false
565
- val campaignObj = dataObj["campaign"] as? Map<*, *>
566
- if (campaignObj != null) {
567
- campaignId = (campaignObj["id"] as? Number)?.toInt()
568
- inQueue = campaignObj["inQueue"] as? Boolean ?: false
569
- }
570
-
571
- activeCampaigns[key] = CampaignData(
572
- campaignId = campaignId, screenId = screenId,
573
- sessionId = null, inQueue = inQueue, major = major, minor = minor)
574
- Log.d(TAG, "Campaign loaded for beacon $key — screenId=$screenId")
575
- } catch (e: Exception) {
576
- Log.e(TAG, "Campaign JSON parse error for $key: ${e.message}")
577
- }
578
- }
579
- }
580
-
581
529
  // ── Tracking events ───────────────────────────────────────────────────────
582
530
 
583
531
  private fun sendTracking(
@@ -593,38 +541,88 @@ class SpotnySdkModule(private val reactContext: ReactApplicationContext) :
593
541
  if (isImpression) impressionEventInProgress[key] = true
594
542
  else proximityEventInProgress[key] = true
595
543
 
596
- val campaign = activeCampaigns[key]
597
- if (campaign == null) {
598
- if (isImpression) impressionEventInProgress[key] = false
599
- else proximityEventInProgress[key] = false
600
- return
601
- }
602
- if (isImpression && (campaign.campaignId == null || campaign.inQueue)) {
603
- impressionEventInProgress[key] = false; return
604
- }
605
-
544
+ // Build payload — proximity events use beacon_id, impressions use screen_id
606
545
  val payload = mutableMapOf<String, Any?>(
607
546
  "event_type" to eventType,
608
- "distance" to distance,
609
- "screen_id" to campaign.screenId
547
+ "distance" to distance
610
548
  )
611
- campaign.campaignId?.let { payload["campaign_id"] = it }
612
- campaign.sessionId?.let { payload["session_id"] = it }
549
+
550
+ if (isImpression) {
551
+ // Impressions require campaign to exist
552
+ val campaign = activeCampaigns[key]
553
+ if (campaign == null || campaign.campaignId == null || campaign.inQueue) {
554
+ impressionEventInProgress[key] = false
555
+ return
556
+ }
557
+ payload["screen_id"] = campaign.screenId
558
+ campaign.campaignId?.let { payload["campaign_id"] = it }
559
+ campaign.sessionId?.let { payload["session_id"] = it }
560
+ } else {
561
+ // Proximity events send beacon_id — backend resolves screen
562
+ payload["beacon_id"] = key
563
+ // Also send screen/campaign/session if we already have them (after first proximity)
564
+ activeCampaigns[key]?.let { campaign ->
565
+ payload["screen_id"] = campaign.screenId
566
+ campaign.campaignId?.let { payload["campaign_id"] = it }
567
+ campaign.sessionId?.let { payload["session_id"] = it }
568
+ }
569
+ }
613
570
 
614
571
  post(endpoint, payload) { status, body ->
615
572
  if (status in 200..299) {
616
573
  Log.d(TAG, "$eventType sent — distance ${"%.2f".format(distance)}m")
617
- if (!isImpression && campaign.sessionId == null) {
574
+
575
+ // Parse screen_id, campaign_id, session_id from proximity response
576
+ if (!isImpression) {
618
577
  try {
619
578
  val json = parseJsonObject(body)
620
- val sid = ((json?.get("data") as? Map<*, *>)
621
- ?.get("event") as? Map<*, *>)
622
- ?.get("session_id") as? String
623
- if (sid != null) {
624
- activeCampaigns[key] = campaign.copy(sessionId = sid)
625
- Log.d(TAG, "session_id = $sid")
579
+ val dataObj = json?.get("data") as? Map<*, *>
580
+
581
+ var screenId: Int? = null
582
+ var campaignId: Int? = null
583
+ var sessionId: String? = null
584
+ var inQueue = false
585
+
586
+ // Extract from event object (contains session_id)
587
+ val eventObj = dataObj?.get("event") as? Map<*, *>
588
+ if (eventObj != null) {
589
+ sessionId = eventObj["session_id"] as? String
590
+ screenId = (eventObj["screen_id"] as? Number)?.toInt()
626
591
  }
627
- } catch (_: Exception) {}
592
+
593
+ // Extract from screen object
594
+ if (screenId == null) {
595
+ val screenObj = dataObj?.get("screen") as? Map<*, *>
596
+ screenId = (screenObj?.get("id") as? Number)?.toInt()
597
+ }
598
+
599
+ // Extract from campaign object
600
+ val campaignObj = dataObj?.get("campaign") as? Map<*, *>
601
+ if (campaignObj != null) {
602
+ campaignId = (campaignObj["id"] as? Number)?.toInt()
603
+ inQueue = campaignObj["inQueue"] as? Boolean ?: false
604
+ }
605
+
606
+ // Store campaign data if we got screen_id
607
+ if (screenId != null) {
608
+ // Parse major/minor from key
609
+ val parts = key.split("_")
610
+ val major = parts.getOrNull(0)?.toIntOrNull() ?: 0
611
+ val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0
612
+
613
+ activeCampaigns[key] = CampaignData(
614
+ campaignId = campaignId,
615
+ screenId = screenId,
616
+ sessionId = sessionId,
617
+ inQueue = inQueue,
618
+ major = major,
619
+ minor = minor
620
+ )
621
+ Log.d(TAG, "Stored campaign — screenId=$screenId, campaignId=$campaignId, sessionId=$sessionId")
622
+ }
623
+ } catch (e: Exception) {
624
+ Log.w(TAG, "Error parsing proximity response: ${e.message}")
625
+ }
628
626
  }
629
627
  } else if (status == 429) {
630
628
  val penalty = System.currentTimeMillis() + 10_000L
@@ -81,7 +81,6 @@ public class SpotnyBeaconScanner: NSObject {
81
81
  private let beaconUUID = UUID(uuidString: "f7826da6-4fa2-4e98-8024-bc5b71e0893e")!
82
82
 
83
83
  // ── Timing constants ──────────────────────────────────────────────────────
84
- private let campaignFetchCooldown: TimeInterval = 5.0
85
84
  private let proximityDistanceThreshold: Double = 0.75
86
85
  private let impressionEventInterval: TimeInterval = 10.0
87
86
  private let impressionDistance: Double = 2.0
@@ -92,9 +91,6 @@ public class SpotnyBeaconScanner: NSObject {
92
91
  private var lastProximityEventSent: [String: Date] = [:]
93
92
  private var lastProximityDistance: [String: Double] = [:]
94
93
  private var lastImpressionEventSent: [String: Date] = [:]
95
- private var lastCampaignFetchAttempt: [String: Date] = [:]
96
- private var fetchInProgress: [String: Bool] = [:]
97
- private var fetchRetryCount: [String: Int] = [:]
98
94
  private var proximityEventInProgress: [String: Bool] = [:]
99
95
  private var impressionEventInProgress: [String: Bool] = [:]
100
96
 
@@ -313,9 +309,6 @@ public class SpotnyBeaconScanner: NSObject {
313
309
  withResolve resolve: @escaping (Any?) -> Void,
314
310
  reject: @escaping (String?, String?, Error?) -> Void
315
311
  ) {
316
- lastCampaignFetchAttempt.removeAll()
317
- fetchInProgress.removeAll()
318
- fetchRetryCount.removeAll()
319
312
  resolve("Debounce cache cleared")
320
313
  }
321
314
 
@@ -329,12 +322,6 @@ public class SpotnyBeaconScanner: NSObject {
329
322
  var status: [String: Any] = [:]
330
323
  for (key, _) in activeCampaigns {
331
324
  var entry: [String: Any] = [:]
332
- if let lastFetch = lastCampaignFetchAttempt[key] {
333
- entry["lastFetchAttempt"] = lastFetch.timeIntervalSince1970
334
- }
335
- if let inProg = fetchInProgress[key] {
336
- entry["fetchInProgress"] = inProg
337
- }
338
325
  if let lastProx = lastProximityEventSent[key] {
339
326
  entry["lastProximityEvent"] = lastProx.timeIntervalSince1970
340
327
  }
@@ -592,70 +579,6 @@ public class SpotnyBeaconScanner: NSObject {
592
579
  }.resume()
593
580
  }
594
581
 
595
- // MARK: - Campaign Fetching
596
-
597
- /// Exponential cooldown: 5 s → 15 s → 45 s. After 3 failures the key is
598
- /// considered permanently failed until the beacon region is re-entered.
599
- private func fetchCooldown(for key: String) -> TimeInterval {
600
- let retries = fetchRetryCount[key] ?? 0
601
- return campaignFetchCooldown * pow(3.0, Double(min(retries, 3)))
602
- }
603
-
604
- private func fetchCampaign(major: Int, minor: Int) {
605
- let key = beaconKey(major: major, minor: minor)
606
- guard activeCampaigns[key] == nil else { return }
607
- guard fetchInProgress[key] != true else { return }
608
- // Give up after 3 consecutive failures (cooldown would be 135 s+)
609
- if (fetchRetryCount[key] ?? 0) > 3 { return }
610
-
611
- if let last = lastCampaignFetchAttempt[key],
612
- Date().timeIntervalSince(last) < fetchCooldown(for: key) { return }
613
-
614
- lastCampaignFetchAttempt[key] = Date()
615
- fetchInProgress[key] = true
616
-
617
- let payload: [String: Any] = ["beacon_id": key]
618
-
619
- post(endpoint: "\(apiBasePath)/distribute", payload: payload) { [weak self] result in
620
- guard let self = self else { return }
621
- defer { self.fetchInProgress[key] = false }
622
-
623
- guard case .success(let (status, data)) = result, status == 200 else {
624
- if case .success(let (s, _)) = result {
625
- print("❌ SpotnySDK: Campaign fetch status \(s) for beacon \(key)")
626
- }
627
- // Increment retry count so next attempt uses a longer cooldown
628
- self.fetchRetryCount[key] = (self.fetchRetryCount[key] ?? 0) + 1
629
- logToFile("❌ Campaign fetch failed for beacon \(key) (retry \(self.fetchRetryCount[key] ?? 0))")
630
- return
631
- }
632
- do {
633
- guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
634
- let dataObj = json["data"] as? [String: Any],
635
- let screen = dataObj["screen"] as? [String: Any],
636
- let screenId = screen["id"] as? Int else {
637
- print("⚠️ SpotnySDK: Unexpected campaign response format for \(key)")
638
- return
639
- }
640
- var campaignId: Int?
641
- var inQueue = false
642
- if let campaignObj = dataObj["campaign"] as? [String: Any],
643
- let cid = campaignObj["id"] as? Int {
644
- campaignId = cid
645
- inQueue = campaignObj["inQueue"] as? Bool ?? false
646
- }
647
- self.activeCampaigns[key] = CampaignData(
648
- campaignId: campaignId, screenId: screenId,
649
- sessionId: nil, inQueue: inQueue, major: major, minor: minor)
650
- self.fetchRetryCount.removeValue(forKey: key) // reset on success
651
- logToFile("✅ Campaign loaded for beacon \(key) — screenId=\(screenId)")
652
- print("✅ SpotnySDK: Campaign loaded for beacon \(key) — screenId=\(screenId)")
653
- } catch {
654
- print("❌ SpotnySDK: JSON parse error for beacon \(key): \(error)")
655
- }
656
- }
657
- }
658
-
659
582
  // MARK: - Tracking
660
583
 
661
584
  private func sendTracking(
@@ -671,24 +594,33 @@ public class SpotnyBeaconScanner: NSObject {
671
594
  if isImpression { impressionEventInProgress[key] = true }
672
595
  else { proximityEventInProgress[key] = true }
673
596
 
674
- guard let campaign = activeCampaigns[key] else {
675
- if isImpression { impressionEventInProgress[key] = false }
676
- else { proximityEventInProgress[key] = false }
677
- return
678
- }
679
- if isImpression {
680
- guard let _ = campaign.campaignId, !campaign.inQueue else {
681
- impressionEventInProgress[key] = false; return
682
- }
683
- }
684
-
597
+ // Build payload proximity events use beacon_id, impressions use screen_id
685
598
  var payload: [String: Any] = [
686
599
  "event_type": eventType,
687
- "distance": distance,
688
- "screen_id": campaign.screenId
600
+ "distance": distance
689
601
  ]
690
- if let cid = campaign.campaignId { payload["campaign_id"] = cid }
691
- if let sid = campaign.sessionId { payload["session_id"] = sid }
602
+
603
+ if isImpression {
604
+ // Impressions require campaign to exist
605
+ guard let campaign = activeCampaigns[key],
606
+ let _ = campaign.campaignId,
607
+ !campaign.inQueue else {
608
+ impressionEventInProgress[key] = false
609
+ return
610
+ }
611
+ payload["screen_id"] = campaign.screenId
612
+ if let cid = campaign.campaignId { payload["campaign_id"] = cid }
613
+ if let sid = campaign.sessionId { payload["session_id"] = sid }
614
+ } else {
615
+ // Proximity events send beacon_id — backend resolves screen
616
+ payload["beacon_id"] = key
617
+ // Also send screen/campaign/session if we already have them (after first proximity)
618
+ if let campaign = activeCampaigns[key] {
619
+ payload["screen_id"] = campaign.screenId
620
+ if let cid = campaign.campaignId { payload["campaign_id"] = cid }
621
+ if let sid = campaign.sessionId { payload["session_id"] = sid }
622
+ }
623
+ }
692
624
 
693
625
  post(endpoint: endpoint, payload: payload) { [weak self] result in
694
626
  guard let self = self else { return }
@@ -697,17 +629,52 @@ public class SpotnyBeaconScanner: NSObject {
697
629
  if 200...299 ~= status {
698
630
  print("✅ SpotnySDK: \(eventType) sent — distance \(String(format: "%.2f", distance))m")
699
631
  logToFile("✅ \(eventType) sent — beacon \(key) @ \(String(format: "%.2f", distance))m")
700
- if !isImpression, campaign.sessionId == nil,
632
+
633
+ // Parse screen_id, campaign_id, session_id from proximity response
634
+ if !isImpression,
701
635
  let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
702
- let dObj = json["data"] as? [String: Any],
703
- let ev = dObj["event"] as? [String: Any],
704
- let sid = ev["session_id"] as? String {
705
- let updated = CampaignData(
706
- campaignId: campaign.campaignId, screenId: campaign.screenId,
707
- sessionId: sid, inQueue: campaign.inQueue,
708
- major: campaign.major, minor: campaign.minor)
709
- self.activeCampaigns[key] = updated
710
- print("✅ SpotnySDK: session_id = \(sid)")
636
+ let dObj = json["data"] as? [String: Any] {
637
+
638
+ var screenId: Int?
639
+ var campaignId: Int?
640
+ var sessionId: String?
641
+ var inQueue = false
642
+
643
+ // Extract from event object (contains session_id)
644
+ if let ev = dObj["event"] as? [String: Any] {
645
+ if let sid = ev["session_id"] as? String { sessionId = sid }
646
+ if let scId = ev["screen_id"] as? Int { screenId = scId }
647
+ }
648
+
649
+ // Extract from screen object
650
+ if screenId == nil, let screen = dObj["screen"] as? [String: Any] {
651
+ screenId = screen["id"] as? Int
652
+ }
653
+
654
+ // Extract from campaign object
655
+ if let campaignObj = dObj["campaign"] as? [String: Any] {
656
+ campaignId = campaignObj["id"] as? Int
657
+ inQueue = campaignObj["inQueue"] as? Bool ?? false
658
+ }
659
+
660
+ // Store campaign data if we got screen_id
661
+ if let scId = screenId {
662
+ // Parse major/minor from key
663
+ let parts = key.components(separatedBy: "_")
664
+ let major = parts.count > 0 ? Int(parts[0]) ?? 0 : 0
665
+ let minor = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
666
+
667
+ let updated = CampaignData(
668
+ campaignId: campaignId,
669
+ screenId: scId,
670
+ sessionId: sessionId,
671
+ inQueue: inQueue,
672
+ major: major,
673
+ minor: minor
674
+ )
675
+ self.activeCampaigns[key] = updated
676
+ print("✅ SpotnySDK: Stored campaign — screenId=\(scId), campaignId=\(campaignId ?? 0), sessionId=\(sessionId ?? "nil")")
677
+ }
711
678
  }
712
679
  } else if status == 429 {
713
680
  let penalty = Date().addingTimeInterval(10)
@@ -743,9 +710,6 @@ public class SpotnyBeaconScanner: NSObject {
743
710
  lastProximityEventSent.removeValue(forKey: key)
744
711
  lastProximityDistance.removeValue(forKey: key)
745
712
  lastImpressionEventSent.removeValue(forKey: key)
746
- lastCampaignFetchAttempt.removeValue(forKey: key)
747
- fetchInProgress.removeValue(forKey: key)
748
- fetchRetryCount.removeValue(forKey: key)
749
713
  proximityEventInProgress.removeValue(forKey: key)
750
714
  impressionEventInProgress.removeValue(forKey: key)
751
715
  print("🧹 SpotnySDK: Cleaned up state for beacon \(key)")
@@ -835,35 +799,36 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
835
799
 
836
800
  guard distance > 0 && distance <= maxDetectionDistance else { continue }
837
801
 
838
- if let campaign = activeCampaigns[key] {
839
- let isFirst = lastProximityEventSent[key] == nil
840
-
841
- if isFirst {
842
- sendProximity(eventType: "NEARBY", key: key, distance: distance)
843
- lastProximityDistance[key] = distance
844
- lastProximityEventSent[key] = now
845
- } else if distance >= 1.0,
846
- let lastDist = lastProximityDistance[key],
847
- abs(distance - lastDist) >= proximityDistanceThreshold {
848
- sendProximity(eventType: "NEARBY", key: key, distance: distance)
849
- lastProximityDistance[key] = distance
850
- lastProximityEventSent[key] = now
851
- }
802
+ let isFirst = lastProximityEventSent[key] == nil
803
+
804
+ if isFirst {
805
+ // First detection — send proximity immediately
806
+ sendProximity(eventType: "NEARBY", key: key, distance: distance)
807
+ lastProximityDistance[key] = distance
808
+ lastProximityEventSent[key] = now
809
+ } else if distance >= 1.0,
810
+ let lastDist = lastProximityDistance[key],
811
+ abs(distance - lastDist) >= proximityDistanceThreshold {
812
+ // Distance changed significantly send proximity update
813
+ sendProximity(eventType: "NEARBY", key: key, distance: distance)
814
+ lastProximityDistance[key] = distance
815
+ lastProximityEventSent[key] = now
816
+ }
852
817
 
853
- // Impression heartbeat when user is very close
854
- if let _ = campaign.campaignId, !campaign.inQueue, distance <= impressionDistance {
855
- if let last = lastImpressionEventSent[key] {
856
- if now.timeIntervalSince(last) >= impressionEventInterval {
857
- sendImpression(key: key, distance: distance)
858
- lastImpressionEventSent[key] = now
859
- }
860
- } else {
818
+ // Impression heartbeat when user is very close AND campaign exists
819
+ if let campaign = activeCampaigns[key],
820
+ let _ = campaign.campaignId,
821
+ !campaign.inQueue,
822
+ distance <= impressionDistance {
823
+ if let last = lastImpressionEventSent[key] {
824
+ if now.timeIntervalSince(last) >= impressionEventInterval {
861
825
  sendImpression(key: key, distance: distance)
862
826
  lastImpressionEventSent[key] = now
863
827
  }
828
+ } else {
829
+ sendImpression(key: key, distance: distance)
830
+ lastImpressionEventSent[key] = now
864
831
  }
865
- } else {
866
- fetchCampaign(major: major, minor: minor)
867
832
  }
868
833
  }
869
834
  }
@@ -879,18 +844,6 @@ extension SpotnyBeaconScanner: KTKBeaconManagerDelegate {
879
844
  if bg != .invalid { UIApplication.shared.endBackgroundTask(bg); bg = .invalid }
880
845
  }
881
846
  }
882
-
883
- // Parse major/minor from named regions (e.g. "SpotnySDK_52885_35127")
884
- let parts = region.identifier.components(separatedBy: "_")
885
- if parts.count == 3, let major = Int(parts[1]), let minor = Int(parts[2]) {
886
- let key = beaconKey(major: major, minor: minor)
887
- // Reset fetch state on re-entry so retry backoff starts fresh
888
- lastCampaignFetchAttempt.removeValue(forKey: key)
889
- fetchRetryCount.removeValue(forKey: key)
890
- if activeCampaigns[key] == nil {
891
- fetchCampaign(major: major, minor: minor)
892
- }
893
- }
894
847
  }
895
848
 
896
849
  public func beaconManager(_ manager: KTKBeaconManager, didExitRegion region: KTKBeaconRegion) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spotny-sdk",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Beacon Scanner",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",