@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.
Files changed (89) hide show
  1. package/README.md +147 -177
  2. package/android/build.gradle +2 -2
  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 +301 -3
  10. package/lib/commonjs/Digia.js.map +1 -1
  11. package/lib/commonjs/DigiaGuideController.js +59 -0
  12. package/lib/commonjs/DigiaGuideController.js.map +1 -0
  13. package/lib/commonjs/DigiaHealthReporter.js +45 -0
  14. package/lib/commonjs/DigiaHealthReporter.js.map +1 -0
  15. package/lib/commonjs/DigiaProvider.js +1079 -0
  16. package/lib/commonjs/DigiaProvider.js.map +1 -0
  17. package/lib/commonjs/DigiaSlotView.js +18 -3
  18. package/lib/commonjs/DigiaSlotView.js.map +1 -1
  19. package/lib/commonjs/NativeDigiaEngage.js +14 -8
  20. package/lib/commonjs/NativeDigiaEngage.js.map +1 -1
  21. package/lib/commonjs/actionHandler.js +316 -0
  22. package/lib/commonjs/actionHandler.js.map +1 -0
  23. package/lib/commonjs/defaultInAppBrowser.js +31 -0
  24. package/lib/commonjs/defaultInAppBrowser.js.map +1 -0
  25. package/lib/commonjs/digiaAnchorRegistry.js +32 -0
  26. package/lib/commonjs/digiaAnchorRegistry.js.map +1 -0
  27. package/lib/commonjs/index.js +7 -0
  28. package/lib/commonjs/index.js.map +1 -1
  29. package/lib/commonjs/templateTypes.js +2 -0
  30. package/lib/commonjs/templateTypes.js.map +1 -0
  31. package/lib/module/Digia.js +301 -3
  32. package/lib/module/Digia.js.map +1 -1
  33. package/lib/module/DigiaGuideController.js +53 -0
  34. package/lib/module/DigiaGuideController.js.map +1 -0
  35. package/lib/module/DigiaHealthReporter.js +38 -0
  36. package/lib/module/DigiaHealthReporter.js.map +1 -0
  37. package/lib/module/DigiaProvider.js +1072 -0
  38. package/lib/module/DigiaProvider.js.map +1 -0
  39. package/lib/module/DigiaSlotView.js +20 -5
  40. package/lib/module/DigiaSlotView.js.map +1 -1
  41. package/lib/module/NativeDigiaEngage.js +14 -8
  42. package/lib/module/NativeDigiaEngage.js.map +1 -1
  43. package/lib/module/actionHandler.js +311 -0
  44. package/lib/module/actionHandler.js.map +1 -0
  45. package/lib/module/defaultInAppBrowser.js +25 -0
  46. package/lib/module/defaultInAppBrowser.js.map +1 -0
  47. package/lib/module/digiaAnchorRegistry.js +26 -0
  48. package/lib/module/digiaAnchorRegistry.js.map +1 -0
  49. package/lib/module/index.js +1 -0
  50. package/lib/module/index.js.map +1 -1
  51. package/lib/module/templateTypes.js +2 -0
  52. package/lib/module/templateTypes.js.map +1 -0
  53. package/lib/typescript/Digia.d.ts +29 -2
  54. package/lib/typescript/Digia.d.ts.map +1 -1
  55. package/lib/typescript/DigiaGuideController.d.ts +30 -0
  56. package/lib/typescript/DigiaGuideController.d.ts.map +1 -0
  57. package/lib/typescript/DigiaHealthReporter.d.ts +24 -0
  58. package/lib/typescript/DigiaHealthReporter.d.ts.map +1 -0
  59. package/lib/typescript/DigiaProvider.d.ts +3 -0
  60. package/lib/typescript/DigiaProvider.d.ts.map +1 -0
  61. package/lib/typescript/DigiaSlotView.d.ts.map +1 -1
  62. package/lib/typescript/NativeDigiaEngage.d.ts +10 -6
  63. package/lib/typescript/NativeDigiaEngage.d.ts.map +1 -1
  64. package/lib/typescript/actionHandler.d.ts +20 -0
  65. package/lib/typescript/actionHandler.d.ts.map +1 -0
  66. package/lib/typescript/defaultInAppBrowser.d.ts +3 -0
  67. package/lib/typescript/defaultInAppBrowser.d.ts.map +1 -0
  68. package/lib/typescript/digiaAnchorRegistry.d.ts +15 -0
  69. package/lib/typescript/digiaAnchorRegistry.d.ts.map +1 -0
  70. package/lib/typescript/index.d.ts +1 -0
  71. package/lib/typescript/index.d.ts.map +1 -1
  72. package/lib/typescript/templateTypes.d.ts +140 -0
  73. package/lib/typescript/templateTypes.d.ts.map +1 -0
  74. package/lib/typescript/types.d.ts +140 -3
  75. package/lib/typescript/types.d.ts.map +1 -1
  76. package/package.json +12 -3
  77. package/react-native.config.js +23 -0
  78. package/src/Digia.ts +340 -3
  79. package/src/DigiaGuideController.ts +61 -0
  80. package/src/DigiaHealthReporter.ts +43 -0
  81. package/src/DigiaProvider.tsx +776 -0
  82. package/src/DigiaSlotView.tsx +26 -6
  83. package/src/NativeDigiaEngage.ts +28 -13
  84. package/src/actionHandler.ts +311 -0
  85. package/src/defaultInAppBrowser.ts +31 -0
  86. package/src/digiaAnchorRegistry.ts +27 -0
  87. package/src/index.ts +1 -0
  88. package/src/templateTypes.ts +121 -0
  89. 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
+ });