@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
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Dimensions,
|
|
5
|
+
Modal,
|
|
6
|
+
Pressable,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
View,
|
|
10
|
+
useWindowDimensions,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import { computePosition, flip, offset, shift } from '@floating-ui/core';
|
|
13
|
+
import Svg, { Path } from 'react-native-svg';
|
|
14
|
+
import { Digia } from './Digia';
|
|
15
|
+
import { digiaGuideController, type DigiaGuideRequest } from './DigiaGuideController';
|
|
16
|
+
import { digiaAnchorRegistry, type AnchorLayout } from './digiaAnchorRegistry';
|
|
17
|
+
import { digiaActionHandler, type ActionCallbacks } from './actionHandler';
|
|
18
|
+
import type { DismissReason } from './types';
|
|
19
|
+
import type { Action, SpotlightConfig, SpotlightStep, TooltipConfig, TooltipStep } from './templateTypes';
|
|
20
|
+
|
|
21
|
+
// ─── @floating-ui/core platform adapter ──────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const rnCorePlatform = {
|
|
24
|
+
getElementRects: ({ reference, floating }: any) => {
|
|
25
|
+
const r = typeof reference.getBoundingClientRect === 'function'
|
|
26
|
+
? reference.getBoundingClientRect()
|
|
27
|
+
: { x: 0, y: 0, width: 0, height: 0 };
|
|
28
|
+
return {
|
|
29
|
+
reference: { x: r.x ?? r.left ?? 0, y: r.y ?? r.top ?? 0, width: r.width, height: r.height },
|
|
30
|
+
floating: { x: 0, y: 0, width: floating.w ?? 0, height: floating.h ?? 0 },
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
getDimensions: (element: any) => ({ width: element.w ?? element.width ?? 0, height: element.h ?? element.height ?? 0 }),
|
|
34
|
+
getClippingRect: () => {
|
|
35
|
+
const { width, height } = Dimensions.get('window');
|
|
36
|
+
return { x: 0, y: 0, width, height, top: 0, left: 0, bottom: height, right: width };
|
|
37
|
+
},
|
|
38
|
+
isElement: () => false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function makeVirtualRef(layout: AnchorLayout, padding = 0) {
|
|
42
|
+
return {
|
|
43
|
+
getBoundingClientRect: () => ({
|
|
44
|
+
x: layout.pageX - padding, y: layout.pageY - padding,
|
|
45
|
+
width: layout.width + padding * 2, height: layout.height + padding * 2,
|
|
46
|
+
top: layout.pageY - padding, left: layout.pageX - padding,
|
|
47
|
+
bottom: layout.pageY + layout.height + padding,
|
|
48
|
+
right: layout.pageX + layout.width + padding,
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type FloatPos = { x: number; y: number };
|
|
54
|
+
|
|
55
|
+
// ─── Arrow component ──────────────────────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// arrowOffset: pixel distance from the start of the side (left for top/bottom,
|
|
58
|
+
// top for left/right) to the arrow tip center. When provided the arrow points
|
|
59
|
+
// at the anchor; when omitted it falls back to centering.
|
|
60
|
+
|
|
61
|
+
function GuideArrow({
|
|
62
|
+
placement,
|
|
63
|
+
color,
|
|
64
|
+
borderColor,
|
|
65
|
+
size,
|
|
66
|
+
arrowOffset,
|
|
67
|
+
}: {
|
|
68
|
+
placement: string;
|
|
69
|
+
color: string;
|
|
70
|
+
borderColor: string;
|
|
71
|
+
size: number;
|
|
72
|
+
arrowOffset?: number;
|
|
73
|
+
}) {
|
|
74
|
+
const s1 = size + 1;
|
|
75
|
+
|
|
76
|
+
// Horizontal: used for top / bottom placements (left offset within bubble width)
|
|
77
|
+
const hWrap = (edge: 'top' | 'bottom') =>
|
|
78
|
+
arrowOffset !== undefined
|
|
79
|
+
? { [edge]: -s1, left: arrowOffset - s1, width: s1 * 2 }
|
|
80
|
+
: { [edge]: -s1, left: 0, right: 0 };
|
|
81
|
+
|
|
82
|
+
// Vertical: used for left / right placements (top offset within bubble height)
|
|
83
|
+
const vWrap = (edge: 'left' | 'right') =>
|
|
84
|
+
arrowOffset !== undefined
|
|
85
|
+
? { [edge]: -s1, top: arrowOffset - s1, height: s1 * 2 }
|
|
86
|
+
: { [edge]: -s1, top: 0, bottom: 0 };
|
|
87
|
+
|
|
88
|
+
if (placement === 'bottom' || placement === 'below') {
|
|
89
|
+
// bubble below anchor → arrow at TOP pointing ▲ up
|
|
90
|
+
return (
|
|
91
|
+
<View style={[arrowS.wrap, hWrap('top')]}>
|
|
92
|
+
<View style={{ position: 'relative', width: s1 * 2, height: s1, alignItems: 'center' }}>
|
|
93
|
+
<View style={{ position: 'absolute', top: 0, width: 0, height: 0, borderStyle: 'solid', borderLeftWidth: s1, borderRightWidth: s1, borderBottomWidth: s1, borderTopWidth: 0, borderLeftColor: 'transparent', borderRightColor: 'transparent', borderBottomColor: borderColor }} />
|
|
94
|
+
<View style={{ position: 'absolute', top: 1, width: 0, height: 0, borderStyle: 'solid', borderLeftWidth: size, borderRightWidth: size, borderBottomWidth: size, borderTopWidth: 0, borderLeftColor: 'transparent', borderRightColor: 'transparent', borderBottomColor: color }} />
|
|
95
|
+
</View>
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (placement === 'top' || placement === 'above') {
|
|
100
|
+
// bubble above anchor → arrow at BOTTOM pointing ▼ down
|
|
101
|
+
return (
|
|
102
|
+
<View style={[arrowS.wrap, hWrap('bottom')]}>
|
|
103
|
+
<View style={{ position: 'relative', width: s1 * 2, height: s1, alignItems: 'center' }}>
|
|
104
|
+
<View style={{ position: 'absolute', top: 0, width: 0, height: 0, borderStyle: 'solid', borderLeftWidth: s1, borderRightWidth: s1, borderTopWidth: s1, borderBottomWidth: 0, borderLeftColor: 'transparent', borderRightColor: 'transparent', borderTopColor: borderColor }} />
|
|
105
|
+
<View style={{ position: 'absolute', top: 0, width: 0, height: 0, borderStyle: 'solid', borderLeftWidth: size, borderRightWidth: size, borderTopWidth: size, borderBottomWidth: 0, borderLeftColor: 'transparent', borderRightColor: 'transparent', borderTopColor: color }} />
|
|
106
|
+
</View>
|
|
107
|
+
</View>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (placement === 'right') {
|
|
111
|
+
// bubble right of anchor → arrow at LEFT pointing ◀ left
|
|
112
|
+
return (
|
|
113
|
+
<View style={[arrowS.wrap, vWrap('left')]}>
|
|
114
|
+
<View style={{ position: 'relative', width: s1, height: s1 * 2, justifyContent: 'center' }}>
|
|
115
|
+
<View style={{ position: 'absolute', left: 0, width: 0, height: 0, borderStyle: 'solid', borderTopWidth: s1, borderBottomWidth: s1, borderRightWidth: s1, borderLeftWidth: 0, borderTopColor: 'transparent', borderBottomColor: 'transparent', borderRightColor: borderColor }} />
|
|
116
|
+
<View style={{ position: 'absolute', left: 1, width: 0, height: 0, borderStyle: 'solid', borderTopWidth: size, borderBottomWidth: size, borderRightWidth: size, borderLeftWidth: 0, borderTopColor: 'transparent', borderBottomColor: 'transparent', borderRightColor: color }} />
|
|
117
|
+
</View>
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (placement === 'left') {
|
|
122
|
+
// bubble left of anchor → arrow at RIGHT pointing ▶ right
|
|
123
|
+
return (
|
|
124
|
+
<View style={[arrowS.wrap, vWrap('right')]}>
|
|
125
|
+
<View style={{ position: 'relative', width: s1, height: s1 * 2, justifyContent: 'center' }}>
|
|
126
|
+
<View style={{ position: 'absolute', right: 0, width: 0, height: 0, borderStyle: 'solid', borderTopWidth: s1, borderBottomWidth: s1, borderLeftWidth: s1, borderRightWidth: 0, borderTopColor: 'transparent', borderBottomColor: 'transparent', borderLeftColor: borderColor }} />
|
|
127
|
+
<View style={{ position: 'absolute', right: 1, width: 0, height: 0, borderStyle: 'solid', borderTopWidth: size, borderBottomWidth: size, borderLeftWidth: size, borderRightWidth: 0, borderTopColor: 'transparent', borderBottomColor: 'transparent', borderLeftColor: color }} />
|
|
128
|
+
</View>
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const arrowS = StyleSheet.create({
|
|
136
|
+
wrap: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── Arrow offset helper ──────────────────────────────────────────────────────
|
|
140
|
+
// Returns the pixel distance from the start of the bubble side (left for
|
|
141
|
+
// top/bottom, top for left/right) to where the arrow tip should point,
|
|
142
|
+
// clamped so the arrow stays inside the bubble's rounded corners.
|
|
143
|
+
|
|
144
|
+
function calcArrowOffset(
|
|
145
|
+
placement: string,
|
|
146
|
+
layout: AnchorLayout,
|
|
147
|
+
floatPos: FloatPos,
|
|
148
|
+
bubbleW: number,
|
|
149
|
+
bubbleH: number,
|
|
150
|
+
cornerRadius: number,
|
|
151
|
+
arrowSize: number,
|
|
152
|
+
): number {
|
|
153
|
+
const minPad = cornerRadius + arrowSize + 2;
|
|
154
|
+
const isHoriz = placement === 'top' || placement === 'bottom' || placement === 'above' || placement === 'below';
|
|
155
|
+
if (isHoriz) {
|
|
156
|
+
const anchorCenterX = layout.pageX + layout.width / 2;
|
|
157
|
+
const raw = anchorCenterX - floatPos.x;
|
|
158
|
+
return Math.max(minPad, Math.min(bubbleW - minPad, raw));
|
|
159
|
+
}
|
|
160
|
+
const anchorCenterY = layout.pageY + layout.height / 2;
|
|
161
|
+
const raw = anchorCenterY - floatPos.y;
|
|
162
|
+
return Math.max(minPad, Math.min(bubbleH - minPad, raw));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Shared action helpers ────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function ActionButton({
|
|
168
|
+
action,
|
|
169
|
+
btnPrimaryBg,
|
|
170
|
+
btnPrimaryText,
|
|
171
|
+
btnGhostText,
|
|
172
|
+
onPress,
|
|
173
|
+
}: {
|
|
174
|
+
action: Action;
|
|
175
|
+
btnPrimaryBg: string;
|
|
176
|
+
btnPrimaryText: string;
|
|
177
|
+
btnGhostText: string;
|
|
178
|
+
onPress: () => void;
|
|
179
|
+
}) {
|
|
180
|
+
const isPrimary = action.style === 'primary';
|
|
181
|
+
const fontFamily = Digia.fontFamily;
|
|
182
|
+
return (
|
|
183
|
+
<Pressable
|
|
184
|
+
onPress={onPress}
|
|
185
|
+
style={[s.button, isPrimary && { backgroundColor: btnPrimaryBg }]}
|
|
186
|
+
>
|
|
187
|
+
<Text style={{ color: isPrimary ? btnPrimaryText : btnGhostText, fontSize: 13, fontWeight: '600', fontFamily }}>
|
|
188
|
+
{action.label}
|
|
189
|
+
</Text>
|
|
190
|
+
</Pressable>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Tooltip overlay ──────────────────────────────────────────────────────────
|
|
195
|
+
// Rendered WITHOUT a Modal so sticky tooltips do not block underlying scrolls.
|
|
196
|
+
// DigiaHost must be placed at the app root level (after NavigationContainer)
|
|
197
|
+
// for absoluteFill to cover the full screen.
|
|
198
|
+
|
|
199
|
+
function TooltipOverlay({
|
|
200
|
+
request,
|
|
201
|
+
config,
|
|
202
|
+
}: {
|
|
203
|
+
request: DigiaGuideRequest;
|
|
204
|
+
config: TooltipConfig;
|
|
205
|
+
}) {
|
|
206
|
+
const [stepIndex, setStepIndex] = useState(0);
|
|
207
|
+
const [layout, setLayout] = useState<AnchorLayout | null>(null);
|
|
208
|
+
const [floatPos, setFloatPos] = useState<FloatPos | null>(null);
|
|
209
|
+
const [resolvedPlacement, setResolvedPlacement] = useState<string>('bottom');
|
|
210
|
+
const [floatingSize, setFloatingSize] = useState<{ w: number; h: number } | null>(null);
|
|
211
|
+
const step = config.steps[stepIndex];
|
|
212
|
+
const { width: screenW } = useWindowDimensions();
|
|
213
|
+
const opacityAnim = useRef(new Animated.Value(1)).current;
|
|
214
|
+
const pendingFadeIn = useRef(false);
|
|
215
|
+
const fontFamily = Digia.fontFamily;
|
|
216
|
+
|
|
217
|
+
const arrowSize = step.arrowSize ?? 8;
|
|
218
|
+
const showArrow = step.showArrow !== false;
|
|
219
|
+
const gap = showArrow ? arrowSize + 4 : 8;
|
|
220
|
+
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
setLayout(null);
|
|
223
|
+
setFloatPos(null);
|
|
224
|
+
return digiaAnchorRegistry.subscribe(step.anchorKey, (l) => {
|
|
225
|
+
setLayout(l);
|
|
226
|
+
});
|
|
227
|
+
}, [step.anchorKey]);
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!layout || !floatingSize) return;
|
|
231
|
+
const tooltipW = Math.min(step.maxWidth, screenW - 32);
|
|
232
|
+
const fpPlacement = (step.placement === 'auto' ? 'bottom' : step.placement) as any;
|
|
233
|
+
computePosition(
|
|
234
|
+
makeVirtualRef(layout),
|
|
235
|
+
{ w: Math.min(tooltipW, floatingSize.w), h: floatingSize.h },
|
|
236
|
+
{
|
|
237
|
+
platform: rnCorePlatform as any,
|
|
238
|
+
placement: fpPlacement,
|
|
239
|
+
middleware: [offset(gap), flip(), shift({ padding: 16 })],
|
|
240
|
+
},
|
|
241
|
+
).then(({ x, y, placement }) => {
|
|
242
|
+
setFloatPos({ x, y });
|
|
243
|
+
setResolvedPlacement(placement as string);
|
|
244
|
+
});
|
|
245
|
+
}, [layout, floatingSize, step.placement, step.maxWidth, screenW, gap]);
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (floatPos && pendingFadeIn.current) {
|
|
249
|
+
pendingFadeIn.current = false;
|
|
250
|
+
Animated.timing(opacityAnim, { toValue: 1, duration: 180, useNativeDriver: true }).start();
|
|
251
|
+
}
|
|
252
|
+
}, [floatPos, opacityAnim]);
|
|
253
|
+
|
|
254
|
+
// Fire viewed/step_viewed when the step renders.
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const isMultiStep = config.steps.length > 1;
|
|
257
|
+
if (stepIndex === 0) {
|
|
258
|
+
request.onExperienceEvent({ type: 'viewed', stepIndex: 0, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip' });
|
|
259
|
+
}
|
|
260
|
+
if (isMultiStep) {
|
|
261
|
+
request.onExperienceEvent({ type: 'step_viewed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip' });
|
|
262
|
+
}
|
|
263
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
264
|
+
}, [stepIndex]);
|
|
265
|
+
|
|
266
|
+
// Closes guide without firing analytics — used after CTA actions have already fired clicked events.
|
|
267
|
+
const closeFromCTA = useCallback(() => {
|
|
268
|
+
Animated.timing(opacityAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
|
|
269
|
+
digiaGuideController.cancel(request.payloadId);
|
|
270
|
+
});
|
|
271
|
+
}, [opacityAnim, request]);
|
|
272
|
+
|
|
273
|
+
// Fires dismissed analytics then closes — used for non-CTA dismissals (scrim, back gesture).
|
|
274
|
+
const dismiss = useCallback((reason: DismissReason = 'scrim_tap') => {
|
|
275
|
+
Animated.timing(opacityAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
|
|
276
|
+
const isMultiStep = config.steps.length > 1;
|
|
277
|
+
request.onExperienceEvent({ type: 'dismissed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip', dismissReason: reason });
|
|
278
|
+
if (isMultiStep) {
|
|
279
|
+
request.onExperienceEvent({ type: 'step_dismissed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip', dismissReason: reason });
|
|
280
|
+
}
|
|
281
|
+
digiaGuideController.cancel(request.payloadId);
|
|
282
|
+
});
|
|
283
|
+
}, [request, opacityAnim, step, config, stepIndex]);
|
|
284
|
+
|
|
285
|
+
const stepTo = useCallback((newIndex: number | null) => {
|
|
286
|
+
Animated.timing(opacityAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
|
|
287
|
+
if (newIndex === null) {
|
|
288
|
+
digiaGuideController.cancel(request.payloadId);
|
|
289
|
+
} else {
|
|
290
|
+
pendingFadeIn.current = true;
|
|
291
|
+
setStepIndex(newIndex);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}, [opacityAnim, request]);
|
|
295
|
+
|
|
296
|
+
const next = useCallback(() => stepTo(stepIndex < config.steps.length - 1 ? stepIndex + 1 : null), [stepIndex, config.steps.length, stepTo]);
|
|
297
|
+
const prev = useCallback(() => { if (stepIndex > 0) stepTo(stepIndex - 1); }, [stepIndex, stepTo]);
|
|
298
|
+
|
|
299
|
+
const actionCallbacks = useCallback((): ActionCallbacks => ({
|
|
300
|
+
onNext: next,
|
|
301
|
+
onBack: prev,
|
|
302
|
+
onDismissSelf: closeFromCTA,
|
|
303
|
+
onDismissAll: closeFromCTA,
|
|
304
|
+
}), [next, prev, closeFromCTA]);
|
|
305
|
+
|
|
306
|
+
const tooltipW = Math.min(step.maxWidth, screenW - 32);
|
|
307
|
+
|
|
308
|
+
const arrowOffset = (showArrow && floatPos && layout && floatingSize)
|
|
309
|
+
? calcArrowOffset(resolvedPlacement, layout, floatPos, tooltipW, floatingSize.h, step.cornerRadius, arrowSize)
|
|
310
|
+
: undefined;
|
|
311
|
+
|
|
312
|
+
const handleBackdropPress = useCallback(() => {
|
|
313
|
+
const behavior = config.outsideTapBehavior ?? 'next';
|
|
314
|
+
if (behavior === 'nothing') return;
|
|
315
|
+
if (behavior === 'next') next();
|
|
316
|
+
if (behavior === 'dismiss') dismiss();
|
|
317
|
+
}, [config.outsideTapBehavior, next, dismiss]);
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<Modal transparent statusBarTranslucent animationType="none" visible>
|
|
321
|
+
<Animated.View style={[StyleSheet.absoluteFill, { opacity: opacityAnim }]}>
|
|
322
|
+
{/* Full-screen backdrop: blocks all touches (scroll, tap) */}
|
|
323
|
+
<Pressable style={StyleSheet.absoluteFill} onPress={handleBackdropPress} />
|
|
324
|
+
{floatPos ? (
|
|
325
|
+
// Bubble as Pressable so tapping the bubble body also advances
|
|
326
|
+
<Pressable
|
|
327
|
+
onLayout={(e) => {
|
|
328
|
+
const { width, height } = e.nativeEvent.layout;
|
|
329
|
+
if (floatingSize?.w !== width || floatingSize?.h !== height) {
|
|
330
|
+
setFloatingSize({ w: width, h: height });
|
|
331
|
+
}
|
|
332
|
+
}}
|
|
333
|
+
onPress={handleBackdropPress}
|
|
334
|
+
style={[
|
|
335
|
+
s.tooltipBubble,
|
|
336
|
+
{
|
|
337
|
+
left: floatPos.x,
|
|
338
|
+
top: floatPos.y,
|
|
339
|
+
width: tooltipW,
|
|
340
|
+
backgroundColor: step.backgroundColor,
|
|
341
|
+
borderRadius: step.cornerRadius,
|
|
342
|
+
borderWidth: step.borderWidth,
|
|
343
|
+
borderColor: step.borderColor,
|
|
344
|
+
padding: step.padding,
|
|
345
|
+
},
|
|
346
|
+
step.shadow && s.shadow,
|
|
347
|
+
]}
|
|
348
|
+
>
|
|
349
|
+
{showArrow && (
|
|
350
|
+
<GuideArrow
|
|
351
|
+
placement={resolvedPlacement}
|
|
352
|
+
color={step.arrowColor ?? step.backgroundColor}
|
|
353
|
+
borderColor={step.arrowBorderColor ?? step.borderColor}
|
|
354
|
+
size={arrowSize}
|
|
355
|
+
arrowOffset={arrowOffset}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
<Text style={{ color: step.titleColor, fontSize: step.titleSize, fontWeight: step.titleWeight, fontFamily }}>
|
|
359
|
+
{step.title}
|
|
360
|
+
</Text>
|
|
361
|
+
{!!step.body && (
|
|
362
|
+
<Text style={{ marginTop: 4, color: step.bodyColor, fontSize: step.bodySize, fontFamily }}>
|
|
363
|
+
{step.body}
|
|
364
|
+
</Text>
|
|
365
|
+
)}
|
|
366
|
+
<View style={s.actionRow}>
|
|
367
|
+
{step.actions.map((action, i) => (
|
|
368
|
+
<ActionButton
|
|
369
|
+
key={i}
|
|
370
|
+
action={action}
|
|
371
|
+
btnPrimaryBg={step.buttonPrimaryBackgroundColor}
|
|
372
|
+
btnPrimaryText={step.buttonPrimaryTextColor}
|
|
373
|
+
btnGhostText={step.buttonGhostTextColor}
|
|
374
|
+
onPress={() => {
|
|
375
|
+
const isMultiStep = config.steps.length > 1;
|
|
376
|
+
const isLastStep = stepIndex === config.steps.length - 1;
|
|
377
|
+
const actionUrl = 'url' in action ? (action as { url: string }).url : undefined;
|
|
378
|
+
request.onExperienceEvent({ type: 'clicked', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip', ctaLabel: action.label, actionType: action.type, actionUrl });
|
|
379
|
+
if (isMultiStep) {
|
|
380
|
+
request.onExperienceEvent({ type: 'step_clicked', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip', ctaLabel: action.label, actionType: action.type, actionUrl });
|
|
381
|
+
}
|
|
382
|
+
if (isMultiStep && isLastStep && action.type !== 'back') {
|
|
383
|
+
request.onExperienceEvent({ type: 'completed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'tooltip' });
|
|
384
|
+
}
|
|
385
|
+
void digiaActionHandler.execute(action, {
|
|
386
|
+
campaign_id: request.payloadId,
|
|
387
|
+
campaign_key: request.campaignKey,
|
|
388
|
+
campaign_type: 'guide',
|
|
389
|
+
source: { kind: 'button', button_label: action.label },
|
|
390
|
+
step_index: stepIndex,
|
|
391
|
+
step_total: config.steps.length,
|
|
392
|
+
}, actionCallbacks());
|
|
393
|
+
}}
|
|
394
|
+
/>
|
|
395
|
+
))}
|
|
396
|
+
</View>
|
|
397
|
+
</Pressable>
|
|
398
|
+
) : (
|
|
399
|
+
// Off-screen measurement pass to determine bubble size before positioning.
|
|
400
|
+
<View
|
|
401
|
+
pointerEvents="none"
|
|
402
|
+
onLayout={(e) => setFloatingSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })}
|
|
403
|
+
style={[s.tooltipBubble, { left: -9999, top: -9999, width: tooltipW, padding: step.padding }]}
|
|
404
|
+
>
|
|
405
|
+
<Text style={{ fontSize: step.titleSize, fontFamily }}>{step.title}</Text>
|
|
406
|
+
{!!step.body && <Text style={{ fontSize: step.bodySize, fontFamily }}>{step.body}</Text>}
|
|
407
|
+
<View style={s.actionRow}>
|
|
408
|
+
{step.actions.map((a, i) => (
|
|
409
|
+
<View key={i} style={s.button}>
|
|
410
|
+
<Text style={{ fontSize: 13, fontFamily }}>{a.label}</Text>
|
|
411
|
+
</View>
|
|
412
|
+
))}
|
|
413
|
+
</View>
|
|
414
|
+
</View>
|
|
415
|
+
)}
|
|
416
|
+
</Animated.View>
|
|
417
|
+
</Modal>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ─── Spotlight overlay ────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
function buildCutoutPath(
|
|
424
|
+
x: number, y: number, w: number, h: number,
|
|
425
|
+
radius: number, shape: string,
|
|
426
|
+
): string {
|
|
427
|
+
if (shape === 'circle') {
|
|
428
|
+
const cx = x + w / 2;
|
|
429
|
+
const cy = y + h / 2;
|
|
430
|
+
const r = Math.max(w, h) / 2;
|
|
431
|
+
return `M${cx - r},${cy} a${r},${r} 0 1,0 ${r * 2},0 a${r},${r} 0 1,0 -${r * 2},0 Z`;
|
|
432
|
+
}
|
|
433
|
+
if (shape === 'pill') {
|
|
434
|
+
const r = h / 2;
|
|
435
|
+
return `M${x + r},${y} L${x + w - r},${y} Q${x + w},${y} ${x + w},${y + r} L${x + w},${y + h - r} Q${x + w},${y + h} ${x + w - r},${y + h} L${x + r},${y + h} Q${x},${y + h} ${x},${y + h - r} L${x},${y + r} Q${x},${y} ${x + r},${y} Z`;
|
|
436
|
+
}
|
|
437
|
+
const r = Math.max(0, radius);
|
|
438
|
+
if (r === 0) {
|
|
439
|
+
return `M${x},${y} L${x + w},${y} L${x + w},${y + h} L${x},${y + h} Z`;
|
|
440
|
+
}
|
|
441
|
+
return `M${x + r},${y} L${x + w - r},${y} Q${x + w},${y} ${x + w},${y + r} L${x + w},${y + h - r} Q${x + w},${y + h} ${x + w - r},${y + h} L${x + r},${y + h} Q${x},${y + h} ${x},${y + h - r} L${x},${y + r} Q${x},${y} ${x + r},${y} Z`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function SpotlightCallout({
|
|
445
|
+
step,
|
|
446
|
+
layout,
|
|
447
|
+
onActionPress,
|
|
448
|
+
}: {
|
|
449
|
+
step: SpotlightStep;
|
|
450
|
+
layout: AnchorLayout;
|
|
451
|
+
onActionPress: (action: Action) => void;
|
|
452
|
+
}) {
|
|
453
|
+
const { width: screenW } = useWindowDimensions();
|
|
454
|
+
const [floatPos, setFloatPos] = useState<FloatPos | null>(null);
|
|
455
|
+
const [resolvedPlacement, setResolvedPlacement] = useState<string>('below');
|
|
456
|
+
const [floatingSize, setFloatingSize] = useState<{ w: number; h: number } | null>(null);
|
|
457
|
+
const calloutW = Math.min(step.calloutMaxWidth, screenW - 32);
|
|
458
|
+
|
|
459
|
+
const arrowSize = step.arrowSize ?? 8;
|
|
460
|
+
const showArrow = step.showArrow !== false;
|
|
461
|
+
const gap = (step.calloutGap ?? 8) + (showArrow ? arrowSize : 0);
|
|
462
|
+
const fontFamily = Digia.fontFamily;
|
|
463
|
+
|
|
464
|
+
useEffect(() => {
|
|
465
|
+
if (!floatingSize) return;
|
|
466
|
+
const fpPlacement = (
|
|
467
|
+
step.calloutPosition === 'above' ? 'top'
|
|
468
|
+
: step.calloutPosition === 'below' ? 'bottom'
|
|
469
|
+
: step.calloutPosition === 'auto' ? 'bottom'
|
|
470
|
+
: step.calloutPosition
|
|
471
|
+
) as any;
|
|
472
|
+
computePosition(
|
|
473
|
+
makeVirtualRef(layout, step.highlightPadding),
|
|
474
|
+
{ w: Math.min(calloutW, floatingSize.w), h: floatingSize.h },
|
|
475
|
+
{
|
|
476
|
+
platform: rnCorePlatform as any,
|
|
477
|
+
placement: fpPlacement,
|
|
478
|
+
middleware: [offset(gap), flip(), shift({ padding: 16 })],
|
|
479
|
+
},
|
|
480
|
+
).then(({ x, y, placement }) => {
|
|
481
|
+
console.log('[Digia:spotlight] floatPos=', { x, y }, 'resolved=', placement);
|
|
482
|
+
setFloatPos({ x, y });
|
|
483
|
+
setResolvedPlacement(placement as string);
|
|
484
|
+
});
|
|
485
|
+
}, [layout, floatingSize, step.calloutPosition, calloutW, step.highlightPadding, gap]);
|
|
486
|
+
|
|
487
|
+
// Compute arrow offset: point arrow tip at anchor center.
|
|
488
|
+
const arrowOffset = (showArrow && floatPos && floatingSize)
|
|
489
|
+
? calcArrowOffset(resolvedPlacement, layout, floatPos, calloutW, floatingSize.h, step.calloutCornerRadius, arrowSize)
|
|
490
|
+
: undefined;
|
|
491
|
+
|
|
492
|
+
const calloutStyle = {
|
|
493
|
+
backgroundColor: step.calloutBackgroundColor,
|
|
494
|
+
borderRadius: step.calloutCornerRadius,
|
|
495
|
+
padding: step.calloutPadding,
|
|
496
|
+
borderWidth: step.calloutBorderWidth,
|
|
497
|
+
borderColor: step.calloutBorderColor,
|
|
498
|
+
width: calloutW,
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
if (!floatPos) {
|
|
502
|
+
return (
|
|
503
|
+
<View
|
|
504
|
+
pointerEvents="none"
|
|
505
|
+
onLayout={(e) => setFloatingSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })}
|
|
506
|
+
style={[calloutStyle, { position: 'absolute', left: -9999, top: -9999 }]}
|
|
507
|
+
>
|
|
508
|
+
<Text style={{ fontSize: step.titleSize, fontFamily }}>{step.title}</Text>
|
|
509
|
+
{!!step.body && <Text style={{ marginTop: 4, fontSize: step.bodySize, fontFamily }}>{step.body}</Text>}
|
|
510
|
+
<View style={s.actionRow}>
|
|
511
|
+
{step.actions.map((a, i) => (
|
|
512
|
+
<View key={i} style={s.button}>
|
|
513
|
+
<Text style={{ fontSize: 13, fontFamily }}>{a.label}</Text>
|
|
514
|
+
</View>
|
|
515
|
+
))}
|
|
516
|
+
</View>
|
|
517
|
+
</View>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return (
|
|
522
|
+
<View
|
|
523
|
+
style={[
|
|
524
|
+
calloutStyle,
|
|
525
|
+
{ position: 'absolute', left: floatPos.x, top: floatPos.y },
|
|
526
|
+
step.calloutShadow && s.shadow,
|
|
527
|
+
]}
|
|
528
|
+
>
|
|
529
|
+
{showArrow && (
|
|
530
|
+
<GuideArrow
|
|
531
|
+
placement={resolvedPlacement}
|
|
532
|
+
color={step.arrowColor ?? step.calloutBackgroundColor}
|
|
533
|
+
borderColor={step.arrowBorderColor ?? step.calloutBorderColor}
|
|
534
|
+
size={arrowSize}
|
|
535
|
+
arrowOffset={arrowOffset}
|
|
536
|
+
/>
|
|
537
|
+
)}
|
|
538
|
+
<Text style={{ color: step.titleColor, fontSize: step.titleSize, fontWeight: step.titleWeight, fontFamily }}>
|
|
539
|
+
{step.title}
|
|
540
|
+
</Text>
|
|
541
|
+
{!!step.body && (
|
|
542
|
+
<Text style={{ marginTop: 4, color: step.bodyColor, fontSize: step.bodySize, fontFamily }}>
|
|
543
|
+
{step.body}
|
|
544
|
+
</Text>
|
|
545
|
+
)}
|
|
546
|
+
<View style={s.actionRow}>
|
|
547
|
+
{step.actions.map((action, i) => (
|
|
548
|
+
<ActionButton
|
|
549
|
+
key={i}
|
|
550
|
+
action={action}
|
|
551
|
+
btnPrimaryBg={step.buttonPrimaryBackgroundColor}
|
|
552
|
+
btnPrimaryText={step.buttonPrimaryTextColor}
|
|
553
|
+
btnGhostText={step.buttonGhostTextColor}
|
|
554
|
+
onPress={() => { onActionPress(action); }}
|
|
555
|
+
/>
|
|
556
|
+
))}
|
|
557
|
+
</View>
|
|
558
|
+
</View>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function SpotlightOverlay({
|
|
563
|
+
request,
|
|
564
|
+
config,
|
|
565
|
+
}: {
|
|
566
|
+
request: DigiaGuideRequest;
|
|
567
|
+
config: SpotlightConfig;
|
|
568
|
+
}) {
|
|
569
|
+
const [stepIndex, setStepIndex] = useState(0);
|
|
570
|
+
const [layout, setLayout] = useState<AnchorLayout | null>(null);
|
|
571
|
+
const step = config.steps[stepIndex];
|
|
572
|
+
const { width: screenW, height: screenH } = useWindowDimensions();
|
|
573
|
+
const opacityAnim = useRef(new Animated.Value(1)).current;
|
|
574
|
+
const pendingFadeIn = useRef(false);
|
|
575
|
+
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
setLayout(null);
|
|
578
|
+
return digiaAnchorRegistry.subscribe(step.anchorKey, (l) => {
|
|
579
|
+
setLayout(l);
|
|
580
|
+
});
|
|
581
|
+
}, [step.anchorKey]);
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
if (layout && pendingFadeIn.current) {
|
|
585
|
+
pendingFadeIn.current = false;
|
|
586
|
+
Animated.timing(opacityAnim, { toValue: 1, duration: 180, useNativeDriver: true }).start();
|
|
587
|
+
}
|
|
588
|
+
}, [layout, opacityAnim]);
|
|
589
|
+
|
|
590
|
+
// Fire viewed/step_viewed when the step renders.
|
|
591
|
+
useEffect(() => {
|
|
592
|
+
const isMultiStep = config.steps.length > 1;
|
|
593
|
+
if (stepIndex === 0) {
|
|
594
|
+
request.onExperienceEvent({ type: 'viewed', stepIndex: 0, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'spotlight' });
|
|
595
|
+
}
|
|
596
|
+
if (isMultiStep) {
|
|
597
|
+
request.onExperienceEvent({ type: 'step_viewed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'spotlight' });
|
|
598
|
+
}
|
|
599
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
600
|
+
}, [stepIndex]);
|
|
601
|
+
|
|
602
|
+
const closeFromCTA = useCallback(() => {
|
|
603
|
+
Animated.timing(opacityAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
|
|
604
|
+
digiaGuideController.cancel(request.payloadId);
|
|
605
|
+
});
|
|
606
|
+
}, [opacityAnim, request]);
|
|
607
|
+
|
|
608
|
+
const dismiss = useCallback((reason: DismissReason = 'scrim_tap') => {
|
|
609
|
+
Animated.timing(opacityAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
|
|
610
|
+
const isMultiStep = config.steps.length > 1;
|
|
611
|
+
request.onExperienceEvent({ type: 'dismissed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'spotlight', dismissReason: reason });
|
|
612
|
+
if (isMultiStep) {
|
|
613
|
+
request.onExperienceEvent({ type: 'step_dismissed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'spotlight', dismissReason: reason });
|
|
614
|
+
}
|
|
615
|
+
digiaGuideController.cancel(request.payloadId);
|
|
616
|
+
});
|
|
617
|
+
}, [request, opacityAnim, step, config, stepIndex]);
|
|
618
|
+
|
|
619
|
+
const stepTo = useCallback((newIndex: number | null) => {
|
|
620
|
+
Animated.timing(opacityAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
|
|
621
|
+
if (newIndex === null) {
|
|
622
|
+
digiaGuideController.cancel(request.payloadId);
|
|
623
|
+
} else {
|
|
624
|
+
pendingFadeIn.current = true;
|
|
625
|
+
setStepIndex(newIndex);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
}, [opacityAnim, request]);
|
|
629
|
+
|
|
630
|
+
const next = useCallback(() => stepTo(stepIndex < config.steps.length - 1 ? stepIndex + 1 : null), [stepIndex, config.steps.length, stepTo]);
|
|
631
|
+
const prev = useCallback(() => { if (stepIndex > 0) stepTo(stepIndex - 1); }, [stepIndex, stepTo]);
|
|
632
|
+
|
|
633
|
+
const actionCallbacks = useCallback((): ActionCallbacks => ({
|
|
634
|
+
onNext: next,
|
|
635
|
+
onBack: prev,
|
|
636
|
+
onDismissSelf: closeFromCTA,
|
|
637
|
+
onDismissAll: closeFromCTA,
|
|
638
|
+
}), [next, prev, closeFromCTA]);
|
|
639
|
+
|
|
640
|
+
const handleActionPress = useCallback((action: Action) => {
|
|
641
|
+
const isMultiStep = config.steps.length > 1;
|
|
642
|
+
const isLastStep = stepIndex === config.steps.length - 1;
|
|
643
|
+
const actionUrl = 'url' in action ? (action as { url: string }).url : undefined;
|
|
644
|
+
request.onExperienceEvent({ type: 'clicked', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'spotlight', ctaLabel: action.label, actionType: action.type, actionUrl });
|
|
645
|
+
if (isMultiStep) {
|
|
646
|
+
request.onExperienceEvent({ type: 'step_clicked', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'spotlight', ctaLabel: action.label, actionType: action.type, actionUrl });
|
|
647
|
+
}
|
|
648
|
+
if (isMultiStep && isLastStep && action.type !== 'back') {
|
|
649
|
+
request.onExperienceEvent({ type: 'completed', stepIndex, stepTotal: config.steps.length, anchorKey: step.anchorKey, displayStyle: 'spotlight' });
|
|
650
|
+
}
|
|
651
|
+
digiaActionHandler.execute(action, {
|
|
652
|
+
campaign_id: request.payloadId,
|
|
653
|
+
campaign_key: request.campaignKey,
|
|
654
|
+
campaign_type: 'guide',
|
|
655
|
+
source: { kind: 'button', button_label: action.label },
|
|
656
|
+
step_index: stepIndex,
|
|
657
|
+
step_total: config.steps.length,
|
|
658
|
+
}, actionCallbacks());
|
|
659
|
+
}, [request, stepIndex, config, step, actionCallbacks]);
|
|
660
|
+
|
|
661
|
+
const handleBackdropPress = useCallback(() => {
|
|
662
|
+
const behavior = config.outsideTapBehavior ?? 'next';
|
|
663
|
+
if (behavior === 'nothing') return;
|
|
664
|
+
if (behavior === 'next') next();
|
|
665
|
+
if (behavior === 'dismiss') dismiss('scrim_tap');
|
|
666
|
+
}, [config.outsideTapBehavior, next, dismiss]);
|
|
667
|
+
|
|
668
|
+
const pad = step.highlightPadding;
|
|
669
|
+
const cutoutX = layout ? layout.pageX - pad : 0;
|
|
670
|
+
const cutoutY = layout ? layout.pageY - pad : 0;
|
|
671
|
+
const cutoutW = layout ? layout.width + pad * 2 : 0;
|
|
672
|
+
const cutoutH = layout ? layout.height + pad * 2 : 0;
|
|
673
|
+
const screenPath = `M0,0 L${screenW},0 L${screenW},${screenH} L0,${screenH} Z`;
|
|
674
|
+
const cutoutPath = layout
|
|
675
|
+
? buildCutoutPath(cutoutX, cutoutY, cutoutW, cutoutH, step.highlightCornerRadius, step.highlightShape)
|
|
676
|
+
: '';
|
|
677
|
+
|
|
678
|
+
return (
|
|
679
|
+
<Modal transparent statusBarTranslucent animationType="none" visible>
|
|
680
|
+
<Animated.View style={[StyleSheet.absoluteFill, { opacity: opacityAnim }]} pointerEvents="box-none">
|
|
681
|
+
{layout && (
|
|
682
|
+
<>
|
|
683
|
+
<Svg
|
|
684
|
+
width={screenW}
|
|
685
|
+
height={screenH}
|
|
686
|
+
style={StyleSheet.absoluteFill}
|
|
687
|
+
pointerEvents="none"
|
|
688
|
+
>
|
|
689
|
+
<Path
|
|
690
|
+
fillRule="evenodd"
|
|
691
|
+
d={`${screenPath} ${cutoutPath}`}
|
|
692
|
+
fill={step.overlayColor}
|
|
693
|
+
fillOpacity={step.overlayOpacity}
|
|
694
|
+
/>
|
|
695
|
+
{step.highlightGlowWidth > 0 && (
|
|
696
|
+
<Path
|
|
697
|
+
d={cutoutPath}
|
|
698
|
+
fill="none"
|
|
699
|
+
stroke={step.highlightGlowColor}
|
|
700
|
+
strokeWidth={step.highlightGlowWidth}
|
|
701
|
+
/>
|
|
702
|
+
)}
|
|
703
|
+
</Svg>
|
|
704
|
+
{/* Backdrop with configurable outside-tap behaviour */}
|
|
705
|
+
<Pressable style={StyleSheet.absoluteFill} onPress={handleBackdropPress} />
|
|
706
|
+
<SpotlightCallout
|
|
707
|
+
step={step}
|
|
708
|
+
layout={layout}
|
|
709
|
+
onActionPress={handleActionPress}
|
|
710
|
+
/>
|
|
711
|
+
</>
|
|
712
|
+
)}
|
|
713
|
+
</Animated.View>
|
|
714
|
+
</Modal>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ─── Guide runtime dispatcher ─────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
function DigiaGuideRuntime() {
|
|
721
|
+
const [activeRequest, setActiveRequest] = useState<DigiaGuideRequest | null>(null);
|
|
722
|
+
|
|
723
|
+
useEffect(() => {
|
|
724
|
+
return digiaGuideController.subscribe((event) => {
|
|
725
|
+
if (event.type === 'cancel') {
|
|
726
|
+
setActiveRequest(null);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
setActiveRequest(event.request);
|
|
730
|
+
});
|
|
731
|
+
}, []);
|
|
732
|
+
|
|
733
|
+
if (!activeRequest) return null;
|
|
734
|
+
|
|
735
|
+
switch (activeRequest.config.templateType) {
|
|
736
|
+
case 'tooltip':
|
|
737
|
+
return <TooltipOverlay request={activeRequest} config={activeRequest.config} />;
|
|
738
|
+
case 'spotlight':
|
|
739
|
+
return <SpotlightOverlay request={activeRequest} config={activeRequest.config} />;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ─── DigiaHost ────────────────────────────────────────────────────────────────
|
|
744
|
+
|
|
745
|
+
export function DigiaHost() {
|
|
746
|
+
return <DigiaGuideRuntime />;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
const s = StyleSheet.create({
|
|
752
|
+
tooltipBubble: {
|
|
753
|
+
position: 'absolute',
|
|
754
|
+
},
|
|
755
|
+
shadow: {
|
|
756
|
+
shadowColor: '#000',
|
|
757
|
+
shadowOpacity: 0.15,
|
|
758
|
+
shadowRadius: 12,
|
|
759
|
+
shadowOffset: { width: 0, height: 4 },
|
|
760
|
+
elevation: 8,
|
|
761
|
+
},
|
|
762
|
+
actionRow: {
|
|
763
|
+
marginTop: 12,
|
|
764
|
+
flexDirection: 'row',
|
|
765
|
+
justifyContent: 'flex-end',
|
|
766
|
+
gap: 8,
|
|
767
|
+
},
|
|
768
|
+
button: {
|
|
769
|
+
minHeight: 32,
|
|
770
|
+
minWidth: 60,
|
|
771
|
+
alignItems: 'center',
|
|
772
|
+
justifyContent: 'center',
|
|
773
|
+
borderRadius: 8,
|
|
774
|
+
paddingHorizontal: 12,
|
|
775
|
+
},
|
|
776
|
+
});
|