@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.
- package/README.md +134 -51
- package/android/build.gradle +3 -3
- package/android/src/main/java/com/digia/engage/rn/DigiaModule.kt +52 -8
- package/android/src/main/java/com/digia/engage/rn/DigiaSlotViewManager.kt +6 -2
- package/android/src/main/java/com/digia/engage/rn/DigiaViewManager.kt +1 -0
- package/ios/DigiaEngageModule.m +7 -1
- package/ios/DigiaHostViewManager.swift +20 -20
- package/ios/DigiaModule.swift +8 -4
- package/lib/commonjs/Digia.js +390 -4
- package/lib/commonjs/Digia.js.map +1 -1
- package/lib/commonjs/DigiaAnchorView.js +35 -3
- package/lib/commonjs/DigiaAnchorView.js.map +1 -1
- package/lib/commonjs/DigiaGuideController.js +59 -0
- package/lib/commonjs/DigiaGuideController.js.map +1 -0
- package/lib/commonjs/DigiaHealthReporter.js +45 -0
- package/lib/commonjs/DigiaHealthReporter.js.map +1 -0
- package/lib/commonjs/DigiaProvider.js +1081 -0
- package/lib/commonjs/DigiaProvider.js.map +1 -0
- package/lib/commonjs/DigiaSlotView.js +18 -3
- package/lib/commonjs/DigiaSlotView.js.map +1 -1
- package/lib/commonjs/NativeDigiaEngage.js +14 -8
- package/lib/commonjs/NativeDigiaEngage.js.map +1 -1
- package/lib/commonjs/actionHandler.js +316 -0
- package/lib/commonjs/actionHandler.js.map +1 -0
- package/lib/commonjs/defaultInAppBrowser.js +31 -0
- package/lib/commonjs/defaultInAppBrowser.js.map +1 -0
- package/lib/commonjs/digiaAnchorRegistry.js +32 -0
- package/lib/commonjs/digiaAnchorRegistry.js.map +1 -0
- package/lib/commonjs/frequencyEvaluator.js +70 -0
- package/lib/commonjs/frequencyEvaluator.js.map +1 -0
- package/lib/commonjs/frequencyStore.js +70 -0
- package/lib/commonjs/frequencyStore.js.map +1 -0
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/templateTypes.js +2 -0
- package/lib/commonjs/templateTypes.js.map +1 -0
- package/lib/module/Digia.js +389 -4
- package/lib/module/Digia.js.map +1 -1
- package/lib/module/DigiaAnchorView.js +33 -1
- package/lib/module/DigiaAnchorView.js.map +1 -1
- package/lib/module/DigiaGuideController.js +53 -0
- package/lib/module/DigiaGuideController.js.map +1 -0
- package/lib/module/DigiaHealthReporter.js +38 -0
- package/lib/module/DigiaHealthReporter.js.map +1 -0
- package/lib/module/DigiaProvider.js +1074 -0
- package/lib/module/DigiaProvider.js.map +1 -0
- package/lib/module/DigiaSlotView.js +20 -5
- package/lib/module/DigiaSlotView.js.map +1 -1
- package/lib/module/NativeDigiaEngage.js +14 -8
- package/lib/module/NativeDigiaEngage.js.map +1 -1
- package/lib/module/actionHandler.js +311 -0
- package/lib/module/actionHandler.js.map +1 -0
- package/lib/module/defaultInAppBrowser.js +25 -0
- package/lib/module/defaultInAppBrowser.js.map +1 -0
- package/lib/module/digiaAnchorRegistry.js +26 -0
- package/lib/module/digiaAnchorRegistry.js.map +1 -0
- package/lib/module/frequencyEvaluator.js +61 -0
- package/lib/module/frequencyEvaluator.js.map +1 -0
- package/lib/module/frequencyStore.js +64 -0
- package/lib/module/frequencyStore.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/templateTypes.js +2 -0
- package/lib/module/templateTypes.js.map +1 -0
- package/lib/typescript/Digia.d.ts +35 -3
- package/lib/typescript/Digia.d.ts.map +1 -1
- package/lib/typescript/DigiaAnchorView.d.ts +5 -1
- package/lib/typescript/DigiaAnchorView.d.ts.map +1 -1
- package/lib/typescript/DigiaGuideController.d.ts +30 -0
- package/lib/typescript/DigiaGuideController.d.ts.map +1 -0
- package/lib/typescript/DigiaHealthReporter.d.ts +24 -0
- package/lib/typescript/DigiaHealthReporter.d.ts.map +1 -0
- package/lib/typescript/DigiaProvider.d.ts +3 -0
- package/lib/typescript/DigiaProvider.d.ts.map +1 -0
- package/lib/typescript/DigiaSlotView.d.ts.map +1 -1
- package/lib/typescript/NativeDigiaEngage.d.ts +10 -6
- package/lib/typescript/NativeDigiaEngage.d.ts.map +1 -1
- package/lib/typescript/actionHandler.d.ts +20 -0
- package/lib/typescript/actionHandler.d.ts.map +1 -0
- package/lib/typescript/defaultInAppBrowser.d.ts +3 -0
- package/lib/typescript/defaultInAppBrowser.d.ts.map +1 -0
- package/lib/typescript/digiaAnchorRegistry.d.ts +15 -0
- package/lib/typescript/digiaAnchorRegistry.d.ts.map +1 -0
- package/lib/typescript/frequencyEvaluator.d.ts +14 -0
- package/lib/typescript/frequencyEvaluator.d.ts.map +1 -0
- package/lib/typescript/frequencyStore.d.ts +7 -0
- package/lib/typescript/frequencyStore.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/templateTypes.d.ts +140 -0
- package/lib/typescript/templateTypes.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +163 -4
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +15 -3
- package/src/Digia.ts +439 -4
- package/src/DigiaAnchorView.tsx +30 -2
- package/src/DigiaGuideController.ts +61 -0
- package/src/DigiaHealthReporter.ts +43 -0
- package/src/DigiaProvider.tsx +778 -0
- package/src/DigiaSlotView.tsx +26 -6
- package/src/NativeDigiaEngage.ts +28 -13
- package/src/actionHandler.ts +311 -0
- package/src/defaultInAppBrowser.ts +31 -0
- package/src/digiaAnchorRegistry.ts +27 -0
- package/src/frequencyEvaluator.ts +57 -0
- package/src/frequencyStore.ts +79 -0
- package/src/index.ts +1 -0
- package/src/templateTypes.ts +121 -0
- 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({
|
|
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
|
-
|
|
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({
|
|
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();
|
package/src/DigiaAnchorView.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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();
|