@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
@@ -6,8 +6,9 @@
6
6
  * pass an explicit `height` in `style` to fix the size instead.
7
7
  */
8
8
 
9
- import React, { useCallback, useState } from 'react';
9
+ import React, { useCallback, useEffect, useState } from 'react';
10
10
  import {
11
+ DeviceEventEmitter,
11
12
  Platform,
12
13
  StyleSheet,
13
14
  requireNativeComponent,
@@ -21,7 +22,7 @@ interface DigiaSlotViewProps {
21
22
  }
22
23
 
23
24
  interface NativeDigiaSlotViewProps extends DigiaSlotViewProps {
24
- onContentSizeChange?: (event: { nativeEvent: { height: number } }) => void;
25
+ onContentSizeChange?: (event: { nativeEvent: { height: number; width: number } }) => void;
25
26
  collapsable?: boolean;
26
27
  }
27
28
 
@@ -32,24 +33,43 @@ const NativeDigiaSlotView =
32
33
 
33
34
  export function DigiaSlotView({ placementKey, style }: DigiaSlotViewProps) {
34
35
  const [contentHeight, setContentHeight] = useState(0);
36
+ const [contentWidth, setContentWidth] = useState<number | null>(null);
37
+
38
+ useEffect(() => {
39
+ console.log('[DigiaSlotView:debug] mounted placementKey=' + placementKey);
40
+ const sub = DeviceEventEmitter.addListener(
41
+ 'digiaSlotWidth',
42
+ (data: { slotKey: string; width: number | null }) => {
43
+ console.log('[DigiaSlotView:debug] digiaSlotWidth event received', JSON.stringify(data), 'myKey=' + placementKey, 'match=' + (data.slotKey === placementKey));
44
+ if (data.slotKey === placementKey) {
45
+ setContentWidth(data.width && data.width > 0 ? data.width : null);
46
+ }
47
+ },
48
+ );
49
+ return () => sub.remove();
50
+ }, [placementKey]);
35
51
 
36
52
  const onContentSizeChange = useCallback(
37
- (event: { nativeEvent: { height: number } }) => {
53
+ (event: { nativeEvent: { height: number; width: number } }) => {
38
54
  const h = event.nativeEvent.height ?? 0;
55
+ const w = event.nativeEvent.width ?? 0;
56
+ console.log('[DigiaSlotView:debug] onContentSizeChange placementKey=' + placementKey + ' h=' + h + ' w=' + w);
39
57
  setContentHeight(Math.max(0, h));
58
+ setContentWidth(w > 0 ? w : null);
40
59
  },
41
- [],
60
+ [placementKey],
42
61
  );
43
62
 
44
63
  if ((Platform.OS === 'android' || Platform.OS === 'ios') && NativeDigiaSlotView) {
45
64
  const flatStyle = StyleSheet.flatten(style) || {};
46
65
  const hasExplicitHeight = flatStyle.height !== undefined;
66
+ const resolvedWidth = contentWidth ?? '100%';
47
67
 
48
68
  if (hasExplicitHeight) {
49
69
  return (
50
70
  <NativeDigiaSlotView
51
71
  placementKey={placementKey}
52
- style={[{ width: '100%' }, style]}
72
+ style={[{ width: resolvedWidth }, style]}
53
73
  {...(Platform.OS === 'android' ? { collapsable: false } : {})}
54
74
  />
55
75
  );
@@ -61,7 +81,7 @@ export function DigiaSlotView({ placementKey, style }: DigiaSlotViewProps) {
61
81
  return (
62
82
  <NativeDigiaSlotView
63
83
  placementKey={placementKey}
64
- style={[{ width: '100%', height: bootstrapHeight }, style]}
84
+ style={[{ width: resolvedWidth, height: bootstrapHeight }, style]}
65
85
  onContentSizeChange={onContentSizeChange}
66
86
  {...(Platform.OS === 'android' ? { collapsable: false } : {})}
67
87
  />
@@ -1,15 +1,13 @@
1
1
  /**
2
2
  * NativeDigiaModule
3
3
  *
4
- * Low-level TurboModule binding to the native Digia Engage module.
4
+ * Low-level native binding to the Digia Engage module.
5
5
  *
6
6
  * The module is resolved lazily on first use (not at import time) so that
7
7
  * module evaluation before native initialisation doesn't throw.
8
8
  * Resolution order:
9
- * 1. TurboModuleRegistry.get() New Architecture / JSI path
10
- * 2. NativeModules bridge interop layer (RN 0.73+ New Arch
11
- * with isTurboModule: false in ReactModuleInfo)
12
- * 3. null — non-Android environments; methods no-op
9
+ * 1. NativeModules iOS bridge module
10
+ * 2. null non-Android environments; methods no-op
13
11
  *
14
12
  * Prefer using the high-level `Digia` singleton from `index.ts`.
15
13
  */
@@ -26,7 +24,7 @@ import { NativeModules, TurboModuleRegistry } from 'react-native';
26
24
  */
27
25
  export interface Spec extends TurboModule {
28
26
  /** Initialise the SDK. Call once before anything else. */
29
- initialize(apiKey: string, environment: string, logLevel: string): Promise<void>;
27
+ initialize(projectId: string, environment: string, logLevel: string, baseUrl?: string, fontFamily?: string): Promise<void>;
30
28
 
31
29
  /**
32
30
  * Wire the internal RNEventBridgePlugin with the native SDK.
@@ -51,6 +49,15 @@ export interface Spec extends TurboModule {
51
49
  /** Invalidate / dismiss a campaign by its ID. */
52
50
  invalidateCampaign(campaignId: string): void;
53
51
 
52
+ /** Register an anchor element position for tooltip/spotlight targeting. */
53
+ registerAnchor(key: string, x: number, y: number, width: number, height: number): void;
54
+
55
+ /** Remove a previously registered anchor. */
56
+ unregisterAnchor(key: string): void;
57
+
58
+ /** Return all component keys registered with the native SDK. */
59
+ getRegisteredComponents(): Promise<string[]>;
60
+
54
61
  }
55
62
 
56
63
 
@@ -61,12 +68,17 @@ export interface Spec extends TurboModule {
61
68
  // If neither resolves, warn in DEV and use no-op stubs so non-Android
62
69
  // environments (web, Storybook) don't crash.
63
70
  let _resolved: Spec | null = null;
71
+ let _didResolve = false;
72
+
73
+ function resolveCodegenModule(): Spec | null {
74
+ return TurboModuleRegistry.get<Spec>('DigiaEngageModule') ?? null;
75
+ }
76
+
64
77
  function getModule(): Spec | null {
65
- if (_resolved !== null) return _resolved;
66
- _resolved =
67
- TurboModuleRegistry.get<Spec>('DigiaEngageModule') ??
68
- (NativeModules.DigiaEngageModule as Spec | undefined) ??
69
- null;
78
+ if (_didResolve) return _resolved;
79
+ _didResolve = true;
80
+
81
+ _resolved = (NativeModules.DigiaEngageModule as Spec | undefined) ?? resolveCodegenModule();
70
82
  if (__DEV__ && !_resolved) {
71
83
  console.warn(
72
84
  '[Digia] DigiaEngageModule not found.\n' +
@@ -77,12 +89,15 @@ function getModule(): Spec | null {
77
89
  }
78
90
 
79
91
  export const nativeDigiaModule: Spec = {
80
- initialize: (apiKey, environment, logLevel) =>
81
- getModule()?.initialize(apiKey, environment, logLevel) ?? Promise.resolve(),
92
+ initialize: (projectId, environment, logLevel, baseUrl, fontFamily) =>
93
+ getModule()?.initialize(projectId, environment, logLevel, baseUrl, fontFamily) ?? Promise.resolve(),
82
94
  registerBridge: () => getModule()?.registerBridge(),
83
95
  setCurrentScreen: (name) => getModule()?.setCurrentScreen(name),
84
96
  triggerCampaign: (id, content, cepContext) =>
85
97
  getModule()?.triggerCampaign(id, content, cepContext),
86
98
  invalidateCampaign: (campaignId) => getModule()?.invalidateCampaign(campaignId),
99
+ registerAnchor: (key, x, y, width, height) => getModule()?.registerAnchor(key, x, y, width, height),
100
+ unregisterAnchor: (key) => getModule()?.unregisterAnchor(key),
101
+ getRegisteredComponents: () => getModule()?.getRegisteredComponents() ?? Promise.resolve([]),
87
102
  getConstants: () => getModule()?.getConstants?.() ?? {},
88
103
  };
@@ -0,0 +1,311 @@
1
+ import { AppState, type AppStateStatus, Linking } from 'react-native';
2
+ import type { DigiaAction, ActionContext, OnAction, InAppBrowserAdapter } from './types';
3
+ import type { Action } from './templateTypes';
4
+ import { digiaHealthReporter, HealthEventType } from './DigiaHealthReporter';
5
+
6
+ // ─── Types ────────────────────────────────────────────────────────────────────
7
+
8
+ export type ActionCallbacks = {
9
+ onNext: () => void;
10
+ onBack: () => void;
11
+ onDismissSelf: () => void;
12
+ onDismissAll: () => void;
13
+ };
14
+
15
+ type ActionHandlerConfig = {
16
+ onAction?: OnAction;
17
+ routeViaSystemLinking: boolean;
18
+ inAppBrowser?: InAppBrowserAdapter;
19
+ };
20
+
21
+ // ─── Internal state ───────────────────────────────────────────────────────────
22
+
23
+ let _config: ActionHandlerConfig = { routeViaSystemLinking: true };
24
+ let _lastActionKey = '';
25
+ let _lastActionAt = 0;
26
+ let _inappBrowserWarned = false;
27
+ const _invalidContextWarned = new Set<string>();
28
+ const MAX_WARNED_KEYS = 50;
29
+
30
+ const DEBOUNCE_MS = 500;
31
+ const HANDLER_TIMEOUT_MS = 2000;
32
+
33
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
34
+
35
+ function isValidUrl(url: string): boolean {
36
+ if (!url) return false;
37
+ try {
38
+ new URL(url);
39
+ return true;
40
+ } catch {
41
+ return /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(url);
42
+ }
43
+ }
44
+
45
+ function isDuplicate(action: DigiaAction, campaignId: string): boolean {
46
+ const key = `${campaignId}:${action.type}`;
47
+ const now = Date.now();
48
+ if (key === _lastActionKey && now - _lastActionAt < DEBOUNCE_MS) return true;
49
+ _lastActionKey = key;
50
+ _lastActionAt = now;
51
+ return false;
52
+ }
53
+
54
+ function emitHealth(eventType: HealthEventType, detail: Record<string, unknown>): void {
55
+ digiaHealthReporter.report(eventType, detail);
56
+ }
57
+
58
+ // ─── Convert widget Action → public DigiaAction ───────────────────────────────
59
+
60
+ export const toDigiaAction = (action: Action): DigiaAction => {
61
+ switch (action.type) {
62
+ case 'dismiss': return { type: 'dismiss', scope: action.scope };
63
+ case 'next': return { type: 'next' };
64
+ case 'back': return { type: 'back' };
65
+ case 'prev': return { type: 'back' };
66
+ case 'deep_link': return { type: 'deep_link', url: action.url, fallback_url: action.fallback_url };
67
+ case 'open_url': return { type: 'open_url', url: action.url, presentation: action.presentation };
68
+ }
69
+ };
70
+
71
+ // ─── onAction invocation with timeout + error handling ───────────────────────
72
+
73
+ async function callOnAction(
74
+ onAction: OnAction,
75
+ action: DigiaAction,
76
+ context: ActionContext,
77
+ ): Promise<boolean> {
78
+ const timedOut = { value: false };
79
+ try {
80
+ const result = onAction(action, context);
81
+ if (result === true) return true;
82
+ if (result === false || result === undefined || result === null) return false;
83
+ const timeoutPromise = new Promise<boolean>((resolve) =>
84
+ setTimeout(() => { timedOut.value = true; resolve(false); }, HANDLER_TIMEOUT_MS),
85
+ );
86
+ const resolved = await Promise.race([
87
+ Promise.resolve(result as Promise<boolean>).catch(() => false),
88
+ timeoutPromise,
89
+ ]);
90
+ if (timedOut.value) {
91
+ emitHealth(HealthEventType.action_handler_timeout, {
92
+ campaign_id: context.campaign_id,
93
+ action_type: action.type,
94
+ });
95
+ }
96
+ return resolved === true;
97
+ } catch (e) {
98
+ const err = e instanceof Error ? e : new Error(String(e));
99
+ emitHealth(HealthEventType.action_handler_threw, {
100
+ campaign_id: context.campaign_id,
101
+ action_type: action.type,
102
+ error_message: err.message,
103
+ error_stack: err.stack,
104
+ });
105
+ return false;
106
+ }
107
+ }
108
+
109
+ // ─── Default behavior per action type ────────────────────────────────────────
110
+
111
+ async function runDefault(
112
+ action: DigiaAction,
113
+ context: ActionContext,
114
+ callbacks: ActionCallbacks,
115
+ ): Promise<void> {
116
+ switch (action.type) {
117
+ case 'deep_link': {
118
+ if (!isValidUrl(action.url)) {
119
+ emitHealth(HealthEventType.invalid_action_url, {
120
+ url: action.url, action_type: 'deep_link', campaign_id: context.campaign_id,
121
+ });
122
+ return;
123
+ }
124
+ const canOpen = await Linking.canOpenURL(action.url).catch(() => false);
125
+ if (canOpen) {
126
+ Linking.openURL(action.url).catch(() => {});
127
+ callbacks.onDismissSelf();
128
+ return;
129
+ }
130
+ if (action.fallback_url) {
131
+ if (!isValidUrl(action.fallback_url)) {
132
+ emitHealth(HealthEventType.invalid_action_url, {
133
+ url: action.fallback_url, action_type: 'deep_link', campaign_id: context.campaign_id,
134
+ });
135
+ } else {
136
+ const canOpenFallback = await Linking.canOpenURL(action.fallback_url).catch(() => false);
137
+ if (canOpenFallback) {
138
+ Linking.openURL(action.fallback_url).catch(() => {});
139
+ callbacks.onDismissSelf();
140
+ return;
141
+ }
142
+ }
143
+ }
144
+ emitHealth(HealthEventType.deep_link_no_handler, {
145
+ url: action.url,
146
+ ...(action.fallback_url ? { fallback_url: action.fallback_url } : {}),
147
+ campaign_id: context.campaign_id,
148
+ });
149
+ callbacks.onDismissSelf();
150
+ return;
151
+ }
152
+
153
+ case 'open_url': {
154
+ if (!isValidUrl(action.url)) {
155
+ emitHealth(HealthEventType.invalid_action_url, {
156
+ url: action.url, action_type: 'open_url', campaign_id: context.campaign_id,
157
+ });
158
+ return;
159
+ }
160
+ const presentation = action.presentation ?? 'external';
161
+ if (presentation === 'in_app' && _config.inAppBrowser) {
162
+ _config.inAppBrowser.open(action.url).catch(() => {});
163
+ } else {
164
+ if (presentation === 'in_app' && !_inappBrowserWarned) {
165
+ _inappBrowserWarned = true;
166
+ emitHealth(HealthEventType.inapp_browser_unavailable, { campaign_id: context.campaign_id });
167
+ }
168
+ Linking.openURL(action.url).catch(() => {});
169
+ }
170
+ callbacks.onDismissSelf();
171
+ return;
172
+ }
173
+
174
+ case 'dismiss': {
175
+ if (action.scope === 'all') {
176
+ callbacks.onDismissAll();
177
+ } else {
178
+ callbacks.onDismissSelf();
179
+ }
180
+ return;
181
+ }
182
+
183
+ case 'next': {
184
+ const isLastStep =
185
+ context.step_total !== undefined &&
186
+ context.step_index !== undefined &&
187
+ context.step_index >= context.step_total - 1;
188
+ if (isLastStep) {
189
+ const warnKey = `next:${context.campaign_id}`;
190
+ if (!_invalidContextWarned.has(warnKey)) {
191
+ if (_invalidContextWarned.size >= MAX_WARNED_KEYS) _invalidContextWarned.clear();
192
+ _invalidContextWarned.add(warnKey);
193
+ emitHealth(HealthEventType.invalid_action_context, {
194
+ campaign_id: context.campaign_id, action_type: 'next',
195
+ });
196
+ }
197
+ callbacks.onDismissSelf();
198
+ return;
199
+ }
200
+ callbacks.onNext();
201
+ return;
202
+ }
203
+
204
+ case 'back': {
205
+ if (context.step_index === 0) {
206
+ const warnKey = `back:${context.campaign_id}`;
207
+ if (!_invalidContextWarned.has(warnKey)) {
208
+ if (_invalidContextWarned.size >= MAX_WARNED_KEYS) _invalidContextWarned.clear();
209
+ _invalidContextWarned.add(warnKey);
210
+ emitHealth(HealthEventType.invalid_action_context, {
211
+ campaign_id: context.campaign_id, action_type: 'back',
212
+ });
213
+ }
214
+ return;
215
+ }
216
+ callbacks.onBack();
217
+ return;
218
+ }
219
+
220
+ }
221
+ }
222
+
223
+ // ─── Public interface ─────────────────────────────────────────────────────────
224
+
225
+ const configure = (config: Partial<ActionHandlerConfig>): void => {
226
+ _config = { routeViaSystemLinking: true, ...config };
227
+ };
228
+
229
+ const execute = async (
230
+ widgetAction: Action,
231
+ context: ActionContext,
232
+ callbacks: ActionCallbacks,
233
+ ): Promise<void> => {
234
+ const action = toDigiaAction(widgetAction);
235
+
236
+ if (isDuplicate(action, context.campaign_id)) return;
237
+
238
+ // Guard: queue if app not in foreground (cold-start scenario)
239
+ if (AppState.currentState !== 'active') {
240
+ digiaActionQueue.enqueue(widgetAction, action.type, context, callbacks);
241
+ return;
242
+ }
243
+
244
+ // 1. Analytics click — fire-and-forget (handled by caller via onExperienceEvent)
245
+
246
+ // 2. Invoke onAction override
247
+ let handled = false;
248
+ if (_config.onAction) {
249
+ handled = await callOnAction(_config.onAction, action, context);
250
+ }
251
+
252
+ // 3. Run default if not handled
253
+ if (!handled) {
254
+ await runDefault(action, context, callbacks);
255
+ }
256
+ };
257
+
258
+ export const digiaActionHandler = { configure, execute };
259
+
260
+ // ─── Cold-start queue ─────────────────────────────────────────────────────────
261
+
262
+ type QueuedItem = {
263
+ widgetAction: Action;
264
+ actionType: string;
265
+ context: ActionContext;
266
+ callbacks: ActionCallbacks;
267
+ };
268
+
269
+ const MAX_QUEUE_SIZE = 5;
270
+
271
+ class ActionQueue {
272
+ private _items: QueuedItem[] = [];
273
+ private _subscription: { remove(): void } | null = null;
274
+
275
+ constructor() {
276
+ this._subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
277
+ if (state === 'active') this._flush();
278
+ });
279
+ }
280
+
281
+ enqueue(widgetAction: Action, actionType: string, context: ActionContext, callbacks: ActionCallbacks): void {
282
+ if (this._items.length >= MAX_QUEUE_SIZE) {
283
+ const dropped = this._items.shift()!;
284
+ emitHealth(HealthEventType.cold_start_queue_overflow, {
285
+ dropped_action_type: dropped.actionType,
286
+ dropped_campaign_id: dropped.context.campaign_id,
287
+ });
288
+ }
289
+ this._items.push({ widgetAction, actionType, context, callbacks });
290
+ }
291
+
292
+ destroy(): void {
293
+ this._subscription?.remove();
294
+ this._subscription = null;
295
+ this._items = [];
296
+ }
297
+
298
+ private _flush(): void {
299
+ const items = this._items.splice(0);
300
+ const processAt = (index: number) => {
301
+ if (index >= items.length) return;
302
+ const item = items[index];
303
+ digiaActionHandler.execute(item.widgetAction, item.context, item.callbacks).finally(() => {
304
+ setTimeout(() => processAt(index + 1), 100);
305
+ });
306
+ };
307
+ processAt(0);
308
+ }
309
+ }
310
+
311
+ const digiaActionQueue = new ActionQueue();
@@ -0,0 +1,31 @@
1
+ import type { InAppBrowserAdapter } from './types';
2
+
3
+ // Lazy accessor — throws at first access if the package is not installed,
4
+ // giving the developer a clear error at init time rather than silently
5
+ // falling back to Linking.openURL.
6
+ type InAppBrowserModule = { open(url: string, options?: Record<string, unknown>): Promise<void> };
7
+ let _module: InAppBrowserModule | null = null;
8
+
9
+ const loadModule = (): InAppBrowserModule => {
10
+ if (_module) return _module;
11
+ try {
12
+ // Dynamic require at runtime — not available at build time.
13
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
14
+ const pkg = (globalThis as any).require('react-native-inappbrowser-reborn');
15
+ if (!pkg) throw new Error('not installed');
16
+ _module = (pkg.InAppBrowser ?? pkg.default?.InAppBrowser ?? pkg) as InAppBrowserModule;
17
+ return _module;
18
+ } catch {
19
+ throw new Error(
20
+ '[Digia] defaultInAppBrowser requires react-native-inappbrowser-reborn. ' +
21
+ 'Run: npm install react-native-inappbrowser-reborn',
22
+ );
23
+ }
24
+ };
25
+
26
+ export const defaultInAppBrowser: InAppBrowserAdapter = {
27
+ open: async (url: string) => {
28
+ const browser = loadModule();
29
+ await browser!.open(url);
30
+ },
31
+ };
@@ -0,0 +1,27 @@
1
+ type AnchorLayout = { pageX: number; pageY: number; width: number; height: number }
2
+ type Listener = (layout: AnchorLayout) => void
3
+
4
+ const _layouts = new Map<string, AnchorLayout>()
5
+ const _listeners = new Map<string, Listener>()
6
+
7
+ const setLayout = (key: string, layout: AnchorLayout) => {
8
+ _layouts.set(key, layout)
9
+ _listeners.get(key)?.(layout)
10
+ }
11
+
12
+ const getLayout = (key: string): AnchorLayout | undefined => _layouts.get(key)
13
+
14
+ const subscribe = (key: string, listener: Listener): () => void => {
15
+ _listeners.set(key, listener)
16
+ const existing = _layouts.get(key)
17
+ if (existing) listener(existing)
18
+ return () => { if (_listeners.get(key) === listener) _listeners.delete(key) }
19
+ }
20
+
21
+ const remove = (key: string) => {
22
+ _layouts.delete(key)
23
+ _listeners.delete(key)
24
+ }
25
+
26
+ export type { AnchorLayout }
27
+ export const digiaAnchorRegistry = { setLayout, getLayout, subscribe, remove }
@@ -0,0 +1,57 @@
1
+ import type { FrequencyPolicy, FrequencyState, FrequencyEvalResult } from './types';
2
+
3
+ const WINDOW_MS: Record<string, number> = {
4
+ day: 86_400_000,
5
+ week: 7 * 86_400_000,
6
+ month: 30 * 86_400_000,
7
+ };
8
+
9
+ export const isSessionPolicy = (policy: FrequencyPolicy): boolean =>
10
+ policy.max_per_window?.window === 'session';
11
+
12
+ /**
13
+ * Pure eligibility function. No side effects.
14
+ *
15
+ * Semantics for max_per_window { count, window }:
16
+ * - "count" shows are allowed, measured from first_shown_at.
17
+ * - Once the window duration has elapsed since first_shown_at, permanently blocked (reason: 'window').
18
+ * - Once shown_count >= count, permanently blocked (reason: 'max_total').
19
+ * - 'session' window is checked by the caller via in-memory state — same logic applies.
20
+ */
21
+ export const evaluate = (
22
+ policy: FrequencyPolicy,
23
+ state: FrequencyState | null,
24
+ now: number,
25
+ ): FrequencyEvalResult => {
26
+ if (!state) return { allow: true, reason: null };
27
+
28
+ if (state.stopped_at !== null) {
29
+ return { allow: false, reason: 'stopped' };
30
+ }
31
+
32
+ if (policy.max_total !== null && state.shown_count >= policy.max_total) {
33
+ return { allow: false, reason: 'max_total' };
34
+ }
35
+
36
+ if (policy.max_per_window !== null) {
37
+ const { count, window: win } = policy.max_per_window;
38
+ const windowMs = WINDOW_MS[win];
39
+
40
+ if (windowMs !== undefined && state.first_shown_at !== null) {
41
+ if (now - state.first_shown_at > windowMs) {
42
+ return { allow: false, reason: 'window' };
43
+ }
44
+ }
45
+
46
+ if (state.shown_count >= count) {
47
+ return { allow: false, reason: 'max_total' };
48
+ }
49
+ }
50
+
51
+ return { allow: true, reason: null };
52
+ };
53
+
54
+ export const hasPolicy = (policy: FrequencyPolicy | null | undefined): policy is FrequencyPolicy =>
55
+ policy !== null &&
56
+ policy !== undefined &&
57
+ (policy.max_total !== null || policy.max_per_window !== null || policy.stop_on !== null);
@@ -0,0 +1,79 @@
1
+ import type { FrequencyState } from './types';
2
+
3
+ // eslint-disable-next-line @typescript-eslint/naming-convention
4
+ const STORE_META_KEY = 'digia:freq:__meta__';
5
+
6
+ type AsyncStorageAdapter = {
7
+ getItem(key: string): Promise<string | null>;
8
+ setItem(key: string, value: string): Promise<void>;
9
+ removeItem(key: string): Promise<void>;
10
+ getAllKeys(): Promise<readonly string[]>;
11
+ multiRemove(keys: string[]): Promise<void>;
12
+ };
13
+
14
+ let _storage: AsyncStorageAdapter | null = null;
15
+
16
+ const _loadStorage = (): AsyncStorageAdapter | null => {
17
+ try {
18
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
19
+ const mod = require('@react-native-async-storage/async-storage');
20
+ return mod.default ?? mod;
21
+ } catch {
22
+ console.warn('[Digia] AsyncStorage unavailable — frequency state is in-memory only (resets on app restart)');
23
+ return null;
24
+ }
25
+ };
26
+
27
+ const getStorage = (): AsyncStorageAdapter | null => {
28
+ if (_storage === undefined) {
29
+ _storage = _loadStorage();
30
+ }
31
+ return _storage;
32
+ };
33
+
34
+ const _sessionStore = new Map<string, FrequencyState>();
35
+
36
+ const storeKey = (campaignKey: string) => `digia:freq:${campaignKey}`;
37
+
38
+ export const frequencyStore = {
39
+ async checkProjectId(projectId: string): Promise<void> {
40
+ const storage = getStorage();
41
+ if (!storage) return;
42
+ try {
43
+ const stored = await storage.getItem(STORE_META_KEY);
44
+ const meta = stored ? (JSON.parse(stored) as { projectId: string }) : null;
45
+ if (meta && meta.projectId !== projectId) {
46
+ const keys = await storage.getAllKeys();
47
+ const digiaKeys = keys.filter((k) => k.startsWith('digia:freq:'));
48
+ if (digiaKeys.length > 0) await storage.multiRemove([...digiaKeys]);
49
+ }
50
+ await storage.setItem(STORE_META_KEY, JSON.stringify({ projectId }));
51
+ } catch {
52
+ // non-fatal
53
+ }
54
+ },
55
+
56
+ async get(campaignKey: string, isSession: boolean): Promise<FrequencyState | null> {
57
+ if (isSession) return _sessionStore.get(campaignKey) ?? null;
58
+ const storage = getStorage();
59
+ if (!storage) return _sessionStore.get(campaignKey) ?? null;
60
+ try {
61
+ const raw = await storage.getItem(storeKey(campaignKey));
62
+ return raw ? (JSON.parse(raw) as FrequencyState) : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ },
67
+
68
+ async set(campaignKey: string, state: FrequencyState, isSession: boolean): Promise<void> {
69
+ _sessionStore.set(campaignKey, state);
70
+ if (isSession) return;
71
+ const storage = getStorage();
72
+ if (!storage) return;
73
+ try {
74
+ await storage.setItem(storeKey(campaignKey), JSON.stringify(state));
75
+ } catch {
76
+ // non-fatal: state already updated in-memory above
77
+ }
78
+ },
79
+ };
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  export { Digia } from './Digia';
13
13
  export { DigiaHostView } from './DigiaHostView';
14
+ export { DigiaHost } from './DigiaProvider';
14
15
  export { DigiaSlotView } from './DigiaSlotView';
15
16
  export { DigiaAnchorView } from './DigiaAnchorView';
16
17
  export type { DigiaConfig, DigiaDelegate, DigiaExperienceEvent, DigiaPlugin, InAppPayload } from './types';