@qoretechnologies/reqraft 0.10.56 → 0.10.57-pr.127.g0edd18d

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 (158) hide show
  1. package/.claude/CLAUDE.md +1 -0
  2. package/README.md +20 -0
  3. package/design/TRACKING.md +278 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +5 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/tracking/browser/attach.d.ts +8 -0
  9. package/dist/tracking/browser/attach.d.ts.map +1 -0
  10. package/dist/tracking/browser/attach.js +182 -0
  11. package/dist/tracking/browser/attach.js.map +1 -0
  12. package/dist/tracking/browser/env.d.ts +14 -0
  13. package/dist/tracking/browser/env.d.ts.map +1 -0
  14. package/dist/tracking/browser/env.js +150 -0
  15. package/dist/tracking/browser/env.js.map +1 -0
  16. package/dist/tracking/core/consent.d.ts +21 -0
  17. package/dist/tracking/core/consent.d.ts.map +1 -0
  18. package/dist/tracking/core/consent.js +39 -0
  19. package/dist/tracking/core/consent.js.map +1 -0
  20. package/dist/tracking/core/engagement.d.ts +20 -0
  21. package/dist/tracking/core/engagement.d.ts.map +1 -0
  22. package/dist/tracking/core/engagement.js +52 -0
  23. package/dist/tracking/core/engagement.js.map +1 -0
  24. package/dist/tracking/core/experiments.d.ts +109 -0
  25. package/dist/tracking/core/experiments.d.ts.map +1 -0
  26. package/dist/tracking/core/experiments.js +294 -0
  27. package/dist/tracking/core/experiments.js.map +1 -0
  28. package/dist/tracking/core/hash.d.ts +16 -0
  29. package/dist/tracking/core/hash.d.ts.map +1 -0
  30. package/dist/tracking/core/hash.js +31 -0
  31. package/dist/tracking/core/hash.js.map +1 -0
  32. package/dist/tracking/core/ids.d.ts +6 -0
  33. package/dist/tracking/core/ids.d.ts.map +1 -0
  34. package/dist/tracking/core/ids.js +31 -0
  35. package/dist/tracking/core/ids.js.map +1 -0
  36. package/dist/tracking/core/observable.d.ts +12 -0
  37. package/dist/tracking/core/observable.d.ts.map +1 -0
  38. package/dist/tracking/core/observable.js +32 -0
  39. package/dist/tracking/core/observable.js.map +1 -0
  40. package/dist/tracking/core/queue.d.ts +47 -0
  41. package/dist/tracking/core/queue.d.ts.map +1 -0
  42. package/dist/tracking/core/queue.js +205 -0
  43. package/dist/tracking/core/queue.js.map +1 -0
  44. package/dist/tracking/core/sections.d.ts +54 -0
  45. package/dist/tracking/core/sections.d.ts.map +1 -0
  46. package/dist/tracking/core/sections.js +156 -0
  47. package/dist/tracking/core/sections.js.map +1 -0
  48. package/dist/tracking/core/storage.d.ts +15 -0
  49. package/dist/tracking/core/storage.d.ts.map +1 -0
  50. package/dist/tracking/core/storage.js +72 -0
  51. package/dist/tracking/core/storage.js.map +1 -0
  52. package/dist/tracking/core/tracker.d.ts +122 -0
  53. package/dist/tracking/core/tracker.d.ts.map +1 -0
  54. package/dist/tracking/core/tracker.js +390 -0
  55. package/dist/tracking/core/tracker.js.map +1 -0
  56. package/dist/tracking/core/types.d.ts +130 -0
  57. package/dist/tracking/core/types.d.ts.map +1 -0
  58. package/dist/tracking/core/types.js +69 -0
  59. package/dist/tracking/core/types.js.map +1 -0
  60. package/dist/tracking/core/utm.d.ts +6 -0
  61. package/dist/tracking/core/utm.d.ts.map +1 -0
  62. package/dist/tracking/core/utm.js +30 -0
  63. package/dist/tracking/core/utm.js.map +1 -0
  64. package/dist/tracking/index.d.ts +46 -0
  65. package/dist/tracking/index.d.ts.map +1 -0
  66. package/dist/tracking/index.js +91 -0
  67. package/dist/tracking/index.js.map +1 -0
  68. package/dist/tracking/markup.d.ts +12 -0
  69. package/dist/tracking/markup.d.ts.map +1 -0
  70. package/dist/tracking/markup.js +19 -0
  71. package/dist/tracking/markup.js.map +1 -0
  72. package/dist/tracking/react/Experiment.d.ts +23 -0
  73. package/dist/tracking/react/Experiment.d.ts.map +1 -0
  74. package/dist/tracking/react/Experiment.js +28 -0
  75. package/dist/tracking/react/Experiment.js.map +1 -0
  76. package/dist/tracking/react/TrackingProvider.d.ts +26 -0
  77. package/dist/tracking/react/TrackingProvider.d.ts.map +1 -0
  78. package/dist/tracking/react/TrackingProvider.js +45 -0
  79. package/dist/tracking/react/TrackingProvider.js.map +1 -0
  80. package/dist/tracking/react/hooks.d.ts +39 -0
  81. package/dist/tracking/react/hooks.d.ts.map +1 -0
  82. package/dist/tracking/react/hooks.js +96 -0
  83. package/dist/tracking/react/hooks.js.map +1 -0
  84. package/dist/tracking/react/index.d.ts +7 -0
  85. package/dist/tracking/react/index.d.ts.map +1 -0
  86. package/dist/tracking/react/index.js +17 -0
  87. package/dist/tracking/react/index.js.map +1 -0
  88. package/dist/tracking/testing.d.ts +40 -0
  89. package/dist/tracking/testing.d.ts.map +1 -0
  90. package/dist/tracking/testing.js +140 -0
  91. package/dist/tracking/testing.js.map +1 -0
  92. package/dist/tracking/ui/ExperimentCard.d.ts +47 -0
  93. package/dist/tracking/ui/ExperimentCard.d.ts.map +1 -0
  94. package/dist/tracking/ui/ExperimentCard.js +305 -0
  95. package/dist/tracking/ui/ExperimentCard.js.map +1 -0
  96. package/dist/tracking/ui/ExperimentsOverlay.d.ts +53 -0
  97. package/dist/tracking/ui/ExperimentsOverlay.d.ts.map +1 -0
  98. package/dist/tracking/ui/ExperimentsOverlay.js +359 -0
  99. package/dist/tracking/ui/ExperimentsOverlay.js.map +1 -0
  100. package/dist/tracking/ui/anchor.d.ts +107 -0
  101. package/dist/tracking/ui/anchor.d.ts.map +1 -0
  102. package/dist/tracking/ui/anchor.js +108 -0
  103. package/dist/tracking/ui/anchor.js.map +1 -0
  104. package/dist/tracking/ui/card.d.ts +87 -0
  105. package/dist/tracking/ui/card.d.ts.map +1 -0
  106. package/dist/tracking/ui/card.js +287 -0
  107. package/dist/tracking/ui/card.js.map +1 -0
  108. package/dist/tracking/ui/client.d.ts +42 -0
  109. package/dist/tracking/ui/client.d.ts.map +1 -0
  110. package/dist/tracking/ui/client.js +123 -0
  111. package/dist/tracking/ui/client.js.map +1 -0
  112. package/dist/tracking/ui/index.d.ts +20 -0
  113. package/dist/tracking/ui/index.d.ts.map +1 -0
  114. package/dist/tracking/ui/index.js +54 -0
  115. package/dist/tracking/ui/index.js.map +1 -0
  116. package/dist/tracking/ui/types.d.ts +111 -0
  117. package/dist/tracking/ui/types.d.ts.map +1 -0
  118. package/dist/tracking/ui/types.js +8 -0
  119. package/dist/tracking/ui/types.js.map +1 -0
  120. package/dist/tracking/ui/useExperimentsAdmin.d.ts +31 -0
  121. package/dist/tracking/ui/useExperimentsAdmin.d.ts.map +1 -0
  122. package/dist/tracking/ui/useExperimentsAdmin.js +136 -0
  123. package/dist/tracking/ui/useExperimentsAdmin.js.map +1 -0
  124. package/package.json +1 -1
  125. package/src/index.tsx +5 -0
  126. package/src/stories/tracking.tsx +197 -0
  127. package/src/tracking/browser/attach.ts +186 -0
  128. package/src/tracking/browser/env.ts +78 -0
  129. package/src/tracking/core/consent.ts +37 -0
  130. package/src/tracking/core/engagement.ts +47 -0
  131. package/src/tracking/core/experiments.ts +258 -0
  132. package/src/tracking/core/hash.ts +26 -0
  133. package/src/tracking/core/ids.ts +25 -0
  134. package/src/tracking/core/observable.ts +23 -0
  135. package/src/tracking/core/queue.ts +131 -0
  136. package/src/tracking/core/sections.ts +166 -0
  137. package/src/tracking/core/storage.ts +69 -0
  138. package/src/tracking/core/tracker.ts +417 -0
  139. package/src/tracking/core/types.ts +162 -0
  140. package/src/tracking/core/utm.ts +23 -0
  141. package/src/tracking/index.ts +72 -0
  142. package/src/tracking/markup.ts +17 -0
  143. package/src/tracking/react/Experiment.stories.tsx +174 -0
  144. package/src/tracking/react/Experiment.tsx +44 -0
  145. package/src/tracking/react/TrackingProvider.tsx +56 -0
  146. package/src/tracking/react/hooks.ts +106 -0
  147. package/src/tracking/react/index.ts +13 -0
  148. package/src/tracking/testing.ts +108 -0
  149. package/src/tracking/ui/ExperimentCard.stories.tsx +236 -0
  150. package/src/tracking/ui/ExperimentCard.tsx +491 -0
  151. package/src/tracking/ui/ExperimentsOverlay.stories.tsx +148 -0
  152. package/src/tracking/ui/ExperimentsOverlay.tsx +423 -0
  153. package/src/tracking/ui/anchor.ts +180 -0
  154. package/src/tracking/ui/card.ts +323 -0
  155. package/src/tracking/ui/client.ts +117 -0
  156. package/src/tracking/ui/index.ts +71 -0
  157. package/src/tracking/ui/types.ts +104 -0
  158. package/src/tracking/ui/useExperimentsAdmin.ts +103 -0
