@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,491 @@
1
+ import {
2
+ ReqoreCallout,
3
+ ReqoreEntityRow,
4
+ ReqoreP,
5
+ ReqorePanel,
6
+ ReqoreProgress,
7
+ ReqoreVerticalSpacer,
8
+ useReqoreProperty,
9
+ useReqoreTheme,
10
+ } from '@qoretechnologies/reqore';
11
+ import type { TReqoreBadge } from '@qoretechnologies/reqore/dist/components/Button';
12
+ import type { IReqoreEffect } from '@qoretechnologies/reqore/dist/components/Effect';
13
+ import type { IReqoreEntityRowAction } from '@qoretechnologies/reqore/dist/components/EntityRow';
14
+ import type { IReqorePanelBottomAction } from '@qoretechnologies/reqore/dist/components/Panel';
15
+ import type { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
16
+ import { getLuminance } from 'polished';
17
+ import {
18
+ useEffect,
19
+ useLayoutEffect,
20
+ useRef,
21
+ useState,
22
+ type KeyboardEvent as ReactKeyboardEvent,
23
+ } from 'react';
24
+ import styled from 'styled-components';
25
+ import { activatesRow } from './anchor';
26
+ import {
27
+ actionOutcome,
28
+ barValues,
29
+ canAccept,
30
+ canPause,
31
+ canRemove,
32
+ canStart,
33
+ canStop,
34
+ confirmCopy,
35
+ lifecycleCopy,
36
+ numbersLine,
37
+ phaseNote,
38
+ primaryMetric,
39
+ statusTag,
40
+ verdictIntent,
41
+ verdictLine,
42
+ versionRows,
43
+ versionsMaxHeight,
44
+ } from './card';
45
+ import type { IExperimentEntry, TExperimentAction, TExperimentActionResult } from './types';
46
+
47
+ type TMaybeAsync<T = unknown> = T | Promise<T>;
48
+
49
+ export interface IExperimentCardProps {
50
+ /** The experiment key (`data-experiment` of `<Experiment>`). */
51
+ experimentKey: string;
52
+ /** The server's experiment and its results; absent while the test is not on the server. */
53
+ test?: IExperimentEntry | null;
54
+ /** The card's title. Default: the experiment's name, else its key. */
55
+ name?: string;
56
+ /** The version this page shows now: its row is marked "Shown". */
57
+ shown?: string;
58
+ /** The versions the page's code renders (`data-variants`); a server version outside it cannot be shown. */
59
+ codeVariants?: string[];
60
+ /** The room the card has (px): the versions scroll inside when it is short. */
61
+ maxHeight?: number;
62
+ /** Why the numbers could not be loaded. */
63
+ loadError?: string | null;
64
+ /** An "Open in …" link at the start of the footer (a new tab). */
65
+ detailsUrl?: string | null;
66
+ /** The link's label. Default `Open in admin portal`. */
67
+ detailsLabel?: string;
68
+ /** Who removed a version, in words, by the history's `actor` (`{ 'landing-editor': 'in the editor' }`). */
69
+ actorLabels?: Record<string, string>;
70
+ /** Asks before Accept, Remove, Start, Pause and Stop (Reqore's confirm dialog). Default true. */
71
+ confirm?: boolean;
72
+ /** A notification after each action (what happened, or the server's error). Default true. */
73
+ notify?: boolean;
74
+ /** A click (or Enter / Space) on a version's row: show that version on this page. Rows are not clickable without it. */
75
+ onShow?: (variant: string) => void;
76
+ /** Each action appears only when its callback is given; a returned promise shows the button busy until it settles. */
77
+ onAccept?: (variant: string) => TMaybeAsync<TExperimentActionResult | unknown>;
78
+ onRemove?: (variant: string) => TMaybeAsync<TExperimentActionResult | unknown>;
79
+ onStart?: () => TMaybeAsync;
80
+ onPause?: () => TMaybeAsync;
81
+ onStop?: () => TMaybeAsync;
82
+ /** The ✕ in the header. */
83
+ onClose?: () => void;
84
+ }
85
+
86
+ // Solid tags: a badge with only an intent is a minimal tag, unreadable on a light theme.
87
+ const solid = (intent: 'success' | 'warning' | 'danger' | 'muted' | 'info'): IReqoreEffect => ({
88
+ gradient: { colors: { 0: intent, 100: `${intent}:darken:1` } },
89
+ weight: 'bold',
90
+ });
91
+
92
+ const ROW_GAP = 6;
93
+
94
+ // Layout only: the versions scroll inside the card past three and a half rows.
95
+ const StyledVersions = styled.div<{ $max: number | null }>`
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: ${ROW_GAP}px;
99
+ max-height: ${({ $max }) => ($max ? `${$max}px` : 'none')};
100
+ overflow-y: ${({ $max }) => ($max ? 'auto' : 'visible')};
101
+ overflow-x: hidden;
102
+ overscroll-behavior: contain;
103
+ min-width: 0;
104
+
105
+ /* The rows take the card's width, never their content's, padding included: a host page
106
+ without a global border-box reset would otherwise push each row past the card's edge. */
107
+ > * {
108
+ min-width: 0;
109
+ }
110
+ *,
111
+ *::before,
112
+ *::after {
113
+ box-sizing: border-box;
114
+ }
115
+ `;
116
+
117
+ /** Header, verdict and footer take about this much of `maxHeight`; the versions get the rest. */
118
+ const CHROME_HEIGHT = 230;
119
+
120
+ /**
121
+ * One A/B test: its name and status, the verdict in plain words, one row per version (visitors,
122
+ * conversion, chance to beat the original, a bar; click a row to show that version; Accept, and
123
+ * Remove at the far end), and the test's own controls (Start / Resume, Pause, Stop) with an
124
+ * optional link to its details. Driven only by props: no requests of its own.
125
+ */
126
+ export const ExperimentCard = ({
127
+ experimentKey,
128
+ test,
129
+ name,
130
+ shown,
131
+ codeVariants = [],
132
+ maxHeight,
133
+ loadError,
134
+ detailsUrl,
135
+ detailsLabel = 'Open in admin portal',
136
+ actorLabels,
137
+ confirm = true,
138
+ notify = true,
139
+ onShow,
140
+ onAccept,
141
+ onRemove,
142
+ onStart,
143
+ onPause,
144
+ onStop,
145
+ onClose,
146
+ }: IExperimentCardProps) => {
147
+ const entry = test ?? undefined;
148
+ const confirmAction = useReqoreProperty('confirmAction');
149
+ const addNotification = useReqoreProperty('addNotification');
150
+ const theme = useReqoreTheme();
151
+ const light = (() => {
152
+ try {
153
+ return getLuminance(theme.main) > 0.5;
154
+ } catch {
155
+ return false;
156
+ }
157
+ })();
158
+ const [busy, setBusy] = useState<string | null>(null);
159
+ const [rowHeight, setRowHeight] = useState(0);
160
+ const firstRow = useRef<HTMLDivElement>(null);
161
+ const mounted = useRef(true);
162
+ useEffect(
163
+ () => () => {
164
+ mounted.current = false;
165
+ },
166
+ []
167
+ );
168
+
169
+ const rows = versionRows(entry, codeVariants);
170
+ const bars = barValues(rows);
171
+ const metric = primaryMetric(entry?.results);
172
+ const status = entry?.experiment.status;
173
+ const title = name || entry?.experiment.name || experimentKey;
174
+ const tag = statusTag(entry);
175
+ const note = phaseNote(entry, actorLabels);
176
+ const busyOn = (action: string, variant = '') => busy === `${action}:${variant}`;
177
+
178
+ useLayoutEffect(() => {
179
+ const h = firstRow.current?.offsetHeight ?? 0;
180
+ if (h && h !== rowHeight) setRowHeight(h);
181
+ });
182
+ // A row's height changes with the card's width (a phone, a resized window): measure it again.
183
+ useEffect(() => {
184
+ const el = firstRow.current;
185
+ if (!el || typeof ResizeObserver === 'undefined') return undefined;
186
+ const ro = new ResizeObserver(() => setRowHeight(el.offsetHeight));
187
+ ro.observe(el);
188
+ return () => ro.disconnect();
189
+ }, []);
190
+
191
+ // Minimal buttons tint their text from the intent, too pale on a light theme: a deeper shade there.
192
+ const quiet = (intent?: 'success' | 'warning' | 'danger') => ({
193
+ minimal: true,
194
+ ...(light && intent ?
195
+ { effect: { color: `${intent}:darken:2` as const, weight: 'thick' as const } }
196
+ : {}),
197
+ });
198
+
199
+ const act = (action: TExperimentAction, variant?: string) => {
200
+ if (!entry) return;
201
+ const callbacks = {
202
+ accept: onAccept,
203
+ remove: onRemove,
204
+ start: onStart,
205
+ pause: onPause,
206
+ stop: onStop,
207
+ };
208
+ const callback = callbacks[action];
209
+ if (!callback) return;
210
+ const isVersion = action === 'accept' || action === 'remove';
211
+ const copy = isVersion ? confirmCopy(action, entry, variant) : lifecycleCopy(action, entry);
212
+ const titles: Record<TExperimentAction, [string, string]> = {
213
+ accept: ['Winner accepted', 'Could not accept'],
214
+ remove: ['Version removed', 'Could not remove'],
215
+ start: [status === 'paused' ? 'Test resumed' : 'Test started', 'Could not start'],
216
+ pause: ['Test paused', 'Could not pause'],
217
+ stop: ['Test stopped', 'Could not stop'],
218
+ };
219
+ const run = async () => {
220
+ setBusy(`${action}:${variant ?? ''}`);
221
+ try {
222
+ const result = await (callback as (v?: string) => TMaybeAsync)(variant);
223
+ if (notify) {
224
+ const content =
225
+ isVersion ?
226
+ actionOutcome(action, result as TExperimentActionResult, variant)
227
+ : `“${title}” is ${
228
+ action === 'start' ? 'running'
229
+ : action === 'pause' ? 'paused'
230
+ : 'stopped'
231
+ }.`;
232
+ addNotification?.({
233
+ intent: 'success',
234
+ title: titles[action][0],
235
+ content,
236
+ duration: 8000,
237
+ });
238
+ }
239
+ } catch (err) {
240
+ if (notify) {
241
+ addNotification?.({
242
+ intent: 'danger',
243
+ title: titles[action][1],
244
+ content: (err as Error)?.message ?? String(err),
245
+ duration: 10000,
246
+ });
247
+ }
248
+ } finally {
249
+ if (mounted.current) setBusy(null);
250
+ }
251
+ };
252
+ if (confirm && confirmAction) {
253
+ confirmAction({
254
+ title: copy.title,
255
+ description: copy.description,
256
+ confirmLabel: copy.confirmLabel,
257
+ confirmButtonIntent:
258
+ action === 'accept' || action === 'start' ? 'success'
259
+ : action === 'pause' ? 'warning'
260
+ : 'danger',
261
+ onConfirm: () => void run(),
262
+ });
263
+ } else {
264
+ void run();
265
+ }
266
+ };
267
+
268
+ const footer: IReqorePanelBottomAction[] = [];
269
+ if (detailsUrl) {
270
+ footer.push({
271
+ position: 'left',
272
+ icon: 'ExternalLinkLine',
273
+ label: detailsLabel,
274
+ responsive: false,
275
+ ...quiet(),
276
+ tooltip: 'This test’s details (a new tab)',
277
+ onClick: () => window.open(detailsUrl, '_blank', 'noopener'),
278
+ });
279
+ }
280
+ if (entry && status) {
281
+ // Every button in the card is minimal, except Start test / Resume: the one primary action.
282
+ if (onStart && canStart(status)) {
283
+ footer.push({
284
+ position: 'right',
285
+ icon: 'PlayLine',
286
+ intent: 'success',
287
+ label: status === 'paused' ? 'Resume' : 'Start test',
288
+ responsive: false,
289
+ disabled: !!busy,
290
+ loading: busyOn('start'),
291
+ tooltip:
292
+ status === 'paused' ?
293
+ 'Split visitors between the versions again'
294
+ : 'Start splitting visitors between the versions and counting',
295
+ onClick: () => act('start'),
296
+ });
297
+ }
298
+ if (onPause && canPause(status)) {
299
+ footer.push({
300
+ position: 'right',
301
+ icon: 'PauseLine',
302
+ intent: 'warning',
303
+ label: 'Pause',
304
+ responsive: false,
305
+ ...quiet('warning'),
306
+ disabled: !!busy,
307
+ loading: busyOn('pause'),
308
+ tooltip: 'Everybody sees the original until you resume',
309
+ onClick: () => act('pause'),
310
+ });
311
+ }
312
+ if (onStop && canStop(status)) {
313
+ footer.push({
314
+ position: 'right',
315
+ icon: 'StopLine',
316
+ intent: 'danger',
317
+ label: 'Stop',
318
+ responsive: false,
319
+ ...quiet('danger'),
320
+ disabled: !!busy,
321
+ loading: busyOn('stop'),
322
+ tooltip: 'End the test without a winner',
323
+ onClick: () => act('stop'),
324
+ });
325
+ }
326
+ }
327
+
328
+ const accept = status ? canAccept(status) : false;
329
+ const remove = status ? canRemove(status, rows.length) : false;
330
+ const listMax = versionsMaxHeight({
331
+ rows: rows.length,
332
+ rowHeight,
333
+ gap: ROW_GAP,
334
+ available: maxHeight ? Math.max(rowHeight, maxHeight - CHROME_HEIGHT) : undefined,
335
+ });
336
+
337
+ return (
338
+ <ReqorePanel
339
+ className='reqraft-experiment-card'
340
+ label={title}
341
+ icon='FlaskLine'
342
+ badge={{ label: tag.label, icon: tag.icon as IReqoreIconName, effect: solid(tag.intent) }}
343
+ size='small'
344
+ rounded
345
+ flat={false}
346
+ padded
347
+ collapsible={false}
348
+ onClose={onClose}
349
+ closeTooltip='Close (Esc)'
350
+ closeButtonProps={{ 'aria-label': 'Close the test card', minimal: true } as never}
351
+ bottomActions={footer.length ? footer : undefined}
352
+ >
353
+ <ReqoreCallout
354
+ size='small'
355
+ flat
356
+ icon='LineChartLine'
357
+ intent={loadError && !entry ? 'warning' : verdictIntent(entry)}
358
+ effect={{ weight: 500 }}
359
+ >
360
+ {loadError && !entry ?
361
+ `Could not load the test's numbers: ${loadError}`
362
+ : verdictLine(entry)}
363
+ </ReqoreCallout>
364
+ {note && (
365
+ <>
366
+ <ReqoreVerticalSpacer height={6} />
367
+ <ReqoreP size='small' effect={{ opacity: 0.7 }}>
368
+ {note}
369
+ </ReqoreP>
370
+ </>
371
+ )}
372
+ <ReqoreVerticalSpacer height={10} />
373
+ <ReqoreP size='small' effect={{ opacity: 0.75, weight: 500 }}>
374
+ {`${rows.length} versions${metric?.name ? ` · goal: “${metric.name}”` : ''}`}
375
+ </ReqoreP>
376
+ <ReqoreVerticalSpacer height={6} />
377
+ <StyledVersions
378
+ $max={listMax}
379
+ role='list'
380
+ aria-label='Versions'
381
+ className='reqraft-experiment-versions'
382
+ >
383
+ {rows.map((r, i) => {
384
+ const isShown = r.key === shown;
385
+ const badges: TReqoreBadge[] = [];
386
+ if (isShown) badges.push({ label: 'Shown', icon: 'EyeLine', effect: solid('info') });
387
+ if (r.control) badges.push({ label: 'Original', icon: 'HomeLine' });
388
+ if (!r.inCode)
389
+ badges.push({ label: 'Not in the code', icon: 'AlertLine', effect: solid('warning') });
390
+ // The whole row shows its version; Accept and Remove stop the click (Reqore's row actions do).
391
+ const showable = !!onShow && r.inCode && !isShown;
392
+ const actions: IReqoreEntityRowAction[] = [];
393
+ if (entry && onAccept) {
394
+ actions.push({
395
+ size: 'small',
396
+ icon: 'TrophyLine',
397
+ intent: 'success',
398
+ label: 'Accept',
399
+ ...quiet('success'),
400
+ disabled: !accept || !!busy,
401
+ loading: busyOn('accept', r.key),
402
+ tooltip:
403
+ accept ?
404
+ `End the test: every visitor sees “${r.name}”`
405
+ : 'Only a started test can be accepted',
406
+ onClick: () => act('accept', r.key),
407
+ });
408
+ }
409
+ if (entry && onRemove) {
410
+ actions.push({
411
+ size: 'small',
412
+ icon: 'DeleteBinLine',
413
+ intent: 'danger',
414
+ label: 'Remove',
415
+ ...quiet('danger'),
416
+ // At the far end of the row, away from Accept, so it is not clicked by accident.
417
+ style: { marginLeft: 'auto' },
418
+ disabled: !remove || !!busy,
419
+ loading: busyOn('remove', r.key),
420
+ tooltip:
421
+ remove ? `Drop “${r.name}” from the test` : 'This version cannot be removed now',
422
+ onClick: () => act('remove', r.key),
423
+ });
424
+ }
425
+ return (
426
+ <div
427
+ role='listitem'
428
+ key={r.key}
429
+ ref={i === 0 ? firstRow : undefined}
430
+ data-version={r.key}
431
+ aria-current={isShown || undefined}
432
+ >
433
+ <ReqoreEntityRow
434
+ size='small'
435
+ rounded
436
+ flat={!isShown}
437
+ intent={isShown ? 'info' : undefined}
438
+ label={r.name}
439
+ badge={badges}
440
+ description={numbersLine(r, metric?.name, status)}
441
+ metadata={
442
+ r.visitors ?
443
+ <ReqoreProgress
444
+ value={bars[i]}
445
+ size='tiny'
446
+ rounded
447
+ flat
448
+ style={{ width: '100%', minWidth: 160 }}
449
+ intent={
450
+ isShown ? 'info'
451
+ : r.control ?
452
+ 'muted'
453
+ : 'success'
454
+ }
455
+ aria-label={`${r.name}: conversion compared with the best version`}
456
+ />
457
+ : undefined
458
+ }
459
+ iconHasBackground={false}
460
+ showIcon={false}
461
+ actions={actions.length ? actions : undefined}
462
+ {...(showable ?
463
+ {
464
+ role: 'button',
465
+ tabIndex: 0,
466
+ 'aria-label': `Show “${r.name}” on this page`,
467
+ tooltip: `Show “${r.name}” on this page (only here, never counted)`,
468
+ onClick: () => onShow(r.key),
469
+ onKeyDown: (e: ReactKeyboardEvent<HTMLDivElement>) => {
470
+ if (!activatesRow(e.key, e.target === e.currentTarget)) return;
471
+ e.preventDefault();
472
+ onShow(r.key);
473
+ },
474
+ }
475
+ : {})}
476
+ />
477
+ </div>
478
+ );
479
+ })}
480
+ </StyledVersions>
481
+ {!entry && (
482
+ <>
483
+ <ReqoreVerticalSpacer height={8} />
484
+ <ReqoreP size='small' effect={{ opacity: 0.7 }}>
485
+ Start, Accept and Remove appear once the test is set up on the server.
486
+ </ReqoreP>
487
+ </>
488
+ )}
489
+ </ReqorePanel>
490
+ );
491
+ };
@@ -0,0 +1,148 @@
1
+ import { ReqoreP, ReqorePanel } from '@qoretechnologies/reqore';
2
+ import { StoryObj } from '@storybook/react-vite';
3
+ import { useState } from 'react';
4
+ import { expect, fireEvent, fn, waitFor, within } from 'storybook/test';
5
+ import { EXPERIMENT_KEY, RUNNING_TWO, variant } from '../../stories/tracking';
6
+ import { StoryMeta } from '../../types';
7
+ import { KEYS } from '../core/types';
8
+ import { Experiment } from '../react/Experiment';
9
+ import { TrackingProvider } from '../react/TrackingProvider';
10
+ import { createMemoryTracker } from '../testing';
11
+ import { ExperimentsOverlay, type IExperimentsOverlayProps } from './ExperimentsOverlay';
12
+
13
+ const CLOCK = Date.UTC(2026, 8, 29, 10, 0, 0);
14
+
15
+ /** A page with one tested element; `atBottom` puts it at the foot of the window. */
16
+ const Page = ({ atBottom, ...overlay }: IExperimentsOverlayProps & { atBottom?: boolean }) => {
17
+ const [memory] = useState(() =>
18
+ createMemoryTracker({
19
+ consent: 'granted',
20
+ vid: 'visitor-3',
21
+ seed: {
22
+ [KEYS.experimentsCache]: JSON.stringify({
23
+ at: CLOCK,
24
+ property: 'test',
25
+ experiments: [{ ...RUNNING_TWO.experiment, traffic: 1 }],
26
+ }),
27
+ },
28
+ })
29
+ );
30
+ return (
31
+ <TrackingProvider tracker={memory.tracker} location='/'>
32
+ {atBottom && <div style={{ height: 'calc(100vh - 200px)' }} />}
33
+ <div style={{ maxWidth: 720 }}>
34
+ <Experiment
35
+ id={EXPERIMENT_KEY}
36
+ variants={{
37
+ a: (
38
+ <ReqorePanel label='Get started' flat padded>
39
+ <ReqoreP>Version A: the current sign-up button copy.</ReqoreP>
40
+ </ReqorePanel>
41
+ ),
42
+ b: (
43
+ <ReqorePanel label='Try it free' flat padded>
44
+ <ReqoreP>Version B: the short copy.</ReqoreP>
45
+ </ReqorePanel>
46
+ ),
47
+ }}
48
+ />
49
+ </div>
50
+ <ExperimentsOverlay {...overlay} />
51
+ </TrackingProvider>
52
+ );
53
+ };
54
+
55
+ const meta = {
56
+ title: 'Tracking/Experiments Overlay',
57
+ component: Page,
58
+ args: {
59
+ tests: [RUNNING_TWO],
60
+ onAccept: fn(async () => undefined),
61
+ onRemove: fn(async () => undefined),
62
+ onStart: fn(async () => undefined),
63
+ onPause: fn(async () => undefined),
64
+ onStop: fn(async () => undefined),
65
+ onSettled: fn(async () => undefined),
66
+ detailsUrl: (key: string) => `https://example.com/analytics?experiment=${key}`,
67
+ },
68
+ } as StoryMeta<typeof Page>;
69
+
70
+ export default meta;
71
+ export type Story = StoryObj<typeof meta>;
72
+
73
+ const handleOf = async (doc: Document) =>
74
+ within(doc.body).findByText(`A/B test · ${RUNNING_TWO.experiment.name}`);
75
+ const cardFloat = (doc: Document) =>
76
+ doc.querySelector('[data-experiment-float][data-side]') as HTMLElement | null;
77
+
78
+ // ── anchored below ───────────────────────────────────────────────────────────────
79
+
80
+ const anchored: Story = {
81
+ play: async ({ canvasElement }) => {
82
+ const doc = canvasElement.ownerDocument;
83
+ await expect(doc.querySelector(`[data-experiment-outline="${EXPERIMENT_KEY}"]`)).toBeTruthy();
84
+ // Esc closes the card, a click on the handle opens it again.
85
+ await fireEvent.click((await handleOf(doc)).closest('button')!);
86
+ await waitFor(() => expect(cardFloat(doc)).toBeTruthy());
87
+ await fireEvent.keyDown(doc, { key: 'Escape' });
88
+ await waitFor(() => expect(cardFloat(doc)).toBeNull());
89
+ // A click outside closes it too.
90
+ await fireEvent.click((await handleOf(doc)).closest('button')!);
91
+ await waitFor(() => expect(cardFloat(doc)).toBeTruthy());
92
+ await fireEvent.pointerDown(canvasElement);
93
+ await waitFor(() => expect(cardFloat(doc)).toBeNull());
94
+ await fireEvent.click((await handleOf(doc)).closest('button')!);
95
+ await waitFor(() => expect(cardFloat(doc)?.dataset.side).toBe('below'));
96
+ // The card hangs under the handle, over the tested element.
97
+ const handle = (await handleOf(doc))
98
+ .closest('[data-experiment-float]')!
99
+ .getBoundingClientRect();
100
+ await expect(cardFloat(doc)!.getBoundingClientRect().top).toBeGreaterThan(handle.bottom);
101
+ await expect(within(cardFloat(doc)!).getByText('Short copy')).toBeInTheDocument();
102
+ },
103
+ };
104
+ const ANCHORED_TEXT =
105
+ 'Renders a page with one tested element: a dashed outline and an "A/B test · Sign-up button copy" handle at its corner. Clicking the handle opens the test\'s card anchored below it; Esc and a click outside close it, and the handle reopens it.';
106
+ export const Anchored = variant(anchored, ANCHORED_TEXT, 'Dark');
107
+ export const AnchoredLight = variant(anchored, ANCHORED_TEXT, 'Light');
108
+ export const AnchoredPhone = variant(anchored, ANCHORED_TEXT, 'Phone');
109
+
110
+ // ── flipped above near the bottom ────────────────────────────────────────────────
111
+
112
+ const flipped: Story = {
113
+ args: { atBottom: true },
114
+ play: async ({ canvasElement }) => {
115
+ const doc = canvasElement.ownerDocument;
116
+ await fireEvent.click((await handleOf(doc)).closest('button')!);
117
+ await waitFor(() => expect(cardFloat(doc)?.dataset.side).toBe('above'));
118
+ const handle = (await handleOf(doc))
119
+ .closest('[data-experiment-float]')!
120
+ .getBoundingClientRect();
121
+ // Once measured, the card ends just above the handle.
122
+ await waitFor(() => expect(cardFloat(doc)!.getBoundingClientRect().bottom).toBeLessThanOrEqual(handle.top));
123
+ },
124
+ };
125
+ const FLIPPED_TEXT =
126
+ 'Renders the tested element at the foot of the window with its card open: there is no room below, so the card flips above the handle.';
127
+ export const FlipsAboveNearTheBottom = variant(flipped, FLIPPED_TEXT, 'Dark');
128
+ export const FlipsAboveNearTheBottomLight = variant(flipped, FLIPPED_TEXT, 'Light');
129
+ export const FlipsAboveNearTheBottomPhone = variant(flipped, FLIPPED_TEXT, 'Phone');
130
+
131
+ // ── hidden ─────────────────────────────────────────────────────────
132
+
133
+ const hidden: Story = {
134
+ args: { visible: false },
135
+ play: async ({ canvasElement }) => {
136
+ const doc = canvasElement.ownerDocument;
137
+ await expect(
138
+ await within(canvasElement).findByText('Version B: the short copy.')
139
+ ).toBeInTheDocument();
140
+ await expect(doc.querySelector('[data-experiment-outline]')).toBeNull();
141
+ await expect(within(doc.body).queryByText(/A\/B test ·/)).toBeNull();
142
+ },
143
+ };
144
+ const HIDDEN_TEXT =
145
+ 'Renders the same page with the overlay hidden (`visible={false}`, an editor\'s "hide editor elements"): the tested element shows as visitors see it, with no outline, handle or card.';
146
+ export const Hidden = variant(hidden, HIDDEN_TEXT, 'Dark');
147
+ export const HiddenLight = variant(hidden, HIDDEN_TEXT, 'Light');
148
+ export const HiddenPhone = variant(hidden, HIDDEN_TEXT, 'Phone');