@sendoracloud/sdk-react-native 1.5.0 → 1.6.1

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/dist/index.cjs CHANGED
@@ -1760,15 +1760,16 @@ var import_react_native3 = require("react-native");
1760
1760
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
1761
1761
  function resolveFlags(cfg) {
1762
1762
  if (cfg === false) {
1763
- return { appOpen: false, sessionStart: false, appBackground: false };
1763
+ return { appOpen: false, sessionStart: false, appBackground: false, engagement: false };
1764
1764
  }
1765
1765
  if (cfg === true || cfg === void 0) {
1766
- return { appOpen: true, sessionStart: true, appBackground: true };
1766
+ return { appOpen: true, sessionStart: true, appBackground: true, engagement: true };
1767
1767
  }
1768
1768
  return {
1769
1769
  appOpen: cfg.appOpen ?? true,
1770
1770
  sessionStart: cfg.sessionStart ?? true,
1771
- appBackground: cfg.appBackground ?? true
1771
+ appBackground: cfg.appBackground ?? true,
1772
+ engagement: cfg.engagement ?? true
1772
1773
  };
1773
1774
  }
1774
1775
  function installAutoTrack(args) {
@@ -1799,15 +1800,21 @@ function installAutoTrack(args) {
1799
1800
  if (flags.appOpen) {
1800
1801
  args.fire("app.opened", { sessionId });
1801
1802
  }
1802
- if (flags.appBackground) {
1803
+ if (flags.appBackground || flags.engagement) {
1803
1804
  const AppState = import_react_native3.AppState;
1804
1805
  if (AppState && typeof AppState.addEventListener === "function") {
1805
1806
  const handler = (state) => {
1806
1807
  ensureSession(true);
1807
1808
  if (state === "background" || state === "inactive") {
1808
- args.fire("app.backgrounded", { sessionId: args.getSessionId() });
1809
+ if (flags.engagement) args.onForeground?.(false);
1810
+ if (flags.appBackground) {
1811
+ args.fire("app.backgrounded", { sessionId: args.getSessionId() });
1812
+ }
1809
1813
  } else if (state === "active") {
1810
- args.fire("app.foregrounded", { sessionId: args.getSessionId() });
1814
+ if (flags.appBackground) {
1815
+ args.fire("app.foregrounded", { sessionId: args.getSessionId() });
1816
+ }
1817
+ if (flags.engagement) args.onForeground?.(true);
1811
1818
  }
1812
1819
  };
1813
1820
  const sub = AppState.addEventListener("change", handler);
@@ -1848,6 +1855,8 @@ var MAX_PUSH_METADATA_BYTES = 4096;
1848
1855
  var MAX_USER_ID_LEN = 256;
1849
1856
  var MAX_TRAITS_BYTES = 32 * 1024;
1850
1857
  var MAX_PROPERTIES_BYTES = 32 * 1024;
1858
+ var MIN_ENGAGEMENT_MS = 250;
1859
+ var MAX_ENGAGEMENT_MS = 6 * 60 * 60 * 1e3;
1851
1860
  function assertCSPRNG() {
1852
1861
  const g = globalThis;
1853
1862
  if (g.crypto?.getRandomValues) {
@@ -1898,6 +1907,12 @@ var SendoraSDK = class {
1898
1907
  this.debug = false;
1899
1908
  this.sessionId = "";
1900
1909
  this.autoTrackCleanup = null;
1910
+ // --- Engagement time (foreground-only, Wave 75) ---
1911
+ this.engEnabled = false;
1912
+ this.engScreen = null;
1913
+ this.engAccumMs = 0;
1914
+ // >0 = accumulating since this timestamp; 0 = paused (backgrounded).
1915
+ this.engSegmentStart = 0;
1901
1916
  /**
1902
1917
  * Lazy-allocated stable session id for chatbot conversations.
1903
1918
  * Survives only the JS lifetime — chatbot multi-turn context lives
@@ -1988,12 +2003,17 @@ var SendoraSDK = class {
1988
2003
  });
1989
2004
  this.ready = true;
1990
2005
  if (config.autoTrack !== false) {
2006
+ this.engEnabled = config.autoTrack === true || config.autoTrack === void 0 || config.autoTrack.engagement !== false;
1991
2007
  this.autoTrackCleanup = installAutoTrack({
1992
2008
  config: this.config,
1993
2009
  fire: (eventType, properties) => this.track(eventType, properties),
1994
2010
  getSessionId: () => this.sessionId,
1995
2011
  setSessionId: (id) => {
1996
2012
  this.sessionId = id;
2013
+ },
2014
+ onForeground: (active) => {
2015
+ if (active) this.engResume();
2016
+ else this.engFlush();
1997
2017
  }
1998
2018
  });
1999
2019
  }
@@ -2057,10 +2077,53 @@ var SendoraSDK = class {
2057
2077
  if (typeof name !== "string" || name.length === 0 || name.length > 256) {
2058
2078
  throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
2059
2079
  }
2080
+ this.engFlush();
2060
2081
  void this.sendEvent("screen", {
2061
2082
  eventType: "screen.viewed",
2062
2083
  properties: { screenName: name, ...properties ?? {} }
2063
2084
  });
2085
+ this.engEnter(name);
2086
+ }
2087
+ // --- Engagement timer (foreground-only, Wave 75) ---
2088
+ /** Bank the in-flight foreground segment into the accumulator + pause. */
2089
+ engBank() {
2090
+ if (this.engSegmentStart > 0) {
2091
+ this.engAccumMs += Date.now() - this.engSegmentStart;
2092
+ this.engSegmentStart = 0;
2093
+ }
2094
+ }
2095
+ /**
2096
+ * Emit `app.engagement` for the current screen with foreground-only
2097
+ * durationMs, then reset the accumulator. Sub-threshold (redirect
2098
+ * flicker) spans are dropped; absurd spans clamp to a 6h ceiling.
2099
+ * Never throws — engagement is best-effort instrumentation.
2100
+ */
2101
+ engFlush() {
2102
+ if (!this.engEnabled || this.engScreen === null) return;
2103
+ this.engBank();
2104
+ let ms = this.engAccumMs;
2105
+ this.engAccumMs = 0;
2106
+ if (ms < MIN_ENGAGEMENT_MS) return;
2107
+ if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
2108
+ try {
2109
+ void this.sendEvent("track", {
2110
+ eventType: "app.engagement",
2111
+ properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
2112
+ });
2113
+ } catch {
2114
+ }
2115
+ }
2116
+ /** Start measuring a new screen (foreground from now). */
2117
+ engEnter(name) {
2118
+ if (!this.engEnabled) return;
2119
+ this.engScreen = name;
2120
+ this.engAccumMs = 0;
2121
+ this.engSegmentStart = Date.now();
2122
+ }
2123
+ /** Resume accumulation after the app returns to foreground. */
2124
+ engResume() {
2125
+ if (!this.engEnabled || this.engScreen === null) return;
2126
+ if (this.engSegmentStart === 0) this.engSegmentStart = Date.now();
2064
2127
  }
2065
2128
  /**
2066
2129
  * Register a device push token (APNs or FCM) so server-side
@@ -2086,7 +2149,13 @@ var SendoraSDK = class {
2086
2149
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
2087
2150
  "/api/v1/push/tokens",
2088
2151
  {
2089
- userId: this.userId,
2152
+ // Conditional spread — the backend's registerPushTokenSchema makes
2153
+ // userId `.optional()`, which accepts a missing key but REJECTS
2154
+ // `null` (422 "userId: Expected string, received null"). Anonymous
2155
+ // installs (pre-identify()) carry a null userId, so OMIT the field
2156
+ // rather than send null — anon devices must still be able to
2157
+ // register. Mirrors the same conditional-spread sendEvent() uses.
2158
+ ...this.userId ? { userId: this.userId } : {},
2090
2159
  anonymousId: this.anonId,
2091
2160
  token: reg.token,
2092
2161
  platform: reg.platform,
@@ -2128,7 +2197,7 @@ var SendoraSDK = class {
2128
2197
  if (clickAction !== void 0) body.clickAction = clickAction;
2129
2198
  await post(
2130
2199
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2131
- "/push/track-open",
2200
+ "/api/v1/push/track-open",
2132
2201
  body
2133
2202
  );
2134
2203
  },
@@ -2147,7 +2216,7 @@ var SendoraSDK = class {
2147
2216
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.liveActivities.startToken().");
2148
2217
  const res = await post(
2149
2218
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2150
- "/push/live-activities/start-token",
2219
+ "/api/v1/push/live-activities/start-token",
2151
2220
  {
2152
2221
  pushToken: input.pushToken,
2153
2222
  activityType: input.activityType,
@@ -2181,7 +2250,7 @@ var SendoraSDK = class {
2181
2250
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.geofences.list().");
2182
2251
  const res = await getJson(
2183
2252
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2184
- "/push/geofences/list-for-device"
2253
+ "/api/v1/push/geofences/list-for-device"
2185
2254
  );
2186
2255
  if (!res || !res.ok) {
2187
2256
  throw new Error(`[sendoracloud] geofences list failed (HTTP ${res?.status ?? "network"}).`);
@@ -2199,7 +2268,7 @@ var SendoraSDK = class {
2199
2268
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.geofences.recordEvent().");
2200
2269
  const res = await post(
2201
2270
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2202
- "/push/geofences/event",
2271
+ "/api/v1/push/geofences/event",
2203
2272
  {
2204
2273
  geofenceId: input.geofenceId,
2205
2274
  kind: input.kind,
@@ -2243,7 +2312,7 @@ var SendoraSDK = class {
2243
2312
  if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.reportInstall().");
2244
2313
  const res = await post(
2245
2314
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2246
- "/attribution/install",
2315
+ "/api/v1/attribution/install",
2247
2316
  input ?? {}
2248
2317
  );
2249
2318
  if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.reportInstall failed (${res?.status ?? "network"})`);
@@ -2255,7 +2324,7 @@ var SendoraSDK = class {
2255
2324
  if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.checkDeferred().");
2256
2325
  const res = await post(
2257
2326
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2258
- "/attribution/deferred",
2327
+ "/api/v1/attribution/deferred",
2259
2328
  input ?? {}
2260
2329
  );
2261
2330
  if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.checkDeferred failed (${res?.status ?? "network"})`);
@@ -2289,7 +2358,7 @@ var SendoraSDK = class {
2289
2358
  if (!self.config) throw new Error("[sendoracloud] init() must complete before support.createTicket().");
2290
2359
  const res = await post(
2291
2360
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2292
- "/support/tickets",
2361
+ "/api/v1/support/tickets",
2293
2362
  input
2294
2363
  );
2295
2364
  if (!res || !res.ok) throw new Error(`[sendoracloud] support.createTicket failed (${res?.status ?? "network"})`);
@@ -2301,7 +2370,7 @@ var SendoraSDK = class {
2301
2370
  if (!self.config) throw new Error("[sendoracloud] init() must complete before support.submitCsat().");
2302
2371
  const res = await post(
2303
2372
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2304
- "/support/csat",
2373
+ "/api/v1/support/csat",
2305
2374
  input
2306
2375
  );
2307
2376
  if (!res || !res.ok) throw new Error(`[sendoracloud] support.submitCsat failed (${res?.status ?? "network"})`);
@@ -2390,7 +2459,7 @@ var SendoraSDK = class {
2390
2459
  if (!self.config) throw new Error("[sendoracloud] init() must complete before consent.record().");
2391
2460
  const res = await post(
2392
2461
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2393
- "/consent/record",
2462
+ "/api/v1/consent/record",
2394
2463
  input
2395
2464
  );
2396
2465
  if (!res || !res.ok) throw new Error(`[sendoracloud] consent.record failed (${res?.status ?? "network"})`);
@@ -2402,7 +2471,7 @@ var SendoraSDK = class {
2402
2471
  if (!self.config) throw new Error("[sendoracloud] init() must complete before consent.requestDeletion().");
2403
2472
  const res = await post(
2404
2473
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2405
- "/consent/deletion-request",
2474
+ "/api/v1/consent/deletion-request",
2406
2475
  input
2407
2476
  );
2408
2477
  if (!res || !res.ok) throw new Error(`[sendoracloud] consent.requestDeletion failed (${res?.status ?? "network"})`);
@@ -2453,7 +2522,7 @@ var SendoraSDK = class {
2453
2522
  if (!self.config) throw new Error("[sendoracloud] init() must complete before surveys.submitResponse().");
2454
2523
  const res = await post(
2455
2524
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2456
- "/surveys/responses",
2525
+ "/api/v1/surveys/responses",
2457
2526
  { completed: true, ...input }
2458
2527
  );
2459
2528
  if (!res || !res.ok) throw new Error(`[sendoracloud] surveys.submitResponse failed (${res?.status ?? "network"})`);
@@ -2503,7 +2572,7 @@ var SendoraSDK = class {
2503
2572
  if (!self.config) throw new Error("[sendoracloud] init() must complete before messages.recordImpression().");
2504
2573
  const res = await post(
2505
2574
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2506
- "/in-app-messages/impressions",
2575
+ "/api/v1/in-app-messages/impressions",
2507
2576
  input
2508
2577
  );
2509
2578
  if (!res || !res.ok) throw new Error(`[sendoracloud] messages.recordImpression failed (${res?.status ?? "network"})`);
@@ -2541,7 +2610,7 @@ var SendoraSDK = class {
2541
2610
  };
2542
2611
  const res = await post(
2543
2612
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2544
- "/feature-flags/evaluate",
2613
+ "/api/v1/feature-flags/evaluate",
2545
2614
  body
2546
2615
  );
2547
2616
  if (!res || !res.ok) throw new Error(`[sendoracloud] flags.evaluate failed (${res?.status ?? "network"})`);
@@ -2558,7 +2627,7 @@ var SendoraSDK = class {
2558
2627
  };
2559
2628
  const res = await post(
2560
2629
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2561
- "/feature-flags/evaluate-all",
2630
+ "/api/v1/feature-flags/evaluate-all",
2562
2631
  body
2563
2632
  );
2564
2633
  if (!res || !res.ok) throw new Error(`[sendoracloud] flags.evaluateAll failed (${res?.status ?? "network"})`);
@@ -2593,7 +2662,7 @@ var SendoraSDK = class {
2593
2662
  };
2594
2663
  const res = await post(
2595
2664
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2596
- "/chatbot/message",
2665
+ "/api/v1/chatbot/message",
2597
2666
  body
2598
2667
  );
2599
2668
  if (!res || !res.ok) throw new Error(`[sendoracloud] chatbot.sendMessage failed (${res?.status ?? "network"})`);
@@ -2621,6 +2690,7 @@ var SendoraSDK = class {
2621
2690
  }
2622
2691
  /** Tear down auto-track listeners. Call in test harnesses. */
2623
2692
  destroy() {
2693
+ this.engFlush();
2624
2694
  if (this.autoTrackCleanup) {
2625
2695
  this.autoTrackCleanup();
2626
2696
  this.autoTrackCleanup = null;
package/dist/index.d.cts CHANGED
@@ -728,6 +728,10 @@ interface SendoraConfig {
728
728
  * (idle >`sessionIdleMs` closes session). Default true.
729
729
  * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
730
730
  * AppState transitions. Default true.
731
+ * - `engagement` — emit `app.engagement` with foreground-only `durationMs`
732
+ * per screen on the next `screen()` call + on app-background (GA4
733
+ * user_engagement parity). Default true. Only measures named screens —
734
+ * it has no effect until you start calling `screen()`.
731
735
  *
732
736
  * Note: screen view tracking is NOT auto — RN has no canonical router.
733
737
  * Call `SendoraCloud.screen(name)` from your navigation listener.
@@ -736,6 +740,7 @@ interface SendoraConfig {
736
740
  appOpen?: boolean;
737
741
  sessionStart?: boolean;
738
742
  appBackground?: boolean;
743
+ engagement?: boolean;
739
744
  };
740
745
  /** Idle threshold (ms) for session expiry; default 30 minutes. */
741
746
  sessionIdleMs?: number;
@@ -827,6 +832,10 @@ declare class SendoraSDK {
827
832
  private debug;
828
833
  private sessionId;
829
834
  private autoTrackCleanup;
835
+ private engEnabled;
836
+ private engScreen;
837
+ private engAccumMs;
838
+ private engSegmentStart;
830
839
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
831
840
  auth: Auth;
832
841
  /** Lazy-initialised in init(). Deep-link surface: create + handleUniversalLink + matchDeferred. */
@@ -847,6 +856,19 @@ declare class SendoraSDK {
847
856
  track(eventType: string, properties?: TrackProperties): void;
848
857
  /** RN equivalent of page view — call on route / screen change. */
849
858
  screen(name: string, properties?: TrackProperties): void;
859
+ /** Bank the in-flight foreground segment into the accumulator + pause. */
860
+ private engBank;
861
+ /**
862
+ * Emit `app.engagement` for the current screen with foreground-only
863
+ * durationMs, then reset the accumulator. Sub-threshold (redirect
864
+ * flicker) spans are dropped; absurd spans clamp to a 6h ceiling.
865
+ * Never throws — engagement is best-effort instrumentation.
866
+ */
867
+ private engFlush;
868
+ /** Start measuring a new screen (foreground from now). */
869
+ private engEnter;
870
+ /** Resume accumulation after the app returns to foreground. */
871
+ private engResume;
850
872
  /**
851
873
  * Register a device push token (APNs or FCM) so server-side
852
874
  * workflows can target this user. The caller is responsible for
package/dist/index.d.ts CHANGED
@@ -728,6 +728,10 @@ interface SendoraConfig {
728
728
  * (idle >`sessionIdleMs` closes session). Default true.
729
729
  * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
730
730
  * AppState transitions. Default true.
731
+ * - `engagement` — emit `app.engagement` with foreground-only `durationMs`
732
+ * per screen on the next `screen()` call + on app-background (GA4
733
+ * user_engagement parity). Default true. Only measures named screens —
734
+ * it has no effect until you start calling `screen()`.
731
735
  *
732
736
  * Note: screen view tracking is NOT auto — RN has no canonical router.
733
737
  * Call `SendoraCloud.screen(name)` from your navigation listener.
@@ -736,6 +740,7 @@ interface SendoraConfig {
736
740
  appOpen?: boolean;
737
741
  sessionStart?: boolean;
738
742
  appBackground?: boolean;
743
+ engagement?: boolean;
739
744
  };
740
745
  /** Idle threshold (ms) for session expiry; default 30 minutes. */
741
746
  sessionIdleMs?: number;
@@ -827,6 +832,10 @@ declare class SendoraSDK {
827
832
  private debug;
828
833
  private sessionId;
829
834
  private autoTrackCleanup;
835
+ private engEnabled;
836
+ private engScreen;
837
+ private engAccumMs;
838
+ private engSegmentStart;
830
839
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
831
840
  auth: Auth;
832
841
  /** Lazy-initialised in init(). Deep-link surface: create + handleUniversalLink + matchDeferred. */
@@ -847,6 +856,19 @@ declare class SendoraSDK {
847
856
  track(eventType: string, properties?: TrackProperties): void;
848
857
  /** RN equivalent of page view — call on route / screen change. */
849
858
  screen(name: string, properties?: TrackProperties): void;
859
+ /** Bank the in-flight foreground segment into the accumulator + pause. */
860
+ private engBank;
861
+ /**
862
+ * Emit `app.engagement` for the current screen with foreground-only
863
+ * durationMs, then reset the accumulator. Sub-threshold (redirect
864
+ * flicker) spans are dropped; absurd spans clamp to a 6h ceiling.
865
+ * Never throws — engagement is best-effort instrumentation.
866
+ */
867
+ private engFlush;
868
+ /** Start measuring a new screen (foreground from now). */
869
+ private engEnter;
870
+ /** Resume accumulation after the app returns to foreground. */
871
+ private engResume;
850
872
  /**
851
873
  * Register a device push token (APNs or FCM) so server-side
852
874
  * workflows can target this user. The caller is responsible for
package/dist/index.js CHANGED
@@ -1722,15 +1722,16 @@ import { AppState as RnAppState2 } from "react-native";
1722
1722
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
1723
1723
  function resolveFlags(cfg) {
1724
1724
  if (cfg === false) {
1725
- return { appOpen: false, sessionStart: false, appBackground: false };
1725
+ return { appOpen: false, sessionStart: false, appBackground: false, engagement: false };
1726
1726
  }
1727
1727
  if (cfg === true || cfg === void 0) {
1728
- return { appOpen: true, sessionStart: true, appBackground: true };
1728
+ return { appOpen: true, sessionStart: true, appBackground: true, engagement: true };
1729
1729
  }
1730
1730
  return {
1731
1731
  appOpen: cfg.appOpen ?? true,
1732
1732
  sessionStart: cfg.sessionStart ?? true,
1733
- appBackground: cfg.appBackground ?? true
1733
+ appBackground: cfg.appBackground ?? true,
1734
+ engagement: cfg.engagement ?? true
1734
1735
  };
1735
1736
  }
1736
1737
  function installAutoTrack(args) {
@@ -1761,15 +1762,21 @@ function installAutoTrack(args) {
1761
1762
  if (flags.appOpen) {
1762
1763
  args.fire("app.opened", { sessionId });
1763
1764
  }
1764
- if (flags.appBackground) {
1765
+ if (flags.appBackground || flags.engagement) {
1765
1766
  const AppState = RnAppState2;
1766
1767
  if (AppState && typeof AppState.addEventListener === "function") {
1767
1768
  const handler = (state) => {
1768
1769
  ensureSession(true);
1769
1770
  if (state === "background" || state === "inactive") {
1770
- args.fire("app.backgrounded", { sessionId: args.getSessionId() });
1771
+ if (flags.engagement) args.onForeground?.(false);
1772
+ if (flags.appBackground) {
1773
+ args.fire("app.backgrounded", { sessionId: args.getSessionId() });
1774
+ }
1771
1775
  } else if (state === "active") {
1772
- args.fire("app.foregrounded", { sessionId: args.getSessionId() });
1776
+ if (flags.appBackground) {
1777
+ args.fire("app.foregrounded", { sessionId: args.getSessionId() });
1778
+ }
1779
+ if (flags.engagement) args.onForeground?.(true);
1773
1780
  }
1774
1781
  };
1775
1782
  const sub = AppState.addEventListener("change", handler);
@@ -1810,6 +1817,8 @@ var MAX_PUSH_METADATA_BYTES = 4096;
1810
1817
  var MAX_USER_ID_LEN = 256;
1811
1818
  var MAX_TRAITS_BYTES = 32 * 1024;
1812
1819
  var MAX_PROPERTIES_BYTES = 32 * 1024;
1820
+ var MIN_ENGAGEMENT_MS = 250;
1821
+ var MAX_ENGAGEMENT_MS = 6 * 60 * 60 * 1e3;
1813
1822
  function assertCSPRNG() {
1814
1823
  const g = globalThis;
1815
1824
  if (g.crypto?.getRandomValues) {
@@ -1860,6 +1869,12 @@ var SendoraSDK = class {
1860
1869
  this.debug = false;
1861
1870
  this.sessionId = "";
1862
1871
  this.autoTrackCleanup = null;
1872
+ // --- Engagement time (foreground-only, Wave 75) ---
1873
+ this.engEnabled = false;
1874
+ this.engScreen = null;
1875
+ this.engAccumMs = 0;
1876
+ // >0 = accumulating since this timestamp; 0 = paused (backgrounded).
1877
+ this.engSegmentStart = 0;
1863
1878
  /**
1864
1879
  * Lazy-allocated stable session id for chatbot conversations.
1865
1880
  * Survives only the JS lifetime — chatbot multi-turn context lives
@@ -1950,12 +1965,17 @@ var SendoraSDK = class {
1950
1965
  });
1951
1966
  this.ready = true;
1952
1967
  if (config.autoTrack !== false) {
1968
+ this.engEnabled = config.autoTrack === true || config.autoTrack === void 0 || config.autoTrack.engagement !== false;
1953
1969
  this.autoTrackCleanup = installAutoTrack({
1954
1970
  config: this.config,
1955
1971
  fire: (eventType, properties) => this.track(eventType, properties),
1956
1972
  getSessionId: () => this.sessionId,
1957
1973
  setSessionId: (id) => {
1958
1974
  this.sessionId = id;
1975
+ },
1976
+ onForeground: (active) => {
1977
+ if (active) this.engResume();
1978
+ else this.engFlush();
1959
1979
  }
1960
1980
  });
1961
1981
  }
@@ -2019,10 +2039,53 @@ var SendoraSDK = class {
2019
2039
  if (typeof name !== "string" || name.length === 0 || name.length > 256) {
2020
2040
  throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
2021
2041
  }
2042
+ this.engFlush();
2022
2043
  void this.sendEvent("screen", {
2023
2044
  eventType: "screen.viewed",
2024
2045
  properties: { screenName: name, ...properties ?? {} }
2025
2046
  });
2047
+ this.engEnter(name);
2048
+ }
2049
+ // --- Engagement timer (foreground-only, Wave 75) ---
2050
+ /** Bank the in-flight foreground segment into the accumulator + pause. */
2051
+ engBank() {
2052
+ if (this.engSegmentStart > 0) {
2053
+ this.engAccumMs += Date.now() - this.engSegmentStart;
2054
+ this.engSegmentStart = 0;
2055
+ }
2056
+ }
2057
+ /**
2058
+ * Emit `app.engagement` for the current screen with foreground-only
2059
+ * durationMs, then reset the accumulator. Sub-threshold (redirect
2060
+ * flicker) spans are dropped; absurd spans clamp to a 6h ceiling.
2061
+ * Never throws — engagement is best-effort instrumentation.
2062
+ */
2063
+ engFlush() {
2064
+ if (!this.engEnabled || this.engScreen === null) return;
2065
+ this.engBank();
2066
+ let ms = this.engAccumMs;
2067
+ this.engAccumMs = 0;
2068
+ if (ms < MIN_ENGAGEMENT_MS) return;
2069
+ if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
2070
+ try {
2071
+ void this.sendEvent("track", {
2072
+ eventType: "app.engagement",
2073
+ properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
2074
+ });
2075
+ } catch {
2076
+ }
2077
+ }
2078
+ /** Start measuring a new screen (foreground from now). */
2079
+ engEnter(name) {
2080
+ if (!this.engEnabled) return;
2081
+ this.engScreen = name;
2082
+ this.engAccumMs = 0;
2083
+ this.engSegmentStart = Date.now();
2084
+ }
2085
+ /** Resume accumulation after the app returns to foreground. */
2086
+ engResume() {
2087
+ if (!this.engEnabled || this.engScreen === null) return;
2088
+ if (this.engSegmentStart === 0) this.engSegmentStart = Date.now();
2026
2089
  }
2027
2090
  /**
2028
2091
  * Register a device push token (APNs or FCM) so server-side
@@ -2048,7 +2111,13 @@ var SendoraSDK = class {
2048
2111
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
2049
2112
  "/api/v1/push/tokens",
2050
2113
  {
2051
- userId: this.userId,
2114
+ // Conditional spread — the backend's registerPushTokenSchema makes
2115
+ // userId `.optional()`, which accepts a missing key but REJECTS
2116
+ // `null` (422 "userId: Expected string, received null"). Anonymous
2117
+ // installs (pre-identify()) carry a null userId, so OMIT the field
2118
+ // rather than send null — anon devices must still be able to
2119
+ // register. Mirrors the same conditional-spread sendEvent() uses.
2120
+ ...this.userId ? { userId: this.userId } : {},
2052
2121
  anonymousId: this.anonId,
2053
2122
  token: reg.token,
2054
2123
  platform: reg.platform,
@@ -2090,7 +2159,7 @@ var SendoraSDK = class {
2090
2159
  if (clickAction !== void 0) body.clickAction = clickAction;
2091
2160
  await post(
2092
2161
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2093
- "/push/track-open",
2162
+ "/api/v1/push/track-open",
2094
2163
  body
2095
2164
  );
2096
2165
  },
@@ -2109,7 +2178,7 @@ var SendoraSDK = class {
2109
2178
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.liveActivities.startToken().");
2110
2179
  const res = await post(
2111
2180
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2112
- "/push/live-activities/start-token",
2181
+ "/api/v1/push/live-activities/start-token",
2113
2182
  {
2114
2183
  pushToken: input.pushToken,
2115
2184
  activityType: input.activityType,
@@ -2143,7 +2212,7 @@ var SendoraSDK = class {
2143
2212
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.geofences.list().");
2144
2213
  const res = await getJson(
2145
2214
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2146
- "/push/geofences/list-for-device"
2215
+ "/api/v1/push/geofences/list-for-device"
2147
2216
  );
2148
2217
  if (!res || !res.ok) {
2149
2218
  throw new Error(`[sendoracloud] geofences list failed (HTTP ${res?.status ?? "network"}).`);
@@ -2161,7 +2230,7 @@ var SendoraSDK = class {
2161
2230
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.geofences.recordEvent().");
2162
2231
  const res = await post(
2163
2232
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2164
- "/push/geofences/event",
2233
+ "/api/v1/push/geofences/event",
2165
2234
  {
2166
2235
  geofenceId: input.geofenceId,
2167
2236
  kind: input.kind,
@@ -2205,7 +2274,7 @@ var SendoraSDK = class {
2205
2274
  if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.reportInstall().");
2206
2275
  const res = await post(
2207
2276
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2208
- "/attribution/install",
2277
+ "/api/v1/attribution/install",
2209
2278
  input ?? {}
2210
2279
  );
2211
2280
  if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.reportInstall failed (${res?.status ?? "network"})`);
@@ -2217,7 +2286,7 @@ var SendoraSDK = class {
2217
2286
  if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.checkDeferred().");
2218
2287
  const res = await post(
2219
2288
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2220
- "/attribution/deferred",
2289
+ "/api/v1/attribution/deferred",
2221
2290
  input ?? {}
2222
2291
  );
2223
2292
  if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.checkDeferred failed (${res?.status ?? "network"})`);
@@ -2251,7 +2320,7 @@ var SendoraSDK = class {
2251
2320
  if (!self.config) throw new Error("[sendoracloud] init() must complete before support.createTicket().");
2252
2321
  const res = await post(
2253
2322
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2254
- "/support/tickets",
2323
+ "/api/v1/support/tickets",
2255
2324
  input
2256
2325
  );
2257
2326
  if (!res || !res.ok) throw new Error(`[sendoracloud] support.createTicket failed (${res?.status ?? "network"})`);
@@ -2263,7 +2332,7 @@ var SendoraSDK = class {
2263
2332
  if (!self.config) throw new Error("[sendoracloud] init() must complete before support.submitCsat().");
2264
2333
  const res = await post(
2265
2334
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2266
- "/support/csat",
2335
+ "/api/v1/support/csat",
2267
2336
  input
2268
2337
  );
2269
2338
  if (!res || !res.ok) throw new Error(`[sendoracloud] support.submitCsat failed (${res?.status ?? "network"})`);
@@ -2352,7 +2421,7 @@ var SendoraSDK = class {
2352
2421
  if (!self.config) throw new Error("[sendoracloud] init() must complete before consent.record().");
2353
2422
  const res = await post(
2354
2423
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2355
- "/consent/record",
2424
+ "/api/v1/consent/record",
2356
2425
  input
2357
2426
  );
2358
2427
  if (!res || !res.ok) throw new Error(`[sendoracloud] consent.record failed (${res?.status ?? "network"})`);
@@ -2364,7 +2433,7 @@ var SendoraSDK = class {
2364
2433
  if (!self.config) throw new Error("[sendoracloud] init() must complete before consent.requestDeletion().");
2365
2434
  const res = await post(
2366
2435
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2367
- "/consent/deletion-request",
2436
+ "/api/v1/consent/deletion-request",
2368
2437
  input
2369
2438
  );
2370
2439
  if (!res || !res.ok) throw new Error(`[sendoracloud] consent.requestDeletion failed (${res?.status ?? "network"})`);
@@ -2415,7 +2484,7 @@ var SendoraSDK = class {
2415
2484
  if (!self.config) throw new Error("[sendoracloud] init() must complete before surveys.submitResponse().");
2416
2485
  const res = await post(
2417
2486
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2418
- "/surveys/responses",
2487
+ "/api/v1/surveys/responses",
2419
2488
  { completed: true, ...input }
2420
2489
  );
2421
2490
  if (!res || !res.ok) throw new Error(`[sendoracloud] surveys.submitResponse failed (${res?.status ?? "network"})`);
@@ -2465,7 +2534,7 @@ var SendoraSDK = class {
2465
2534
  if (!self.config) throw new Error("[sendoracloud] init() must complete before messages.recordImpression().");
2466
2535
  const res = await post(
2467
2536
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2468
- "/in-app-messages/impressions",
2537
+ "/api/v1/in-app-messages/impressions",
2469
2538
  input
2470
2539
  );
2471
2540
  if (!res || !res.ok) throw new Error(`[sendoracloud] messages.recordImpression failed (${res?.status ?? "network"})`);
@@ -2503,7 +2572,7 @@ var SendoraSDK = class {
2503
2572
  };
2504
2573
  const res = await post(
2505
2574
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2506
- "/feature-flags/evaluate",
2575
+ "/api/v1/feature-flags/evaluate",
2507
2576
  body
2508
2577
  );
2509
2578
  if (!res || !res.ok) throw new Error(`[sendoracloud] flags.evaluate failed (${res?.status ?? "network"})`);
@@ -2520,7 +2589,7 @@ var SendoraSDK = class {
2520
2589
  };
2521
2590
  const res = await post(
2522
2591
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2523
- "/feature-flags/evaluate-all",
2592
+ "/api/v1/feature-flags/evaluate-all",
2524
2593
  body
2525
2594
  );
2526
2595
  if (!res || !res.ok) throw new Error(`[sendoracloud] flags.evaluateAll failed (${res?.status ?? "network"})`);
@@ -2555,7 +2624,7 @@ var SendoraSDK = class {
2555
2624
  };
2556
2625
  const res = await post(
2557
2626
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2558
- "/chatbot/message",
2627
+ "/api/v1/chatbot/message",
2559
2628
  body
2560
2629
  );
2561
2630
  if (!res || !res.ok) throw new Error(`[sendoracloud] chatbot.sendMessage failed (${res?.status ?? "network"})`);
@@ -2583,6 +2652,7 @@ var SendoraSDK = class {
2583
2652
  }
2584
2653
  /** Tear down auto-track listeners. Call in test harnesses. */
2585
2654
  destroy() {
2655
+ this.engFlush();
2586
2656
  if (this.autoTrackCleanup) {
2587
2657
  this.autoTrackCleanup();
2588
2658
  this.autoTrackCleanup = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",