@digia-engage/core 1.1.1 → 2.0.0-rc.2

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.
Files changed (109) hide show
  1. package/README.md +134 -51
  2. package/android/build.gradle +3 -3
  3. package/android/src/main/java/com/digia/engage/rn/DigiaModule.kt +52 -8
  4. package/android/src/main/java/com/digia/engage/rn/DigiaSlotViewManager.kt +6 -2
  5. package/android/src/main/java/com/digia/engage/rn/DigiaViewManager.kt +1 -0
  6. package/ios/DigiaEngageModule.m +7 -1
  7. package/ios/DigiaHostViewManager.swift +20 -20
  8. package/ios/DigiaModule.swift +8 -4
  9. package/lib/commonjs/Digia.js +390 -4
  10. package/lib/commonjs/Digia.js.map +1 -1
  11. package/lib/commonjs/DigiaAnchorView.js +35 -3
  12. package/lib/commonjs/DigiaAnchorView.js.map +1 -1
  13. package/lib/commonjs/DigiaGuideController.js +59 -0
  14. package/lib/commonjs/DigiaGuideController.js.map +1 -0
  15. package/lib/commonjs/DigiaHealthReporter.js +45 -0
  16. package/lib/commonjs/DigiaHealthReporter.js.map +1 -0
  17. package/lib/commonjs/DigiaProvider.js +1081 -0
  18. package/lib/commonjs/DigiaProvider.js.map +1 -0
  19. package/lib/commonjs/DigiaSlotView.js +18 -3
  20. package/lib/commonjs/DigiaSlotView.js.map +1 -1
  21. package/lib/commonjs/NativeDigiaEngage.js +14 -8
  22. package/lib/commonjs/NativeDigiaEngage.js.map +1 -1
  23. package/lib/commonjs/actionHandler.js +316 -0
  24. package/lib/commonjs/actionHandler.js.map +1 -0
  25. package/lib/commonjs/defaultInAppBrowser.js +31 -0
  26. package/lib/commonjs/defaultInAppBrowser.js.map +1 -0
  27. package/lib/commonjs/digiaAnchorRegistry.js +32 -0
  28. package/lib/commonjs/digiaAnchorRegistry.js.map +1 -0
  29. package/lib/commonjs/frequencyEvaluator.js +70 -0
  30. package/lib/commonjs/frequencyEvaluator.js.map +1 -0
  31. package/lib/commonjs/frequencyStore.js +70 -0
  32. package/lib/commonjs/frequencyStore.js.map +1 -0
  33. package/lib/commonjs/index.js +7 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/templateTypes.js +2 -0
  36. package/lib/commonjs/templateTypes.js.map +1 -0
  37. package/lib/module/Digia.js +389 -4
  38. package/lib/module/Digia.js.map +1 -1
  39. package/lib/module/DigiaAnchorView.js +33 -1
  40. package/lib/module/DigiaAnchorView.js.map +1 -1
  41. package/lib/module/DigiaGuideController.js +53 -0
  42. package/lib/module/DigiaGuideController.js.map +1 -0
  43. package/lib/module/DigiaHealthReporter.js +38 -0
  44. package/lib/module/DigiaHealthReporter.js.map +1 -0
  45. package/lib/module/DigiaProvider.js +1074 -0
  46. package/lib/module/DigiaProvider.js.map +1 -0
  47. package/lib/module/DigiaSlotView.js +20 -5
  48. package/lib/module/DigiaSlotView.js.map +1 -1
  49. package/lib/module/NativeDigiaEngage.js +14 -8
  50. package/lib/module/NativeDigiaEngage.js.map +1 -1
  51. package/lib/module/actionHandler.js +311 -0
  52. package/lib/module/actionHandler.js.map +1 -0
  53. package/lib/module/defaultInAppBrowser.js +25 -0
  54. package/lib/module/defaultInAppBrowser.js.map +1 -0
  55. package/lib/module/digiaAnchorRegistry.js +26 -0
  56. package/lib/module/digiaAnchorRegistry.js.map +1 -0
  57. package/lib/module/frequencyEvaluator.js +61 -0
  58. package/lib/module/frequencyEvaluator.js.map +1 -0
  59. package/lib/module/frequencyStore.js +64 -0
  60. package/lib/module/frequencyStore.js.map +1 -0
  61. package/lib/module/index.js +1 -0
  62. package/lib/module/index.js.map +1 -1
  63. package/lib/module/templateTypes.js +2 -0
  64. package/lib/module/templateTypes.js.map +1 -0
  65. package/lib/typescript/Digia.d.ts +35 -3
  66. package/lib/typescript/Digia.d.ts.map +1 -1
  67. package/lib/typescript/DigiaAnchorView.d.ts +5 -1
  68. package/lib/typescript/DigiaAnchorView.d.ts.map +1 -1
  69. package/lib/typescript/DigiaGuideController.d.ts +30 -0
  70. package/lib/typescript/DigiaGuideController.d.ts.map +1 -0
  71. package/lib/typescript/DigiaHealthReporter.d.ts +24 -0
  72. package/lib/typescript/DigiaHealthReporter.d.ts.map +1 -0
  73. package/lib/typescript/DigiaProvider.d.ts +3 -0
  74. package/lib/typescript/DigiaProvider.d.ts.map +1 -0
  75. package/lib/typescript/DigiaSlotView.d.ts.map +1 -1
  76. package/lib/typescript/NativeDigiaEngage.d.ts +10 -6
  77. package/lib/typescript/NativeDigiaEngage.d.ts.map +1 -1
  78. package/lib/typescript/actionHandler.d.ts +20 -0
  79. package/lib/typescript/actionHandler.d.ts.map +1 -0
  80. package/lib/typescript/defaultInAppBrowser.d.ts +3 -0
  81. package/lib/typescript/defaultInAppBrowser.d.ts.map +1 -0
  82. package/lib/typescript/digiaAnchorRegistry.d.ts +15 -0
  83. package/lib/typescript/digiaAnchorRegistry.d.ts.map +1 -0
  84. package/lib/typescript/frequencyEvaluator.d.ts +14 -0
  85. package/lib/typescript/frequencyEvaluator.d.ts.map +1 -0
  86. package/lib/typescript/frequencyStore.d.ts +7 -0
  87. package/lib/typescript/frequencyStore.d.ts.map +1 -0
  88. package/lib/typescript/index.d.ts +1 -0
  89. package/lib/typescript/index.d.ts.map +1 -1
  90. package/lib/typescript/templateTypes.d.ts +140 -0
  91. package/lib/typescript/templateTypes.d.ts.map +1 -0
  92. package/lib/typescript/types.d.ts +163 -4
  93. package/lib/typescript/types.d.ts.map +1 -1
  94. package/package.json +15 -3
  95. package/src/Digia.ts +439 -4
  96. package/src/DigiaAnchorView.tsx +30 -2
  97. package/src/DigiaGuideController.ts +61 -0
  98. package/src/DigiaHealthReporter.ts +43 -0
  99. package/src/DigiaProvider.tsx +778 -0
  100. package/src/DigiaSlotView.tsx +26 -6
  101. package/src/NativeDigiaEngage.ts +28 -13
  102. package/src/actionHandler.ts +311 -0
  103. package/src/defaultInAppBrowser.ts +31 -0
  104. package/src/digiaAnchorRegistry.ts +27 -0
  105. package/src/frequencyEvaluator.ts +57 -0
  106. package/src/frequencyStore.ts +79 -0
  107. package/src/index.ts +1 -0
  108. package/src/templateTypes.ts +121 -0
  109. package/src/types.ts +132 -6