@@ -0,0 +1,423 @@
1
+ import {
2
+ ReqoreButton,
3
+ ReqoreTag,
4
+ useReqoreProperty,
5
+ useReqoreTheme,
6
+ } from '@qoretechnologies/reqore';
7
+ import {
8
+ useCallback,
9
+ useEffect,
10
+ useLayoutEffect,
11
+ useReducer,
12
+ useRef,
13
+ useState,
14
+ type FocusEvent,
15
+ } from 'react';
16
+ import { createPortal } from 'react-dom';
17
+ import styled from 'styled-components';
18
+ import { useTracker } from '../react/TrackingProvider';
19
+ import {
20
+ cardReducer,
21
+ CLOSED,
22
+ openCard,
23
+ OUTSIDE_IGNORE,
24
+ placeCard,
25
+ type IPlacement,
26
+ } from './anchor';
27
+ import { winnerNote } from './card';
28
+ import { ExperimentCard } from './ExperimentCard';
29
+ import type { IExperimentEntry, TExperimentActionResult } from './types';
30
+
31
+ type TMaybeAsync<T = unknown> = T | Promise<T>;
32
+
33
+ export interface IExperimentsOverlayProps {
34
+ /** Draws nothing while false (an editor's "hide editor elements"). Default true. */
35
+ visible?: boolean;
36
+ /** The server's tests (the editor or admin list); null while loading. A test only in code still gets its versions. */
37
+ tests?: IExperimentEntry[] | null;
38
+ /** Why the list could not be loaded. */
39
+ loadError?: string | null;
40
+ /**
41
+ * Show a version on this page. Default: the tracker's in-app switch
42
+ * (`tracker.experiments.setEditorOverride`, never logged) when a `<TrackingProvider>` is above.
43
+ */
44
+ onShow?: (key: string, variant: string) => void;
45
+ onAccept?: (key: string, variant: string) => TMaybeAsync<TExperimentActionResult | unknown>;
46
+ onRemove?: (key: string, variant: string) => TMaybeAsync<TExperimentActionResult | unknown>;
47
+ onStart?: (key: string) => TMaybeAsync;
48
+ onPause?: (key: string) => TMaybeAsync;
49
+ onStop?: (key: string) => TMaybeAsync;
50
+ /**
51
+ * After an action succeeded. Default: the page shows what visitors see now (the switch is
52
+ * cleared, the tracker reloads the definitions and mounted experiments decide again).
53
+ */
54
+ onSettled?: (key: string) => TMaybeAsync;
55
+ /** The test's details page (the card's "Open in …" link); none when it returns nothing. */
56
+ detailsUrl?: (key: string) => string | null | undefined;
57
+ /** The link's label. Default `Open in admin portal`. */
58
+ detailsLabel?: string;
59
+ /** Who removed a version, in words, by the history's `actor`. */
60
+ actorLabels?: Record<string, string>;
61
+ /** The part of the viewport a sticky header covers (px, or read on every render). Default 0. */
62
+ topInset?: number | (() => number);
63
+ /** Markers inside elements matching this selector are ignored (e.g. an editor's own UI). */
64
+ exclude?: string;
65
+ /** The handle's words. Default `A/B test · <name>`. */
66
+ handleLabel?: (name: string) => string;
67
+ }
68
+
69
+ // Overlay chrome only: the outline around a tested element, never taking the pointer.
70
+ const StyledOutline = styled.div`
71
+ position: absolute;
72
+ pointer-events: none;
73
+ z-index: 880;
74
+ border-radius: 10px;
75
+ outline-width: 2px;
76
+ outline-style: dashed;
77
+ outline-offset: -3px;
78
+ `;
79
+
80
+ // Layout only: the handle and the card, in page coordinates so they scroll with the element.
81
+ const StyledFloat = styled.div<{ $card?: boolean }>`
82
+ position: absolute;
83
+ z-index: ${({ $card }) => ($card ? 956 : 955)};
84
+ filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.35));
85
+ `;
86
+
87
+ /** The overlay's own root; markers inside it are never outlined. */
88
+ export const OVERLAY_ATTRIBUTE = 'data-experiments-overlay';
89
+
90
+ interface IMarked {
91
+ key: string;
92
+ shown: string;
93
+ variants: string[];
94
+ reason: string;
95
+ box: DOMRect;
96
+ }
97
+
98
+ /** One box around the elements (viewport coordinates); tiny elements are ignored. */
99
+ export const boxOf = (els: Element[]): DOMRect | null => {
100
+ let box: { l: number; t: number; r: number; b: number } | null = null;
101
+ for (const el of els) {
102
+ const r = el.getBoundingClientRect();
103
+ if (r.width < 8 || r.height < 8) continue;
104
+ box =
105
+ box ?
106
+ {
107
+ l: Math.min(box.l, r.left),
108
+ t: Math.min(box.t, r.top),
109
+ r: Math.max(box.r, r.right),
110
+ b: Math.max(box.b, r.bottom),
111
+ }
112
+ : { l: r.left, t: r.top, r: r.right, b: r.bottom };
113
+ }
114
+ return box ? new DOMRect(box.l, box.t, box.r - box.l, box.b - box.t) : null;
115
+ };
116
+
117
+ const markedOnPage = (exclude?: string): IMarked[] =>
118
+ Array.from(document.querySelectorAll<HTMLElement>('[data-experiment]'))
119
+ .filter((el) => !el.closest(`[${OVERLAY_ATTRIBUTE}]`) && !(exclude && el.closest(exclude)))
120
+ .map((el) => {
121
+ const box = boxOf(Array.from(el.children));
122
+ return box ?
123
+ {
124
+ key: el.dataset.experiment ?? '',
125
+ shown: el.dataset.variant ?? '',
126
+ variants: (el.dataset.variants ?? '').split(',').filter(Boolean),
127
+ reason: el.dataset.experimentReason ?? '',
128
+ box,
129
+ }
130
+ : null;
131
+ })
132
+ .filter((m): m is IMarked => !!m && !!m.key);
133
+
134
+ const inside = (
135
+ r: { left: number; top: number; right: number; bottom: number },
136
+ x: number,
137
+ y: number
138
+ ) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
139
+
140
+ /** Re-renders the caller on scroll, resize and every 700 ms (content arriving), while `active`. */
141
+ const useFollowPage = (active: boolean) => {
142
+ const [, setTick] = useState(0);
143
+ useEffect(() => {
144
+ if (!active) return undefined;
145
+ let raf = 0;
146
+ const bump = () => {
147
+ cancelAnimationFrame(raf);
148
+ raf = requestAnimationFrame(() => setTick((t) => t + 1));
149
+ };
150
+ window.addEventListener('scroll', bump, { passive: true, capture: true });
151
+ window.addEventListener('resize', bump);
152
+ const id = window.setInterval(bump, 700);
153
+ return () => {
154
+ window.removeEventListener('scroll', bump, { capture: true });
155
+ window.removeEventListener('resize', bump);
156
+ window.clearInterval(id);
157
+ cancelAnimationFrame(raf);
158
+ };
159
+ }, [active]);
160
+ };
161
+
162
+ const HANDLE_ESTIMATE = { width: 240, height: 30 };
163
+
164
+ /**
165
+ * Every `<Experiment>` on the page (its `data-experiment` marker) gets a dashed outline and a
166
+ * handle, "A/B test · <name>", at its top-left corner. Hovering the element, focusing the handle,
167
+ * or clicking / tapping it opens the test's `<ExperimentCard>`, anchored to the element: it moves
168
+ * and resizes with it and flips above or below the handle to stay in view. ✕, Esc and a click
169
+ * outside close it. An ended test shows "Winner live" instead. Data and actions come in as props
170
+ * (`useExperimentsAdmin` provides them from the qorus-api routes).
171
+ */
172
+ export const ExperimentsOverlay = ({
173
+ visible = true,
174
+ tests,
175
+ loadError,
176
+ onShow,
177
+ onAccept,
178
+ onRemove,
179
+ onStart,
180
+ onPause,
181
+ onStop,
182
+ onSettled,
183
+ detailsUrl,
184
+ detailsLabel,
185
+ actorLabels,
186
+ topInset = 0,
187
+ exclude,
188
+ handleLabel = (name) => `A/B test · ${name}`,
189
+ }: IExperimentsOverlayProps) => {
190
+ const tracker = useTracker();
191
+ const theme = useReqoreTheme();
192
+ const hoverCapable = useReqoreProperty('isHoverCapable') !== false;
193
+ const [cards, dispatch] = useReducer(cardReducer, CLOSED);
194
+ const [sizes, setSizes] = useState<Record<string, { width: number; height: number }>>({});
195
+ const boxes = useRef(new Map<string, DOMRect>());
196
+ const floats = useRef(new Map<string, HTMLDivElement>());
197
+ const sides = useRef(new Map<string, 'below' | 'above'>());
198
+ const open = visible ? openCard(cards) : null;
199
+
200
+ const marked = visible && typeof document !== 'undefined' ? markedOnPage(exclude) : [];
201
+ useFollowPage(visible);
202
+ // A version switch, an accept or a removal re-renders the tested element; a resize moves it.
203
+ const [, setSeen] = useState(0);
204
+ const markedKeys = marked.map((m) => m.key).join(',');
205
+ useEffect(() => {
206
+ if (!visible) return undefined;
207
+ const bump = () => setSeen((n) => n + 1);
208
+ const mo = new MutationObserver(bump);
209
+ mo.observe(document.body, {
210
+ subtree: true,
211
+ attributes: true,
212
+ attributeFilter: ['data-variant', 'data-experiment-reason'],
213
+ });
214
+ const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(bump);
215
+ document.querySelectorAll('[data-experiment] > *').forEach((el) => ro?.observe(el));
216
+ return () => {
217
+ mo.disconnect();
218
+ ro?.disconnect();
219
+ };
220
+ }, [markedKeys, visible]);
221
+
222
+ // Hover: the pointer over a tested element, its handle or its card. Leaving waits a moment,
223
+ // so the pointer can cross the gap between the handle and the card.
224
+ useEffect(() => {
225
+ if (!hoverCapable || !visible) return undefined;
226
+ let leave = 0;
227
+ const onMove = (e: PointerEvent) => {
228
+ let hit: string | null = null;
229
+ boxes.current.forEach((box, key) => {
230
+ if (!hit && inside(box, e.clientX, e.clientY)) hit = key;
231
+ });
232
+ floats.current.forEach((el, id) => {
233
+ if (!hit && inside(el.getBoundingClientRect(), e.clientX, e.clientY))
234
+ hit = id.split('|')[0];
235
+ });
236
+ window.clearTimeout(leave);
237
+ if (hit) dispatch({ type: 'hover', key: hit });
238
+ else leave = window.setTimeout(() => dispatch({ type: 'hover', key: null }), 220);
239
+ };
240
+ document.addEventListener('pointermove', onMove, { passive: true });
241
+ return () => {
242
+ window.clearTimeout(leave);
243
+ document.removeEventListener('pointermove', onMove);
244
+ };
245
+ }, [hoverCapable, visible]);
246
+
247
+ // Esc and a click outside the card close it (a click in the confirm step it opened does not).
248
+ useEffect(() => {
249
+ if (!open) return undefined;
250
+ const onKey = (e: KeyboardEvent) => {
251
+ if (e.key === 'Escape') dispatch({ type: 'close' });
252
+ };
253
+ const onDown = (e: PointerEvent) => {
254
+ const target = e.target as Element | null;
255
+ if (target?.closest?.(OUTSIDE_IGNORE)) return;
256
+ dispatch({ type: 'close' });
257
+ };
258
+ document.addEventListener('keydown', onKey);
259
+ document.addEventListener('pointerdown', onDown, true);
260
+ return () => {
261
+ document.removeEventListener('keydown', onKey);
262
+ document.removeEventListener('pointerdown', onDown, true);
263
+ };
264
+ }, [open]);
265
+
266
+ // The handles' and the card's real sizes, for the placement.
267
+ useLayoutEffect(() => {
268
+ const next: Record<string, { width: number; height: number }> = {};
269
+ floats.current.forEach((el, id) => {
270
+ next[id] = { width: el.offsetWidth, height: el.offsetHeight };
271
+ });
272
+ const same =
273
+ Object.keys(next).length === Object.keys(sizes).length &&
274
+ Object.entries(next).every(
275
+ ([k, v]) => sizes[k]?.width === v.width && sizes[k]?.height === v.height
276
+ );
277
+ if (!same) setSizes(next);
278
+ });
279
+
280
+ /** After an action the page shows what visitors see now: no switch, fresh definitions, a new decision. */
281
+ const settle = useCallback(
282
+ async (key: string) => {
283
+ if (onSettled) {
284
+ await onSettled(key);
285
+ return;
286
+ }
287
+ tracker?.experiments.setEditorOverride(key, null);
288
+ await tracker?.experiments.refresh();
289
+ },
290
+ [onSettled, tracker]
291
+ );
292
+
293
+ const settled = <A extends unknown[]>(
294
+ key: string,
295
+ fn?: (key: string, ...args: A) => TMaybeAsync
296
+ ) =>
297
+ fn ?
298
+ async (...args: A) => {
299
+ const result = await fn(key, ...args);
300
+ await settle(key);
301
+ return result;
302
+ }
303
+ : undefined;
304
+
305
+ const show =
306
+ onShow ??
307
+ (tracker ?
308
+ (key: string, variant: string) => tracker.experiments.setEditorOverride(key, variant)
309
+ : undefined);
310
+
311
+ if (!visible || typeof document === 'undefined') return null;
312
+ boxes.current.clear();
313
+ const viewport = {
314
+ width: document.documentElement.clientWidth || window.innerWidth,
315
+ height: window.innerHeight,
316
+ };
317
+ const scroll = { x: window.scrollX, y: window.scrollY };
318
+ const inset = typeof topInset === 'function' ? topInset() : topInset;
319
+
320
+ const setFloat = (id: string) => (el: HTMLDivElement | null) => {
321
+ if (el) floats.current.set(id, el);
322
+ else floats.current.delete(id);
323
+ };
324
+
325
+ return createPortal(
326
+ <div {...{ [OVERLAY_ATTRIBUTE]: '' }}>
327
+ {marked.map((m) => {
328
+ const entry = tests?.find((e) => e.experiment.key === m.key);
329
+ const concluded = m.reason === 'concluded' || entry?.experiment.status === 'concluded';
330
+ const name = entry?.experiment.name || m.key;
331
+ const isOpen = !concluded && open === m.key;
332
+ const place: IPlacement = placeCard({
333
+ box: m.box,
334
+ viewport,
335
+ scroll,
336
+ handle: sizes[`${m.key}|handle`] ?? HANDLE_ESTIMATE,
337
+ card: isOpen ? (sizes[`${m.key}|card`] ?? { height: 420 }) : null,
338
+ topInset: inset,
339
+ side: sides.current.get(m.key),
340
+ });
341
+ if (place.card) sides.current.set(m.key, place.card.side);
342
+ else sides.current.delete(m.key);
343
+ boxes.current.set(m.key, m.box);
344
+ if (!place.visible) return null;
345
+ const onBlur = (e: FocusEvent<HTMLDivElement>) => {
346
+ if (
347
+ !(e.relatedTarget as Element | null)?.closest?.(`[data-experiment-float="${m.key}"]`)
348
+ ) {
349
+ dispatch({ type: 'blur', key: m.key });
350
+ }
351
+ };
352
+ const floatProps = {
353
+ 'data-experiment-float': m.key,
354
+ onFocus: () => dispatch({ type: 'focus', key: m.key }),
355
+ onBlur,
356
+ };
357
+ return (
358
+ <div key={m.key}>
359
+ <StyledOutline
360
+ style={{ ...place.outline, outlineColor: theme.intents?.info }}
361
+ data-experiment-outline={m.key}
362
+ />
363
+ <StyledFloat ref={setFloat(`${m.key}|handle`)} style={place.handle} {...floatProps}>
364
+ {concluded ?
365
+ <ReqoreTag
366
+ icon='TrophyLine'
367
+ label={winnerNote(entry, m.shown)}
368
+ tooltip='Every visitor sees this version. A developer removes the other versions’ code later.'
369
+ effect={{
370
+ gradient: { colors: { 0: 'success', 100: 'success:darken:1' } },
371
+ weight: 'bold',
372
+ }}
373
+ />
374
+ : <ReqoreButton
375
+ pill
376
+ size='small'
377
+ icon='FlaskLine'
378
+ intent='info'
379
+ active={isOpen}
380
+ aria-expanded={isOpen}
381
+ tooltip={isOpen ? undefined : 'A/B test: see its versions and numbers'}
382
+ onClick={() => dispatch({ type: 'toggle', key: m.key })}
383
+ >
384
+ {handleLabel(name)}
385
+ </ReqoreButton>
386
+ }
387
+ </StyledFloat>
388
+ {isOpen && place.card && (
389
+ <StyledFloat
390
+ $card
391
+ ref={setFloat(`${m.key}|card`)}
392
+ style={{ top: place.card.top, left: place.card.left, width: place.card.width }}
393
+ data-side={place.card.side}
394
+ {...floatProps}
395
+ >
396
+ <ExperimentCard
397
+ experimentKey={m.key}
398
+ test={entry}
399
+ name={name}
400
+ shown={m.shown}
401
+ codeVariants={m.variants}
402
+ maxHeight={place.card.maxHeight}
403
+ loadError={loadError}
404
+ detailsUrl={detailsUrl?.(m.key)}
405
+ detailsLabel={detailsLabel}
406
+ actorLabels={actorLabels}
407
+ onClose={() => dispatch({ type: 'close' })}
408
+ onShow={show ? (variant) => show(m.key, variant) : undefined}
409
+ onAccept={settled(m.key, onAccept)}
410
+ onRemove={settled(m.key, onRemove)}
411
+ onStart={settled(m.key, onStart)}
412
+ onPause={settled(m.key, onPause)}
413
+ onStop={settled(m.key, onStop)}
414
+ />
415
+ </StyledFloat>
416
+ )}
417
+ </div>
418
+ );
419
+ })}
420
+ </div>,
421
+ document.body
422
+ );
423
+ };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The plain logic behind the A/B test overlay: where the handle and the card go (anchored to the
3
+ * tested element, flipped above or below to stay in view), and which card is open (hover,
4
+ * keyboard focus, a click; closed by ✕, Esc or a click outside). No React, no DOM
5
+ * (unit-tested in `__tests__/tracking/anchor.test.ts`).
6
+ */
7
+
8
+ // ── anchoring ────────────────────────────────────────────────────────────────────
9
+
10
+ export interface IBox {
11
+ top: number;
12
+ left: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ export interface IPlaceInput {
18
+ /** The tested element's box, in viewport coordinates. */
19
+ box: IBox;
20
+ viewport: { width: number; height: number };
21
+ /** The page's scroll offset: the result is in document coordinates. */
22
+ scroll: { x: number; y: number };
23
+ /** The handle's measured size. */
24
+ handle: { width: number; height: number };
25
+ /** The card's measured size, when it is open (its natural height, before any cap). */
26
+ card?: { height: number } | null;
27
+ /** The part of the viewport the app's sticky header covers (the handle and the card stay below it). */
28
+ topInset: number;
29
+ /** The smallest distance to a viewport edge. */
30
+ edge?: number;
31
+ /** Between the handle and the card. */
32
+ gap?: number;
33
+ /** The card's widest. */
34
+ maxWidth?: number;
35
+ /** The side the card is on now: it stays there while it still fits, so it does not jump while scrolling. */
36
+ side?: 'below' | 'above';
37
+ }
38
+
39
+ export interface IPlacement {
40
+ /** False when no part of the element is on screen: nothing is drawn. */
41
+ visible: boolean;
42
+ /** Document coordinates. */
43
+ outline: IBox;
44
+ handle: { top: number; left: number };
45
+ card: {
46
+ top: number;
47
+ left: number;
48
+ width: number;
49
+ maxHeight: number;
50
+ side: 'below' | 'above';
51
+ } | null;
52
+ }
53
+
54
+ export const CARD_MAX_WIDTH = 440;
55
+ /** The card never gets shorter than this; a smaller window scrolls inside it. */
56
+ export const CARD_MIN_HEIGHT = 180;
57
+
58
+ const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), Math.max(lo, hi));
59
+
60
+ /**
61
+ * Where the outline, the handle and the card go. The handle sits at the element's top-left
62
+ * corner and, while the element is partly scrolled away, slides down to stay on its visible
63
+ * part. The card hangs under the handle, or above it when there is more room there than
64
+ * below and it does not fit below; its height is capped to that room (it scrolls inside).
65
+ */
66
+ export const placeCard = ({
67
+ box,
68
+ viewport,
69
+ scroll,
70
+ handle,
71
+ card,
72
+ topInset,
73
+ edge = 12,
74
+ gap = 8,
75
+ maxWidth = CARD_MAX_WIDTH,
76
+ side: current,
77
+ }: IPlaceInput): IPlacement => {
78
+ const bottom = box.top + box.height;
79
+ const right = box.left + box.width;
80
+ const outline = {
81
+ top: box.top + scroll.y,
82
+ left: box.left + scroll.x,
83
+ width: box.width,
84
+ height: box.height,
85
+ };
86
+ const visible =
87
+ bottom > topInset && box.top < viewport.height && right > 0 && box.left < viewport.width;
88
+
89
+ // The handle: inside the element, below the header, above the element's bottom edge.
90
+ const hTop = Math.min(Math.max(box.top + edge, topInset + edge), bottom - handle.height - edge);
91
+ const hLeft = clamp(box.left + edge, edge, viewport.width - handle.width - edge);
92
+ const handlePos = { top: Math.max(hTop, box.top) + scroll.y, left: hLeft + scroll.x };
93
+
94
+ if (!card) return { visible, outline, handle: handlePos, card: null };
95
+
96
+ const width = Math.min(maxWidth, viewport.width - 2 * edge);
97
+ const left = clamp(hLeft, edge, viewport.width - width - edge);
98
+ const handleTop = handlePos.top - scroll.y;
99
+ const handleBottom = handleTop + handle.height;
100
+ const below = viewport.height - edge - (handleBottom + gap);
101
+ const above = handleTop - gap - (topInset + edge);
102
+ const fits = { below: card.height <= below, above: card.height <= above };
103
+ const side: 'below' | 'above' =
104
+ current && fits[current] ? current
105
+ : fits.below || below >= above ? 'below'
106
+ : 'above';
107
+ const room = Math.max(side === 'below' ? below : above, CARD_MIN_HEIGHT);
108
+ const height = Math.min(card.height, room);
109
+ const top = side === 'below' ? handleBottom + gap : handleTop - gap - height;
110
+ return {
111
+ visible,
112
+ outline,
113
+ handle: handlePos,
114
+ card: { top: top + scroll.y, left: left + scroll.x, width, maxHeight: room, side },
115
+ };
116
+ };
117
+
118
+ // ── which card is open ────────────────────────────────────────────────────────────
119
+
120
+ export interface ICardState {
121
+ /** The test whose element (or card) the pointer is over. */
122
+ hovered: string | null;
123
+ /** The test whose handle or card holds the keyboard focus. */
124
+ focused: string | null;
125
+ /** The test opened with a click or a tap: stays open until closed. */
126
+ pinned: string | null;
127
+ /** Closed while the pointer was still over it: hovering it opens it again only after leaving it. */
128
+ dismissed: string | null;
129
+ }
130
+
131
+ export type TCardEvent =
132
+ | { type: 'hover'; key: string | null }
133
+ | { type: 'focus'; key: string }
134
+ | { type: 'blur'; key: string }
135
+ | { type: 'toggle'; key: string }
136
+ /** ✕, Esc, a click outside, an action that re-renders the element. */
137
+ | { type: 'close' };
138
+
139
+ export const CLOSED: ICardState = { hovered: null, focused: null, pinned: null, dismissed: null };
140
+
141
+ /** The one open card: a click wins, then the keyboard, then the pointer. */
142
+ export const openCard = (s: ICardState): string | null =>
143
+ s.pinned ?? s.focused ?? (s.hovered !== s.dismissed ? s.hovered : null);
144
+
145
+ export const cardReducer = (s: ICardState, e: TCardEvent): ICardState => {
146
+ switch (e.type) {
147
+ case 'hover':
148
+ if (e.key === s.hovered) return s;
149
+ // Leaving the element a card was closed on lets hovering open it again.
150
+ return {
151
+ ...s,
152
+ hovered: e.key,
153
+ dismissed: s.dismissed && s.dismissed !== e.key ? null : s.dismissed,
154
+ };
155
+ case 'focus':
156
+ return s.focused === e.key && s.dismissed !== e.key ?
157
+ s
158
+ : { ...s, focused: e.key, dismissed: s.dismissed === e.key ? null : s.dismissed };
159
+ case 'blur':
160
+ return s.focused === e.key ? { ...s, focused: null } : s;
161
+ case 'toggle':
162
+ // A click on the handle of an open card pins it (hover or focus opened it); a second click closes it.
163
+ return s.pinned === e.key ?
164
+ cardReducer(s, { type: 'close' })
165
+ : { ...s, pinned: e.key, dismissed: null };
166
+ case 'close': {
167
+ const open = openCard(s);
168
+ if (!open) return s;
169
+ return { hovered: s.hovered, focused: null, pinned: null, dismissed: s.hovered ?? open };
170
+ }
171
+ }
172
+ };
173
+
174
+ /** Enter or Space on a version's row itself (not on its Accept / Remove buttons) shows that version. */
175
+ export const activatesRow = (key: string, onRowItself: boolean) =>
176
+ onRowItself && (key === 'Enter' || key === ' ');
177
+
178
+ /** Whether a click lands outside the overlay's cards (and outside Reqore's dialogs and menus they open). */
179
+ export const OUTSIDE_IGNORE =
180
+ '[data-experiment-float], .reqore-modal, .reqore-popover-content, .reqore-drawer, .reqore-notification, [role="dialog"]';