@digia-engage/core 1.1.0 → 2.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -177
- package/android/build.gradle +2 -2
- 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 +301 -3
- package/lib/commonjs/Digia.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 +1079 -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/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 +301 -3
- package/lib/module/Digia.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 +1072 -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/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 +29 -2
- package/lib/typescript/Digia.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/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 +140 -3
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +12 -3
- package/react-native.config.js +23 -0
- package/src/Digia.ts +340 -3
- package/src/DigiaGuideController.ts +61 -0
- package/src/DigiaHealthReporter.ts +43 -0
- package/src/DigiaProvider.tsx +776 -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/index.ts +1 -0
- package/src/templateTypes.ts +121 -0
- package/src/types.ts +102 -5
package/src/DigiaSlotView.tsx
CHANGED
|
@@ -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:
|
|
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:
|
|
84
|
+
style={[{ width: resolvedWidth, height: bootstrapHeight }, style]}
|
|
65
85
|
onContentSizeChange={onContentSizeChange}
|
|
66
86
|
{...(Platform.OS === 'android' ? { collapsable: false } : {})}
|
|
67
87
|
/>
|
package/src/NativeDigiaEngage.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* NativeDigiaModule
|
|
3
3
|
*
|
|
4
|
-
* Low-level
|
|
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.
|
|
10
|
-
* 2.
|
|
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(
|
|
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 (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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: (
|
|
81
|
-
getModule()?.initialize(
|
|
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 }
|
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';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export type Action =
|
|
2
|
+
| { type: 'dismiss'; label: string; style: 'primary' | 'secondary' | 'ghost'; scope?: 'self' | 'all' }
|
|
3
|
+
| { type: 'next'; label: string; style: 'primary' | 'secondary' | 'ghost' }
|
|
4
|
+
| { type: 'back'; label: string; style: 'primary' | 'secondary' | 'ghost' }
|
|
5
|
+
| { type: 'prev'; label: string; style: 'primary' | 'secondary' | 'ghost' }
|
|
6
|
+
| { type: 'deep_link'; label: string; style: 'primary' | 'secondary' | 'ghost'; url: string; fallback_url?: string }
|
|
7
|
+
| { type: 'open_url'; label: string; style: 'primary' | 'secondary' | 'ghost'; url: string; presentation: 'external' | 'in_app' }
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export type TooltipStep = {
|
|
11
|
+
anchorKey: string
|
|
12
|
+
title: string
|
|
13
|
+
body: string
|
|
14
|
+
placement: 'top' | 'bottom' | 'left' | 'right' | 'auto'
|
|
15
|
+
backgroundColor: string
|
|
16
|
+
borderColor: string
|
|
17
|
+
borderWidth: number
|
|
18
|
+
cornerRadius: number
|
|
19
|
+
shadow: boolean
|
|
20
|
+
maxWidth: number
|
|
21
|
+
padding: number
|
|
22
|
+
showArrow: boolean
|
|
23
|
+
arrowColor?: string
|
|
24
|
+
arrowBorderColor?: string
|
|
25
|
+
arrowSize?: number
|
|
26
|
+
titleColor: string
|
|
27
|
+
titleSize: number
|
|
28
|
+
titleWeight: '400' | '600' | '700'
|
|
29
|
+
bodyColor: string
|
|
30
|
+
bodySize: number
|
|
31
|
+
buttonPrimaryBackgroundColor: string
|
|
32
|
+
buttonPrimaryTextColor: string
|
|
33
|
+
buttonGhostTextColor: string
|
|
34
|
+
actions: Action[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SpotlightStep = {
|
|
38
|
+
anchorKey: string
|
|
39
|
+
title: string
|
|
40
|
+
body: string
|
|
41
|
+
calloutPosition: 'above' | 'below' | 'left' | 'right' | 'auto'
|
|
42
|
+
calloutGap?: number
|
|
43
|
+
overlayColor: string
|
|
44
|
+
overlayOpacity: number
|
|
45
|
+
highlightShape: 'rect' | 'circle' | 'pill'
|
|
46
|
+
highlightCornerRadius: number
|
|
47
|
+
highlightPadding: number
|
|
48
|
+
highlightGlowColor: string
|
|
49
|
+
highlightGlowWidth: number
|
|
50
|
+
calloutBackgroundColor: string
|
|
51
|
+
calloutCornerRadius: number
|
|
52
|
+
calloutMaxWidth: number
|
|
53
|
+
calloutPadding: number
|
|
54
|
+
calloutShadow: boolean
|
|
55
|
+
calloutBorderColor: string
|
|
56
|
+
calloutBorderWidth: number
|
|
57
|
+
showArrow?: boolean
|
|
58
|
+
arrowColor?: string
|
|
59
|
+
arrowBorderColor?: string
|
|
60
|
+
arrowSize?: number
|
|
61
|
+
titleColor: string
|
|
62
|
+
titleSize: number
|
|
63
|
+
titleWeight: '400' | '600' | '700'
|
|
64
|
+
bodyColor: string
|
|
65
|
+
bodySize: number
|
|
66
|
+
buttonPrimaryBackgroundColor: string
|
|
67
|
+
buttonPrimaryTextColor: string
|
|
68
|
+
buttonGhostTextColor: string
|
|
69
|
+
actions: Action[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type TooltipConfig = {
|
|
73
|
+
templateType: 'tooltip'
|
|
74
|
+
templateId: string | null
|
|
75
|
+
steps: TooltipStep[]
|
|
76
|
+
outsideTapBehavior?: 'dismiss' | 'next' | 'nothing'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type SpotlightConfig = {
|
|
80
|
+
templateType: 'spotlight'
|
|
81
|
+
templateId: string | null
|
|
82
|
+
steps: SpotlightStep[]
|
|
83
|
+
outsideTapBehavior?: 'dismiss' | 'next' | 'nothing'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type CarouselItem = { imageUrl: string; deepLink?: string }
|
|
87
|
+
export type CarouselIndicatorConfig = {
|
|
88
|
+
showIndicator: boolean
|
|
89
|
+
dotHeight: number
|
|
90
|
+
dotWidth: number
|
|
91
|
+
spacing: number
|
|
92
|
+
dotColor: string
|
|
93
|
+
activeDotColor: string
|
|
94
|
+
indicatorEffectType: 'slide' | 'expanding' | 'worm' | 'scale' | 'jumping' | 'scrolling'
|
|
95
|
+
}
|
|
96
|
+
export type CarouselConfig = {
|
|
97
|
+
templateType: 'carousel'
|
|
98
|
+
slotKey: string
|
|
99
|
+
items: CarouselItem[]
|
|
100
|
+
height: number
|
|
101
|
+
width?: number
|
|
102
|
+
autoPlay: boolean
|
|
103
|
+
autoPlayInterval: number
|
|
104
|
+
animationDuration: number
|
|
105
|
+
infiniteScroll: boolean
|
|
106
|
+
viewportFraction: number
|
|
107
|
+
indicator: CarouselIndicatorConfig
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type SurveyTemplateConfig = {
|
|
111
|
+
templateType: 'survey'
|
|
112
|
+
templateId: string
|
|
113
|
+
surveyName: string
|
|
114
|
+
uiTemplateId: string | null
|
|
115
|
+
settings: Record<string, unknown>
|
|
116
|
+
blocks: Record<string, unknown>[]
|
|
117
|
+
nodes: Record<string, unknown>[]
|
|
118
|
+
rootNodeId: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type TemplateConfig = TooltipConfig | SpotlightConfig | CarouselConfig | SurveyTemplateConfig
|