package/src/Digia.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * import { Digia } from '@digia/engage-react-native';
8
8
  *
9
9
  * // In your App entry point (e.g. App.tsx):
10
- * await Digia.initialize({ apiKey: 'YOUR_API_KEY' });
10
+ * await Digia.initialize({ projectId: 'YOUR_PROJECT_ID' });
11
11
  *
12
12
  * // Whenever your navigation screen changes:
13
13
  * Digia.setCurrentScreen('Home');
@@ -17,13 +17,37 @@
17
17
 
18
18
  import { DeviceEventEmitter } from 'react-native';
19
19
  import { nativeDigiaModule } from './NativeDigiaEngage';
20
+ import { digiaHealthReporter, HealthEventType } from './DigiaHealthReporter';
21
+ import { digiaGuideController } from './DigiaGuideController';
22
+ import { digiaActionHandler } from './actionHandler';
23
+ import uuid from 'react-native-uuid';
24
+ import { frequencyStore } from './frequencyStore';
25
+ import { evaluate, hasPolicy, isSessionPolicy } from './frequencyEvaluator';
20
26
  import type {
27
+ CampaignType,
21
28
  DigiaConfig,
22
29
  DigiaDelegate,
23
30
  DigiaExperienceEvent,
24
31
  DigiaPlugin,
32
+ FrequencyPolicy,
33
+ FrequencyState,
34
+ GuideLifecycleEvent,
25
35
  InAppPayload,
26
36
  } from './types';
37
+ import type { TemplateConfig } from './templateTypes';
38
+
39
+ const PRODUCTION_API_ROOT = 'https://app.digia.tech';
40
+ const SANDBOX_API_ROOT = 'https://dev.digia.tech';
41
+ const DIGIA_SDK_VERSION = '1.0.0';
42
+
43
+ interface SdkCampaign {
44
+ id?: string;
45
+ _id?: string;
46
+ campaign_key: string;
47
+ campaign_type: CampaignType;
48
+ templateConfig?: Record<string, unknown>;
49
+ frequency?: FrequencyPolicy | null;
50
+ }
27
51
 
28
52
  class DigiaClass implements DigiaDelegate {
29
53
  private readonly _plugins = new Map<string, DigiaPlugin>();
@@ -34,6 +58,14 @@ class DigiaClass implements DigiaDelegate {
34
58
  // the full InAppPayload when overlay lifecycle events arrive from native.
35
59
  private readonly _activePayloads = new Map<string, InAppPayload>();
36
60
  private _engageSubscription: { remove(): void } | null = null;
61
+ private _projectId = '';
62
+ private _deviceId = '';
63
+ private _apiBaseUrl = '';
64
+ private _logLevel: DigiaConfig['logLevel'] = 'error';
65
+ private _fontFamily: string | undefined;
66
+ private _currentScreen: string | null = null;
67
+ private readonly _campaignsByKey = new Map<string, SdkCampaign>();
68
+ private readonly _registeredAnchorKeys = new Set<string>();
37
69
 
38
70
  /**
39
71
  * Initialise the Digia Engage SDK.
@@ -44,7 +76,29 @@ class DigiaClass implements DigiaDelegate {
44
76
  async initialize(config: DigiaConfig): Promise<void> {
45
77
  const environment = config.environment ?? 'production';
46
78
  const logLevel = config.logLevel ?? 'error';
47
- await nativeDigiaModule.initialize(config.apiKey, environment, logLevel);
79
+ this._projectId = config.projectId;
80
+ this._apiBaseUrl = this._resolveApiBaseUrl(config);
81
+ this._logLevel = logLevel;
82
+ this._fontFamily = config.fontFamily?.trim() || undefined;
83
+ digiaHealthReporter.init(config.projectId, this._apiBaseUrl);
84
+
85
+ digiaActionHandler.configure({
86
+ onAction: config.onAction,
87
+ routeViaSystemLinking: config.linking?.routeViaSystemLinking ?? true,
88
+ inAppBrowser: config.linking?.inAppBrowser,
89
+ });
90
+
91
+ try {
92
+ await nativeDigiaModule.initialize(config.projectId, environment, logLevel, config.baseUrl, config.fontFamily);
93
+ } catch (e) {
94
+ // TODO: TO BE PICKED LATER @aditya-digia — health event reporting being removed
95
+ // digiaHealthReporter.report(HealthEventType.fetch_failed, { error_code: 0, platform: 'react_native' });
96
+ throw e;
97
+ }
98
+
99
+ this._deviceId = await this._loadOrCreateDeviceId();
100
+ await frequencyStore.checkProjectId(config.projectId);
101
+ await this._refreshCampaignStore();
48
102
  }
49
103
 
50
104
  /**
@@ -58,7 +112,7 @@ class DigiaClass implements DigiaDelegate {
58
112
  * ```ts
59
113
  * import { DigiaMoEngagePlugin } from '@digia/moengage-plugin';
60
114
  *
61
- * await Digia.initialize({ apiKey: 'YOUR_KEY' });
115
+ * await Digia.initialize({ projectId: 'YOUR_PROJECT_ID' });
62
116
  * Digia.register(new DigiaMoEngagePlugin({ moEngage: MoEngage }));
63
117
  * ```
64
118
  */
@@ -74,6 +128,7 @@ class DigiaClass implements DigiaDelegate {
74
128
  this._nativeBridgeWired = true;
75
129
  }
76
130
  plugin.setup(this);
131
+ (plugin as any).reportHealth?.(digiaHealthReporter);
77
132
  this._plugins.set(plugin.identifier, plugin);
78
133
  }
79
134
 
@@ -95,22 +150,119 @@ class DigiaClass implements DigiaDelegate {
95
150
  * All registered plugins will have forwardScreen() called automatically.
96
151
  */
97
152
  setCurrentScreen(name: string): void {
153
+ this._currentScreen = name;
98
154
  nativeDigiaModule.setCurrentScreen(name);
99
155
  this._plugins.forEach((plugin) => plugin.forwardScreen(name));
100
156
  }
101
157
 
158
+ registerAnchor(anchorKey: string, _screenName?: string | null): void {
159
+ const cleanAnchorKey = anchorKey.trim();
160
+ if (!cleanAnchorKey) return;
161
+ this._registeredAnchorKeys.add(cleanAnchorKey);
162
+ }
163
+
164
+ unregisterAnchor(anchorKey: string): void {
165
+ this._registeredAnchorKeys.delete(anchorKey);
166
+ }
167
+
168
+ /**
169
+ * Global font family configured via {@link initialize}, or `undefined` when
170
+ * none was set. Used by the JS-rendered guide overlays (tooltip/spotlight)
171
+ * so their text matches native-rendered campaigns.
172
+ */
173
+ get fontFamily(): string | undefined {
174
+ return this._fontFamily;
175
+ }
176
+
102
177
 
103
178
  // ── DigiaDelegate ────────────────────────────────────────────────────────
104
179
  // Mirrors DigiaCEPDelegate on Android.
105
180
  // Forwards to the native DigiaCEPDelegate via the bridge.
106
181
 
107
- onCampaignTriggered(payload: InAppPayload): void {
182
+ async onCampaignTriggered(payload: InAppPayload): Promise<void> {
183
+ if (!this._nativeBridgeWired) {
184
+ digiaHealthReporter.report(HealthEventType.plugin_not_registered, { campaign_key: payload.id });
185
+ }
186
+
187
+ const campaignKey = this._extractCampaignKey(payload);
188
+ this._log(`onCampaignTriggered payloadId=${payload.id} extractedKey=${campaignKey} knownKeys=[${[...this._campaignsByKey.keys()].join(', ')}]`);
189
+
190
+ if (campaignKey) {
191
+ const campaign = this._campaignsByKey.get(campaignKey);
192
+
193
+ if (campaign && hasPolicy(campaign.frequency)) {
194
+ const policy = campaign.frequency!;
195
+ const isSession = isSessionPolicy(policy);
196
+ const state = await this._getFrequencyState(campaignKey, isSession);
197
+ const result = evaluate(policy, state, Date.now());
198
+ if (!result.allow) {
199
+ this._log(`frequency_capped campaign_key=${campaignKey} reason=${result.reason}`);
200
+ return;
201
+ }
202
+ }
203
+
204
+ if (campaign?.campaign_type === 'inline' || campaign?.campaign_type === 'survey') {
205
+ this._log(`${campaign.campaign_type} campaign triggered campaign_key=${campaignKey}, forwarding to native`);
206
+ this._activePayloads.set(payload.id, payload);
207
+ if (campaign.campaign_type === 'inline') {
208
+ this._emitSlotWidth(campaign);
209
+ }
210
+ nativeDigiaModule.triggerCampaign(payload.id, payload.content, payload.cepContext);
211
+ return;
212
+ }
213
+
214
+ if (campaign?.campaign_type === 'guide') {
215
+ const config = this._parseTemplateConfig(campaign);
216
+ if (
217
+ !config ||
218
+ (config.templateType !== 'tooltip' && config.templateType !== 'spotlight') ||
219
+ config.steps.length === 0
220
+ ) {
221
+ digiaHealthReporter.report(HealthEventType.anchor_not_on_screen, {
222
+ campaign_key: campaignKey,
223
+ reason: 'guide_campaign_has_no_steps',
224
+ });
225
+ return;
226
+ }
227
+
228
+ this._activePayloads.set(payload.id, payload);
229
+ const digiaId = campaign._id ?? campaign.id ?? campaignKey;
230
+ const mounted = digiaGuideController.start({
231
+ payloadId: payload.id,
232
+ campaignKey,
233
+ campaignId: digiaId,
234
+ config,
235
+ onExperienceEvent: (event) => this._onGuideLifecycleEvent(event, payload.id, campaignKey, digiaId),
236
+ });
237
+
238
+ this._log(`guide trigger campaign_key=${campaignKey} mounted=${mounted}`);
239
+ if (!mounted) {
240
+ digiaHealthReporter.report(HealthEventType.host_not_mounted, {
241
+ campaign_key: campaignKey,
242
+ payload_id: payload.id,
243
+ });
244
+ }
245
+ return;
246
+ }
247
+
248
+ if (!campaign) {
249
+ this._log(`campaign_key_mismatch: no campaign found for key="${campaignKey}"`);
250
+ digiaHealthReporter.report(HealthEventType.campaign_key_mismatch, {
251
+ campaign_key: campaignKey,
252
+ payload_id: payload.id,
253
+ available_campaign_keys: [...this._campaignsByKey.keys()],
254
+ });
255
+ return;
256
+ }
257
+ }
258
+
108
259
  this._activePayloads.set(payload.id, payload);
109
260
  nativeDigiaModule.triggerCampaign(payload.id, payload.content, payload.cepContext);
110
261
  }
111
262
 
112
263
  onCampaignInvalidated(campaignId: string): void {
113
264
  this._activePayloads.delete(campaignId);
265
+ digiaGuideController.cancel(campaignId);
114
266
  nativeDigiaModule.invalidateCampaign(campaignId);
115
267
  }
116
268
 
@@ -139,16 +291,21 @@ class DigiaClass implements DigiaDelegate {
139
291
  const payload = this._activePayloads.get(data.campaignId);
140
292
  if (!payload) return;
141
293
 
294
+ const campaignKey = this._extractCampaignKey(payload);
295
+
142
296
  let event: DigiaExperienceEvent;
143
297
  switch (data.type) {
144
298
  case 'impressed':
145
299
  event = { type: 'impressed' };
300
+ if (campaignKey) void this._bumpFrequencyImpression(campaignKey);
146
301
  break;
147
302
  case 'clicked':
148
303
  event = { type: 'clicked', elementId: data.elementId };
304
+ if (campaignKey) void this._applyStopOn(campaignKey, 'click');
149
305
  break;
150
306
  case 'dismissed':
151
307
  event = { type: 'dismissed' };
308
+ if (campaignKey) void this._applyStopOn(campaignKey, 'dismiss');
152
309
  this._activePayloads.delete(data.campaignId);
153
310
  break;
154
311
  default:
@@ -158,6 +315,284 @@ class DigiaClass implements DigiaDelegate {
158
315
  this._plugins.forEach((plugin) => plugin.notifyEvent(event, payload));
159
316
  }
160
317
 
318
+ private _onGuideLifecycleEvent(
319
+ event: GuideLifecycleEvent,
320
+ payloadId: string,
321
+ campaignKey: string,
322
+ campaignId: string,
323
+ ): void {
324
+ const eventName = this._guideEventName(event.type);
325
+ const properties = this._buildGuideProperties(event, campaignId, campaignKey);
326
+ this._plugins.forEach((p) => p.track?.(eventName, properties));
327
+
328
+ if (event.type === 'viewed') {
329
+ void this._bumpFrequencyImpression(campaignKey);
330
+ }
331
+ if (event.type === 'clicked' || event.type === 'completed') {
332
+ void this._applyStopOn(campaignKey, 'click');
333
+ }
334
+ if (event.type === 'dismissed') {
335
+ void this._applyStopOn(campaignKey, 'dismiss');
336
+ }
337
+
338
+ // Notify plugins of CEP lifecycle termination (template cleanup) on exit events.
339
+ if (event.type === 'dismissed' || event.type === 'completed') {
340
+ const storedPayload = this._activePayloads.get(payloadId);
341
+ if (storedPayload) {
342
+ this._plugins.forEach((p) => p.notifyEvent({ type: 'dismissed' }, storedPayload));
343
+ this._activePayloads.delete(payloadId);
344
+ }
345
+ }
346
+ }
347
+
348
+ private _guideEventName(type: GuideLifecycleEvent['type']): string {
349
+ switch (type) {
350
+ case 'viewed': return 'Digia Experience Viewed';
351
+ case 'step_viewed': return 'Digia Step Viewed';
352
+ case 'clicked': return 'Digia Experience Clicked';
353
+ case 'step_clicked': return 'Digia Step Clicked';
354
+ case 'dismissed': return 'Digia Experience Dismissed';
355
+ case 'step_dismissed': return 'Digia Step Dismissed';
356
+ case 'completed': return 'Digia Experience Completed';
357
+ }
358
+ }
359
+
360
+ private _buildGuideProperties(
361
+ event: GuideLifecycleEvent,
362
+ campaignId: string,
363
+ campaignKey: string,
364
+ ): Record<string, unknown> {
365
+ const base: Record<string, unknown> = {
366
+ campaign_id: campaignId,
367
+ campaign_key: campaignKey,
368
+ campaign_type: 'guide',
369
+ display_style: event.displayStyle,
370
+ step_index: event.stepIndex + 1,
371
+ step_total: event.stepTotal,
372
+ anchor_key: event.anchorKey,
373
+ slot_key: null,
374
+ element_id: null,
375
+ cta_label: null,
376
+ action_type: null,
377
+ action_url: null,
378
+ dismiss_reason: null,
379
+ abandoned_at_step: null,
380
+ digia_sdk_version: DIGIA_SDK_VERSION,
381
+ digia_platform: 'react_native',
382
+ };
383
+
384
+ if (event.type === 'clicked' || event.type === 'step_clicked') {
385
+ base.element_id = event.elementId ?? null;
386
+ base.cta_label = event.ctaLabel;
387
+ base.action_type = event.actionType;
388
+ base.action_url = event.actionUrl ?? null;
389
+ } else if (event.type === 'dismissed' || event.type === 'step_dismissed') {
390
+ base.dismiss_reason = event.dismissReason;
391
+ base.abandoned_at_step = event.stepIndex + 1;
392
+ }
393
+
394
+ return base;
395
+ }
396
+
397
+ private _resolveApiBaseUrl(config: DigiaConfig): string {
398
+ const root = (config.baseUrl ??
399
+ (config.environment === 'sandbox' ? SANDBOX_API_ROOT : PRODUCTION_API_ROOT)).trim();
400
+ const cleanRoot = root.replace(/\/+$/, '');
401
+ return cleanRoot.endsWith('/api/v1') ? cleanRoot : `${cleanRoot}/api/v1`;
402
+ }
403
+
404
+ private async _refreshCampaignStore(): Promise<void> {
405
+ try {
406
+ this._log(`fetching campaigns from ${this._apiBaseUrl}/engage/sdk/getCampaigns`);
407
+ const campaigns = await this._sdkPost<SdkCampaign[]>('getCampaigns');
408
+ this._campaignsByKey.clear();
409
+ if (campaigns.length > 0) {
410
+ this._log(`campaign[0] raw keys: ${JSON.stringify(Object.keys(campaigns[0] as object))}`);
411
+ }
412
+ campaigns.forEach((campaign) => {
413
+ const raw = campaign as unknown as Record<string, unknown>;
414
+ const key = (typeof raw.campaign_key === 'string' && raw.campaign_key)
415
+ || (typeof raw.campaignKey === 'string' && raw.campaignKey)
416
+ || null;
417
+ const type = (typeof raw.campaign_type === 'string' && raw.campaign_type)
418
+ || (typeof raw.campaignType === 'string' && raw.campaignType)
419
+ || '';
420
+ if (key) {
421
+ this._campaignsByKey.set(key, { ...campaign, campaign_key: key, campaign_type: type as SdkCampaign['campaign_type'] });
422
+ }
423
+ });
424
+ this._log(`loaded ${campaigns.length} campaign(s): [${[...this._campaignsByKey.keys()].join(', ')}]`);
425
+ } catch (e) {
426
+ const reason = e instanceof Error ? e.message : String(e);
427
+ this._log(`getCampaigns FAILED: ${reason}`);
428
+ digiaHealthReporter.report(HealthEventType.fetch_failed, {
429
+ error_code: 0,
430
+ platform: 'react_native',
431
+ reason,
432
+ });
433
+ }
434
+ }
435
+
436
+ private async _sdkPost<T>(path: string, body: Record<string, unknown> = {}): Promise<T> {
437
+ const res = await fetch(`${this._apiBaseUrl}/engage/sdk/${path}`, {
438
+ method: 'POST',
439
+ headers: {
440
+ 'Content-Type': 'application/json',
441
+ 'x-digia-project-id': this._projectId,
442
+ 'x-digia-device-id': this._deviceId,
443
+ },
444
+ body: JSON.stringify(body),
445
+ });
446
+
447
+ if (!res.ok) {
448
+ throw new Error(`${path} failed: HTTP ${res.status}`);
449
+ }
450
+
451
+ const json = await res.json();
452
+ return this._extractApiResponse<T>(json);
453
+ }
454
+
455
+ private _extractApiResponse<T>(json: unknown): T {
456
+ if (Array.isArray(json)) return json as T;
457
+ if (json && typeof json === 'object') {
458
+ const obj = json as Record<string, unknown>;
459
+ const data = obj.data;
460
+ if (data && typeof data === 'object' && 'response' in data) {
461
+ const value = (data as Record<string, unknown>).response;
462
+ if (value == null) throw new Error('SDK response.data.response is null');
463
+ return value as T;
464
+ }
465
+ if ('response' in obj) {
466
+ const value = obj.response;
467
+ if (value == null) throw new Error('SDK response.response is null');
468
+ return value as T;
469
+ }
470
+ }
471
+ throw new Error('SDK response missing data.response');
472
+ }
473
+
474
+ private _emitSlotWidth(campaign: SdkCampaign): void {
475
+ const raw = campaign as unknown as Record<string, unknown>;
476
+ const config = raw.templateConfig as Record<string, unknown> | undefined;
477
+ if (!config) {
478
+ this._log(`_emitSlotWidth: no templateConfig for campaign ${campaign.campaign_key}`);
479
+ return;
480
+ }
481
+ const slotKey = typeof config.slotKey === 'string' ? config.slotKey : null;
482
+ const width = typeof config.width === 'number' && config.width > 0 ? config.width : null;
483
+ this._log(`_emitSlotWidth slotKey=${slotKey} width=${width} campaign=${campaign.campaign_key}`);
484
+ if (slotKey) {
485
+ DeviceEventEmitter.emit('digiaSlotWidth', { slotKey, width });
486
+ } else {
487
+ this._log(`_emitSlotWidth: no slotKey in templateConfig ${JSON.stringify(config)}`);
488
+ }
489
+ }
490
+
491
+ private _extractCampaignKey(payload: InAppPayload): string | null {
492
+ const fromContent = this._extractString(payload.content, 'digiaKey', 'campaign_key', 'campaignKey');
493
+ if (fromContent) return fromContent;
494
+
495
+ const args = payload.content.args;
496
+ if (args && typeof args === 'object' && !Array.isArray(args)) {
497
+ const fromArgs = this._extractString(args as Record<string, unknown>, 'digiaKey', 'campaign_key', 'campaignKey');
498
+ if (fromArgs) return fromArgs;
499
+ }
500
+
501
+ if (this._campaignsByKey.has(payload.id)) return payload.id;
502
+ return null;
503
+ }
504
+
505
+ private _extractString(data: Record<string, unknown>, ...keys: string[]): string | null {
506
+ for (const key of keys) {
507
+ const value = data[key];
508
+ if (typeof value === 'string' && value.trim()) return value.trim();
509
+ }
510
+ return null;
511
+ }
512
+
513
+ private _parseTemplateConfig(campaign: SdkCampaign): TemplateConfig | null {
514
+ const c = campaign as unknown as Record<string, unknown>;
515
+ const raw = c.templateConfig as Record<string, unknown> | undefined;
516
+ if (!raw || typeof raw !== 'object') return null;
517
+ const type = raw.templateType;
518
+ if (type !== 'tooltip' && type !== 'spotlight' && type !== 'carousel') {
519
+ this._log(`unknown templateType="${type}" for campaign_key=${campaign.campaign_key}`);
520
+ return null;
521
+ }
522
+ if (type === 'carousel') {
523
+ return raw as TemplateConfig;
524
+ }
525
+ const steps = Array.isArray(raw.steps) ? raw.steps : [];
526
+ return { ...raw, templateType: type, steps } as TemplateConfig;
527
+ }
528
+
529
+ // ── Device ID ────────────────────────────────────────────────────────────
530
+
531
+ private async _loadOrCreateDeviceId(): Promise<string> {
532
+ const DEVICE_ID_KEY = 'digia:device_id';
533
+ try {
534
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
535
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default;
536
+ const stored = await AsyncStorage.getItem(DEVICE_ID_KEY);
537
+ if (stored) return stored;
538
+ const id = uuid.v4() as string;
539
+ await AsyncStorage.setItem(DEVICE_ID_KEY, id);
540
+ return id;
541
+ } catch {
542
+ return uuid.v4() as string;
543
+ }
544
+ }
545
+
546
+ // ── Frequency capping ────────────────────────────────────────────────────
547
+
548
+ private async _getFrequencyState(campaignKey: string, isSession: boolean): Promise<FrequencyState | null> {
549
+ return frequencyStore.get(campaignKey, isSession);
550
+ }
551
+
552
+ async _bumpFrequencyImpression(campaignKey: string): Promise<void> {
553
+ const campaign = this._campaignsByKey.get(campaignKey);
554
+ if (!campaign || !hasPolicy(campaign.frequency)) return;
555
+ const isSession = isSessionPolicy(campaign.frequency!);
556
+ const now = Date.now();
557
+ const prev = await frequencyStore.get(campaignKey, isSession);
558
+ const next: FrequencyState = {
559
+ shown_count: (prev?.shown_count ?? 0) + 1,
560
+ first_shown_at: prev?.first_shown_at ?? now,
561
+ last_shown_at: now,
562
+ stopped_at: prev?.stopped_at ?? null,
563
+ stopped_reason: prev?.stopped_reason ?? null,
564
+ };
565
+ await frequencyStore.set(campaignKey, next, isSession);
566
+ }
567
+
568
+ async _applyStopOn(campaignKey: string, interactionType: 'click' | 'dismiss'): Promise<void> {
569
+ const campaign = this._campaignsByKey.get(campaignKey);
570
+ const stopOn = campaign?.frequency?.stop_on;
571
+ if (!stopOn) return;
572
+ const matches =
573
+ stopOn === 'any_action' ||
574
+ stopOn === interactionType;
575
+ if (!matches) return;
576
+ const isSession = isSessionPolicy(campaign!.frequency!);
577
+ const prev = await frequencyStore.get(campaignKey, isSession);
578
+ if (prev?.stopped_at) return;
579
+ const now = Date.now();
580
+ const next: FrequencyState = {
581
+ shown_count: prev?.shown_count ?? 0,
582
+ first_shown_at: prev?.first_shown_at ?? null,
583
+ last_shown_at: prev?.last_shown_at ?? null,
584
+ stopped_at: now,
585
+ stopped_reason: interactionType,
586
+ };
587
+ await frequencyStore.set(campaignKey, next, isSession);
588
+ }
589
+
590
+ private _log(message: string): void {
591
+ if (this._logLevel !== 'verbose') return;
592
+ // eslint-disable-next-line no-console
593
+ console.log(`[Digia] ${message}`);
594
+ }
595
+
161
596
  }
162
597
 
163
598
  export const Digia = new DigiaClass();
@@ -5,13 +5,18 @@
5
5
  * When a SHOW_TOOLTIP or SHOW_SPOTLIGHT campaign fires, the native SDK looks up this view
6
6
  * via AnchorRegistry and uses getLocationOnScreen() for accurate pixel-perfect coordinates.
7
7
  *
8
+ * Also reports layout into the JS digiaAnchorRegistry so JS-rendered guides (tooltip/spotlight)
9
+ * can position themselves relative to this anchor.
10
+ *
8
11
  * Usage:
9
12
  * <DigiaAnchorView anchorKey="pdp_add_to_cart" style={{ alignSelf: 'flex-start' }}>
10
13
  * <TouchableOpacity ...>Add to Cart</TouchableOpacity>
11
14
  * </DigiaAnchorView>
12
15
  */
13
16
 
14
- import { requireNativeComponent, type ViewProps } from 'react-native';
17
+ import React, { useCallback, useRef } from 'react';
18
+ import { requireNativeComponent, View, type ViewProps, type LayoutChangeEvent } from 'react-native';
19
+ import { digiaAnchorRegistry } from './digiaAnchorRegistry';
15
20
 
16
21
  interface DigiaAnchorViewProps extends ViewProps {
17
22
  anchorKey: string;
@@ -19,4 +24,27 @@ interface DigiaAnchorViewProps extends ViewProps {
19
24
  cornerRadius?: number;
20
25
  }
21
26
 
22
- export const DigiaAnchorView = requireNativeComponent<DigiaAnchorViewProps>('DigiaAnchorView');
27
+ const NativeDigiaAnchorView = requireNativeComponent<DigiaAnchorViewProps>('DigiaAnchorView');
28
+
29
+ export const DigiaAnchorView = ({ anchorKey, onLayout, ...rest }: DigiaAnchorViewProps) => {
30
+ const viewRef = useRef<View>(null);
31
+
32
+ const handleLayout = useCallback((e: LayoutChangeEvent) => {
33
+ onLayout?.(e);
34
+ // Use measure() for absolute screen coordinates (onLayout gives relative coords)
35
+ viewRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
36
+ if (width > 0 && height > 0) {
37
+ digiaAnchorRegistry.setLayout(anchorKey, { pageX, pageY, width, height });
38
+ }
39
+ });
40
+ }, [anchorKey, onLayout]);
41
+
42
+ return (
43
+ <NativeDigiaAnchorView
44
+ ref={viewRef as any}
45
+ anchorKey={anchorKey}
46
+ onLayout={handleLayout}
47
+ {...rest}
48
+ />
49
+ );
50
+ };
@@ -0,0 +1,61 @@
1
+ import type { GuideLifecycleEvent } from './types';
2
+ import type { TemplateConfig } from './templateTypes';
3
+
4
+ export interface DigiaGuideRequest {
5
+ payloadId: string;
6
+ campaignKey: string;
7
+ /** Digia backend UUID for this campaign (from the campaign store _id field). */
8
+ campaignId: string;
9
+ config: TemplateConfig;
10
+ onExperienceEvent: (event: GuideLifecycleEvent) => void;
11
+ }
12
+
13
+ type DigiaGuideControllerEvent =
14
+ | { type: 'start'; request: DigiaGuideRequest }
15
+ | { type: 'cancel'; payloadId: string };
16
+
17
+ type DigiaGuideListener = (event: DigiaGuideControllerEvent) => void;
18
+
19
+ class DigiaGuideController {
20
+ private _listener: DigiaGuideListener | null = null;
21
+ private _queue: DigiaGuideRequest[] = [];
22
+ private _activeRequest: DigiaGuideRequest | null = null;
23
+
24
+ subscribe(listener: DigiaGuideListener): () => void {
25
+ this._listener = listener;
26
+ if (this._queue.length > 0) {
27
+ const request = this._queue.shift()!;
28
+ this._activeRequest = request;
29
+ listener({ type: 'start', request });
30
+ }
31
+ return () => { if (this._listener === listener) this._listener = null; };
32
+ }
33
+
34
+ start(request: DigiaGuideRequest): boolean {
35
+ if (!this._listener) {
36
+ this._queue.push(request);
37
+ return false;
38
+ }
39
+ this._activeRequest = request;
40
+ this._listener({ type: 'start', request });
41
+ return true;
42
+ }
43
+
44
+ cancel(payloadId: string): void {
45
+ this._queue = this._queue.filter(r => r.payloadId !== payloadId);
46
+ if (this._activeRequest?.payloadId === payloadId) {
47
+ this._listener?.({ type: 'cancel', payloadId });
48
+ this._activeRequest = null;
49
+ this._dispatchNext();
50
+ }
51
+ }
52
+
53
+ private _dispatchNext(): void {
54
+ if (this._queue.length === 0 || !this._listener) return;
55
+ const request = this._queue.shift()!;
56
+ this._activeRequest = request;
57
+ this._listener({ type: 'start', request });
58
+ }
59
+ }
60
+
61
+ export const digiaGuideController = new DigiaGuideController();
@@ -0,0 +1,43 @@
1
+ export const HealthEventType = {
2
+ campaign_key_mismatch: 'campaign_key_mismatch',
3
+ component_orphaned: 'component_orphaned',
4
+ anchor_not_on_screen: 'anchor_not_on_screen',
5
+ host_not_mounted: 'host_not_mounted',
6
+ plugin_not_registered: 'plugin_not_registered',
7
+ fetch_failed: 'fetch_failed',
8
+ action_handler_threw: 'action_handler_threw',
9
+ action_handler_timeout: 'action_handler_timeout',
10
+ deep_link_no_handler: 'deep_link_no_handler',
11
+ invalid_action_url: 'invalid_action_url',
12
+ inapp_browser_unavailable: 'inapp_browser_unavailable',
13
+ invalid_action_context: 'invalid_action_context',
14
+ cold_start_queue_overflow: 'cold_start_queue_overflow',
15
+ } as const;
16
+
17
+ export type HealthEventType = (typeof HealthEventType)[keyof typeof HealthEventType];
18
+
19
+ export class DigiaHealthReporter {
20
+ private _projectId = '';
21
+ private _baseUrl = '';
22
+
23
+ init(projectId: string, baseUrl: string): void {
24
+ this._projectId = projectId;
25
+ this._baseUrl = baseUrl;
26
+ }
27
+
28
+ report(eventType: HealthEventType, detail: Record<string, unknown>): void {
29
+ if (!this._projectId) return;
30
+ // TODO: TO BE PICKED LATER @aditya-digia — health event backend endpoint being removed
31
+ // fetch(`${this._baseUrl}/engage/sdk/recordHealthEvent`, {
32
+ // method: 'POST',
33
+ // headers: {
34
+ // 'Content-Type': 'application/json',
35
+ // 'x-digia-project-id': this._projectId,
36
+ // },
37
+ // body: JSON.stringify({ event_type: eventType, detail }),
38
+ // }).catch(() => { /* swallow */ });
39
+ if (__DEV__) console.log('[DigiaHealth]', eventType, detail);
40
+ }
41
+ }
42
+
43
+ export const digiaHealthReporter = new DigiaHealthReporter();