@qoretechnologies/reqraft 0.10.56 → 0.10.57-pr.127.g0a792ea
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +1 -0
- package/README.md +20 -0
- package/design/TRACKING.md +290 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/tracking/browser/attach.d.ts +8 -0
- package/dist/tracking/browser/attach.d.ts.map +1 -0
- package/dist/tracking/browser/attach.js +182 -0
- package/dist/tracking/browser/attach.js.map +1 -0
- package/dist/tracking/browser/env.d.ts +14 -0
- package/dist/tracking/browser/env.d.ts.map +1 -0
- package/dist/tracking/browser/env.js +150 -0
- package/dist/tracking/browser/env.js.map +1 -0
- package/dist/tracking/core/consent.d.ts +21 -0
- package/dist/tracking/core/consent.d.ts.map +1 -0
- package/dist/tracking/core/consent.js +39 -0
- package/dist/tracking/core/consent.js.map +1 -0
- package/dist/tracking/core/engagement.d.ts +20 -0
- package/dist/tracking/core/engagement.d.ts.map +1 -0
- package/dist/tracking/core/engagement.js +52 -0
- package/dist/tracking/core/engagement.js.map +1 -0
- package/dist/tracking/core/experiments.d.ts +109 -0
- package/dist/tracking/core/experiments.d.ts.map +1 -0
- package/dist/tracking/core/experiments.js +294 -0
- package/dist/tracking/core/experiments.js.map +1 -0
- package/dist/tracking/core/hash.d.ts +16 -0
- package/dist/tracking/core/hash.d.ts.map +1 -0
- package/dist/tracking/core/hash.js +31 -0
- package/dist/tracking/core/hash.js.map +1 -0
- package/dist/tracking/core/ids.d.ts +6 -0
- package/dist/tracking/core/ids.d.ts.map +1 -0
- package/dist/tracking/core/ids.js +31 -0
- package/dist/tracking/core/ids.js.map +1 -0
- package/dist/tracking/core/observable.d.ts +12 -0
- package/dist/tracking/core/observable.d.ts.map +1 -0
- package/dist/tracking/core/observable.js +32 -0
- package/dist/tracking/core/observable.js.map +1 -0
- package/dist/tracking/core/queue.d.ts +47 -0
- package/dist/tracking/core/queue.d.ts.map +1 -0
- package/dist/tracking/core/queue.js +205 -0
- package/dist/tracking/core/queue.js.map +1 -0
- package/dist/tracking/core/sections.d.ts +54 -0
- package/dist/tracking/core/sections.d.ts.map +1 -0
- package/dist/tracking/core/sections.js +156 -0
- package/dist/tracking/core/sections.js.map +1 -0
- package/dist/tracking/core/storage.d.ts +15 -0
- package/dist/tracking/core/storage.d.ts.map +1 -0
- package/dist/tracking/core/storage.js +72 -0
- package/dist/tracking/core/storage.js.map +1 -0
- package/dist/tracking/core/tracker.d.ts +122 -0
- package/dist/tracking/core/tracker.d.ts.map +1 -0
- package/dist/tracking/core/tracker.js +390 -0
- package/dist/tracking/core/tracker.js.map +1 -0
- package/dist/tracking/core/types.d.ts +130 -0
- package/dist/tracking/core/types.d.ts.map +1 -0
- package/dist/tracking/core/types.js +69 -0
- package/dist/tracking/core/types.js.map +1 -0
- package/dist/tracking/core/utm.d.ts +6 -0
- package/dist/tracking/core/utm.d.ts.map +1 -0
- package/dist/tracking/core/utm.js +30 -0
- package/dist/tracking/core/utm.js.map +1 -0
- package/dist/tracking/index.d.ts +46 -0
- package/dist/tracking/index.d.ts.map +1 -0
- package/dist/tracking/index.js +91 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/tracking/markup.d.ts +12 -0
- package/dist/tracking/markup.d.ts.map +1 -0
- package/dist/tracking/markup.js +19 -0
- package/dist/tracking/markup.js.map +1 -0
- package/dist/tracking/react/Experiment.d.ts +23 -0
- package/dist/tracking/react/Experiment.d.ts.map +1 -0
- package/dist/tracking/react/Experiment.js +28 -0
- package/dist/tracking/react/Experiment.js.map +1 -0
- package/dist/tracking/react/TrackingProvider.d.ts +26 -0
- package/dist/tracking/react/TrackingProvider.d.ts.map +1 -0
- package/dist/tracking/react/TrackingProvider.js +45 -0
- package/dist/tracking/react/TrackingProvider.js.map +1 -0
- package/dist/tracking/react/hooks.d.ts +39 -0
- package/dist/tracking/react/hooks.d.ts.map +1 -0
- package/dist/tracking/react/hooks.js +96 -0
- package/dist/tracking/react/hooks.js.map +1 -0
- package/dist/tracking/react/index.d.ts +7 -0
- package/dist/tracking/react/index.d.ts.map +1 -0
- package/dist/tracking/react/index.js +17 -0
- package/dist/tracking/react/index.js.map +1 -0
- package/dist/tracking/testing.d.ts +40 -0
- package/dist/tracking/testing.d.ts.map +1 -0
- package/dist/tracking/testing.js +140 -0
- package/dist/tracking/testing.js.map +1 -0
- package/dist/tracking/ui/ExperimentCard.d.ts +55 -0
- package/dist/tracking/ui/ExperimentCard.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentCard.js +340 -0
- package/dist/tracking/ui/ExperimentCard.js.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts +59 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.js +359 -0
- package/dist/tracking/ui/ExperimentsOverlay.js.map +1 -0
- package/dist/tracking/ui/anchor.d.ts +107 -0
- package/dist/tracking/ui/anchor.d.ts.map +1 -0
- package/dist/tracking/ui/anchor.js +108 -0
- package/dist/tracking/ui/anchor.js.map +1 -0
- package/dist/tracking/ui/card.d.ts +121 -0
- package/dist/tracking/ui/card.d.ts.map +1 -0
- package/dist/tracking/ui/card.js +417 -0
- package/dist/tracking/ui/card.js.map +1 -0
- package/dist/tracking/ui/client.d.ts +42 -0
- package/dist/tracking/ui/client.d.ts.map +1 -0
- package/dist/tracking/ui/client.js +123 -0
- package/dist/tracking/ui/client.js.map +1 -0
- package/dist/tracking/ui/index.d.ts +20 -0
- package/dist/tracking/ui/index.d.ts.map +1 -0
- package/dist/tracking/ui/index.js +60 -0
- package/dist/tracking/ui/index.js.map +1 -0
- package/dist/tracking/ui/types.d.ts +120 -0
- package/dist/tracking/ui/types.d.ts.map +1 -0
- package/dist/tracking/ui/types.js +8 -0
- package/dist/tracking/ui/types.js.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts +31 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.js +136 -0
- package/dist/tracking/ui/useExperimentsAdmin.js.map +1 -0
- package/package.json +1 -1
- package/src/index.tsx +5 -0
- package/src/stories/tracking.tsx +206 -0
- package/src/tracking/browser/attach.ts +186 -0
- package/src/tracking/browser/env.ts +78 -0
- package/src/tracking/core/consent.ts +37 -0
- package/src/tracking/core/engagement.ts +47 -0
- package/src/tracking/core/experiments.ts +258 -0
- package/src/tracking/core/hash.ts +26 -0
- package/src/tracking/core/ids.ts +25 -0
- package/src/tracking/core/observable.ts +23 -0
- package/src/tracking/core/queue.ts +131 -0
- package/src/tracking/core/sections.ts +166 -0
- package/src/tracking/core/storage.ts +69 -0
- package/src/tracking/core/tracker.ts +417 -0
- package/src/tracking/core/types.ts +162 -0
- package/src/tracking/core/utm.ts +23 -0
- package/src/tracking/index.ts +72 -0
- package/src/tracking/markup.ts +17 -0
- package/src/tracking/react/Experiment.tsx +44 -0
- package/src/tracking/react/TrackingProvider.tsx +56 -0
- package/src/tracking/react/hooks.ts +106 -0
- package/src/tracking/react/index.ts +13 -0
- package/src/tracking/testing.ts +108 -0
- package/src/tracking/ui/ExperimentCard.stories.tsx +290 -0
- package/src/tracking/ui/ExperimentCard.tsx +581 -0
- package/src/tracking/ui/ExperimentsOverlay.stories.tsx +155 -0
- package/src/tracking/ui/ExperimentsOverlay.tsx +437 -0
- package/src/tracking/ui/anchor.ts +180 -0
- package/src/tracking/ui/card.ts +464 -0
- package/src/tracking/ui/client.ts +117 -0
- package/src/tracking/ui/index.ts +77 -0
- package/src/tracking/ui/types.ts +110 -0
- package/src/tracking/ui/useExperimentsAdmin.ts +103 -0
|
@@ -0,0 +1,155 @@
|
|
|
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, noSnapshot, 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) =>
|
|
67
|
+
`https://example.com/analytics?dashboard=tracking&experiment=${key}`,
|
|
68
|
+
versionDetailsUrl: (key: string, v: string) =>
|
|
69
|
+
`https://example.com/analytics?dashboard=tracking&experiment=${key}&variant=${v}`,
|
|
70
|
+
},
|
|
71
|
+
} as StoryMeta<typeof Page>;
|
|
72
|
+
|
|
73
|
+
export default meta;
|
|
74
|
+
export type Story = StoryObj<typeof meta>;
|
|
75
|
+
|
|
76
|
+
const handleOf = async (doc: Document) =>
|
|
77
|
+
within(doc.body).findByText(`A/B test · ${RUNNING_TWO.experiment.name}`);
|
|
78
|
+
const cardFloat = (doc: Document) =>
|
|
79
|
+
doc.querySelector('[data-experiment-float][data-side]') as HTMLElement | null;
|
|
80
|
+
|
|
81
|
+
// ── anchored below ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const anchored: Story = {
|
|
84
|
+
play: async ({ canvasElement }) => {
|
|
85
|
+
const doc = canvasElement.ownerDocument;
|
|
86
|
+
await expect(doc.querySelector(`[data-experiment-outline="${EXPERIMENT_KEY}"]`)).toBeTruthy();
|
|
87
|
+
// Esc closes the card, a click on the handle opens it again.
|
|
88
|
+
await fireEvent.click((await handleOf(doc)).closest('button')!);
|
|
89
|
+
await waitFor(() => expect(cardFloat(doc)).toBeTruthy());
|
|
90
|
+
await fireEvent.keyDown(doc, { key: 'Escape' });
|
|
91
|
+
await waitFor(() => expect(cardFloat(doc)).toBeNull());
|
|
92
|
+
// A click outside closes it too.
|
|
93
|
+
await fireEvent.click((await handleOf(doc)).closest('button')!);
|
|
94
|
+
await waitFor(() => expect(cardFloat(doc)).toBeTruthy());
|
|
95
|
+
await fireEvent.pointerDown(canvasElement);
|
|
96
|
+
await waitFor(() => expect(cardFloat(doc)).toBeNull());
|
|
97
|
+
await fireEvent.click((await handleOf(doc)).closest('button')!);
|
|
98
|
+
await waitFor(() => expect(cardFloat(doc)?.dataset.side).toBe('below'));
|
|
99
|
+
// The card hangs under the handle, over the tested element.
|
|
100
|
+
const handle = (await handleOf(doc))
|
|
101
|
+
.closest('[data-experiment-float]')!
|
|
102
|
+
.getBoundingClientRect();
|
|
103
|
+
await expect(cardFloat(doc)!.getBoundingClientRect().top).toBeGreaterThan(handle.bottom);
|
|
104
|
+
await expect(within(cardFloat(doc)!).getByText('Short copy')).toBeInTheDocument();
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
const ANCHORED_TEXT =
|
|
108
|
+
'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.';
|
|
109
|
+
export const Anchored = variant(anchored, ANCHORED_TEXT, 'Dark');
|
|
110
|
+
export const AnchoredLight = variant(anchored, ANCHORED_TEXT, 'Light');
|
|
111
|
+
export const AnchoredPhone = variant(anchored, ANCHORED_TEXT, 'Phone');
|
|
112
|
+
|
|
113
|
+
// ── flipped above near the bottom ────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
const flipped: Story = {
|
|
116
|
+
args: { atBottom: true },
|
|
117
|
+
play: async ({ canvasElement }) => {
|
|
118
|
+
const doc = canvasElement.ownerDocument;
|
|
119
|
+
await fireEvent.click((await handleOf(doc)).closest('button')!);
|
|
120
|
+
await waitFor(() => expect(cardFloat(doc)?.dataset.side).toBe('above'));
|
|
121
|
+
const handle = (await handleOf(doc))
|
|
122
|
+
.closest('[data-experiment-float]')!
|
|
123
|
+
.getBoundingClientRect();
|
|
124
|
+
// Once measured, the card ends just above the handle.
|
|
125
|
+
await waitFor(() =>
|
|
126
|
+
expect(cardFloat(doc)!.getBoundingClientRect().bottom).toBeLessThanOrEqual(handle.top)
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
const FLIPPED_TEXT =
|
|
131
|
+
'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.';
|
|
132
|
+
export const FlipsAboveNearTheBottom = variant(flipped, FLIPPED_TEXT, 'Dark');
|
|
133
|
+
export const FlipsAboveNearTheBottomLight = variant(flipped, FLIPPED_TEXT, 'Light');
|
|
134
|
+
// Visually empty at phone height: runs as a test, no snapshot.
|
|
135
|
+
export const FlipsAboveNearTheBottomPhone = noSnapshot(variant(flipped, FLIPPED_TEXT, 'Phone'));
|
|
136
|
+
|
|
137
|
+
// ── hidden ─────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const hidden: Story = {
|
|
140
|
+
args: { visible: false },
|
|
141
|
+
play: async ({ canvasElement }) => {
|
|
142
|
+
const doc = canvasElement.ownerDocument;
|
|
143
|
+
await expect(
|
|
144
|
+
await within(canvasElement).findByText('Version B: the short copy.')
|
|
145
|
+
).toBeInTheDocument();
|
|
146
|
+
await expect(doc.querySelector('[data-experiment-outline]')).toBeNull();
|
|
147
|
+
await expect(within(doc.body).queryByText(/A\/B test ·/)).toBeNull();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
const HIDDEN_TEXT =
|
|
151
|
+
'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.';
|
|
152
|
+
// Nothing of the overlay to see: they run as tests, without snapshots.
|
|
153
|
+
export const Hidden = noSnapshot(variant(hidden, HIDDEN_TEXT, 'Dark'));
|
|
154
|
+
export const HiddenLight = noSnapshot(variant(hidden, HIDDEN_TEXT, 'Light'));
|
|
155
|
+
export const HiddenPhone = noSnapshot(variant(hidden, HIDDEN_TEXT, 'Phone'));
|
|
@@ -0,0 +1,437 @@
|
|
|
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
|
+
/** One version's analytics (the rows' "Show analytics"); hidden when absent or it returns nothing. */
|
|
60
|
+
versionDetailsUrl?: (key: string, variant: string) => string | null | undefined;
|
|
61
|
+
/** Where the full analytics live, in the help's words. Default `the admin portal`. */
|
|
62
|
+
analyticsLabel?: string;
|
|
63
|
+
/** The words for a test that is in the page's code but not set up in the analytics service yet. */
|
|
64
|
+
notSetUpText?: string;
|
|
65
|
+
/** Who removed a version, in words, by the history's `actor`. */
|
|
66
|
+
actorLabels?: Record<string, string>;
|
|
67
|
+
/** The part of the viewport a sticky header covers (px, or read on every render). Default 0. */
|
|
68
|
+
topInset?: number | (() => number);
|
|
69
|
+
/** Markers inside elements matching this selector are ignored (e.g. an editor's own UI). */
|
|
70
|
+
exclude?: string;
|
|
71
|
+
/** The handle's words. Default `A/B test · <name>`. */
|
|
72
|
+
handleLabel?: (name: string) => string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Overlay chrome only: the outline around a tested element, never taking the pointer.
|
|
76
|
+
const StyledOutline = styled.div`
|
|
77
|
+
position: absolute;
|
|
78
|
+
pointer-events: none;
|
|
79
|
+
z-index: 880;
|
|
80
|
+
border-radius: 10px;
|
|
81
|
+
outline-width: 2px;
|
|
82
|
+
outline-style: dashed;
|
|
83
|
+
outline-offset: -3px;
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
// Layout only: the handle and the card, in page coordinates so they scroll with the element.
|
|
87
|
+
const StyledFloat = styled.div<{ $card?: boolean }>`
|
|
88
|
+
position: absolute;
|
|
89
|
+
z-index: ${({ $card }) => ($card ? 956 : 955)};
|
|
90
|
+
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.35));
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
/** The overlay's own root; markers inside it are never outlined. */
|
|
94
|
+
export const OVERLAY_ATTRIBUTE = 'data-experiments-overlay';
|
|
95
|
+
|
|
96
|
+
interface IMarked {
|
|
97
|
+
key: string;
|
|
98
|
+
shown: string;
|
|
99
|
+
variants: string[];
|
|
100
|
+
reason: string;
|
|
101
|
+
box: DOMRect;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One box around the elements (viewport coordinates); tiny elements are ignored. */
|
|
105
|
+
export const boxOf = (els: Element[]): DOMRect | null => {
|
|
106
|
+
let box: { l: number; t: number; r: number; b: number } | null = null;
|
|
107
|
+
for (const el of els) {
|
|
108
|
+
const r = el.getBoundingClientRect();
|
|
109
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
110
|
+
box =
|
|
111
|
+
box ?
|
|
112
|
+
{
|
|
113
|
+
l: Math.min(box.l, r.left),
|
|
114
|
+
t: Math.min(box.t, r.top),
|
|
115
|
+
r: Math.max(box.r, r.right),
|
|
116
|
+
b: Math.max(box.b, r.bottom),
|
|
117
|
+
}
|
|
118
|
+
: { l: r.left, t: r.top, r: r.right, b: r.bottom };
|
|
119
|
+
}
|
|
120
|
+
return box ? new DOMRect(box.l, box.t, box.r - box.l, box.b - box.t) : null;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const markedOnPage = (exclude?: string): IMarked[] =>
|
|
124
|
+
Array.from(document.querySelectorAll<HTMLElement>('[data-experiment]'))
|
|
125
|
+
.filter((el) => !el.closest(`[${OVERLAY_ATTRIBUTE}]`) && !(exclude && el.closest(exclude)))
|
|
126
|
+
.map((el) => {
|
|
127
|
+
const box = boxOf(Array.from(el.children));
|
|
128
|
+
return box ?
|
|
129
|
+
{
|
|
130
|
+
key: el.dataset.experiment ?? '',
|
|
131
|
+
shown: el.dataset.variant ?? '',
|
|
132
|
+
variants: (el.dataset.variants ?? '').split(',').filter(Boolean),
|
|
133
|
+
reason: el.dataset.experimentReason ?? '',
|
|
134
|
+
box,
|
|
135
|
+
}
|
|
136
|
+
: null;
|
|
137
|
+
})
|
|
138
|
+
.filter((m): m is IMarked => !!m && !!m.key);
|
|
139
|
+
|
|
140
|
+
const inside = (
|
|
141
|
+
r: { left: number; top: number; right: number; bottom: number },
|
|
142
|
+
x: number,
|
|
143
|
+
y: number
|
|
144
|
+
) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
145
|
+
|
|
146
|
+
/** Re-renders the caller on scroll, resize and every 700 ms (content arriving), while `active`. */
|
|
147
|
+
const useFollowPage = (active: boolean) => {
|
|
148
|
+
const [, setTick] = useState(0);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!active) return undefined;
|
|
151
|
+
let raf = 0;
|
|
152
|
+
const bump = () => {
|
|
153
|
+
cancelAnimationFrame(raf);
|
|
154
|
+
raf = requestAnimationFrame(() => setTick((t) => t + 1));
|
|
155
|
+
};
|
|
156
|
+
window.addEventListener('scroll', bump, { passive: true, capture: true });
|
|
157
|
+
window.addEventListener('resize', bump);
|
|
158
|
+
const id = window.setInterval(bump, 700);
|
|
159
|
+
return () => {
|
|
160
|
+
window.removeEventListener('scroll', bump, { capture: true });
|
|
161
|
+
window.removeEventListener('resize', bump);
|
|
162
|
+
window.clearInterval(id);
|
|
163
|
+
cancelAnimationFrame(raf);
|
|
164
|
+
};
|
|
165
|
+
}, [active]);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const HANDLE_ESTIMATE = { width: 240, height: 30 };
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Every `<Experiment>` on the page (its `data-experiment` marker) gets a dashed outline and a
|
|
172
|
+
* handle, "A/B test · <name>", at its top-left corner. Hovering the element, focusing the handle,
|
|
173
|
+
* or clicking / tapping it opens the test's `<ExperimentCard>`, anchored to the element: it moves
|
|
174
|
+
* and resizes with it and flips above or below the handle to stay in view. ✕, Esc and a click
|
|
175
|
+
* outside close it. An ended test shows "Winner live" instead. Data and actions come in as props
|
|
176
|
+
* (`useExperimentsAdmin` provides them from the qorus-api routes).
|
|
177
|
+
*/
|
|
178
|
+
export const ExperimentsOverlay = ({
|
|
179
|
+
visible = true,
|
|
180
|
+
tests,
|
|
181
|
+
loadError,
|
|
182
|
+
onShow,
|
|
183
|
+
onAccept,
|
|
184
|
+
onRemove,
|
|
185
|
+
onStart,
|
|
186
|
+
onPause,
|
|
187
|
+
onStop,
|
|
188
|
+
onSettled,
|
|
189
|
+
detailsUrl,
|
|
190
|
+
detailsLabel,
|
|
191
|
+
versionDetailsUrl,
|
|
192
|
+
analyticsLabel,
|
|
193
|
+
notSetUpText,
|
|
194
|
+
actorLabels,
|
|
195
|
+
topInset = 0,
|
|
196
|
+
exclude,
|
|
197
|
+
handleLabel = (name) => `A/B test · ${name}`,
|
|
198
|
+
}: IExperimentsOverlayProps) => {
|
|
199
|
+
const tracker = useTracker();
|
|
200
|
+
const theme = useReqoreTheme();
|
|
201
|
+
const hoverCapable = useReqoreProperty('isHoverCapable') !== false;
|
|
202
|
+
const [cards, dispatch] = useReducer(cardReducer, CLOSED);
|
|
203
|
+
const [sizes, setSizes] = useState<Record<string, { width: number; height: number }>>({});
|
|
204
|
+
const boxes = useRef(new Map<string, DOMRect>());
|
|
205
|
+
const floats = useRef(new Map<string, HTMLDivElement>());
|
|
206
|
+
const sides = useRef(new Map<string, 'below' | 'above'>());
|
|
207
|
+
const open = visible ? openCard(cards) : null;
|
|
208
|
+
|
|
209
|
+
const marked = visible && typeof document !== 'undefined' ? markedOnPage(exclude) : [];
|
|
210
|
+
useFollowPage(visible);
|
|
211
|
+
// A version switch, an accept or a removal re-renders the tested element; a resize moves it.
|
|
212
|
+
const [, setSeen] = useState(0);
|
|
213
|
+
const markedKeys = marked.map((m) => m.key).join(',');
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (!visible) return undefined;
|
|
216
|
+
const bump = () => setSeen((n) => n + 1);
|
|
217
|
+
const mo = new MutationObserver(bump);
|
|
218
|
+
mo.observe(document.body, {
|
|
219
|
+
subtree: true,
|
|
220
|
+
attributes: true,
|
|
221
|
+
attributeFilter: ['data-variant', 'data-experiment-reason'],
|
|
222
|
+
});
|
|
223
|
+
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(bump);
|
|
224
|
+
document.querySelectorAll('[data-experiment] > *').forEach((el) => ro?.observe(el));
|
|
225
|
+
return () => {
|
|
226
|
+
mo.disconnect();
|
|
227
|
+
ro?.disconnect();
|
|
228
|
+
};
|
|
229
|
+
}, [markedKeys, visible]);
|
|
230
|
+
|
|
231
|
+
// Hover: the pointer over a tested element, its handle or its card. Leaving waits a moment,
|
|
232
|
+
// so the pointer can cross the gap between the handle and the card.
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!hoverCapable || !visible) return undefined;
|
|
235
|
+
let leave = 0;
|
|
236
|
+
const onMove = (e: PointerEvent) => {
|
|
237
|
+
let hit: string | null = null;
|
|
238
|
+
boxes.current.forEach((box, key) => {
|
|
239
|
+
if (!hit && inside(box, e.clientX, e.clientY)) hit = key;
|
|
240
|
+
});
|
|
241
|
+
floats.current.forEach((el, id) => {
|
|
242
|
+
if (!hit && inside(el.getBoundingClientRect(), e.clientX, e.clientY))
|
|
243
|
+
hit = id.split('|')[0];
|
|
244
|
+
});
|
|
245
|
+
window.clearTimeout(leave);
|
|
246
|
+
if (hit) dispatch({ type: 'hover', key: hit });
|
|
247
|
+
else leave = window.setTimeout(() => dispatch({ type: 'hover', key: null }), 220);
|
|
248
|
+
};
|
|
249
|
+
document.addEventListener('pointermove', onMove, { passive: true });
|
|
250
|
+
return () => {
|
|
251
|
+
window.clearTimeout(leave);
|
|
252
|
+
document.removeEventListener('pointermove', onMove);
|
|
253
|
+
};
|
|
254
|
+
}, [hoverCapable, visible]);
|
|
255
|
+
|
|
256
|
+
// Esc and a click outside the card close it (a click in the confirm step it opened does not).
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!open) return undefined;
|
|
259
|
+
const onKey = (e: KeyboardEvent) => {
|
|
260
|
+
if (e.key === 'Escape') dispatch({ type: 'close' });
|
|
261
|
+
};
|
|
262
|
+
const onDown = (e: PointerEvent) => {
|
|
263
|
+
const target = e.target as Element | null;
|
|
264
|
+
if (target?.closest?.(OUTSIDE_IGNORE)) return;
|
|
265
|
+
dispatch({ type: 'close' });
|
|
266
|
+
};
|
|
267
|
+
document.addEventListener('keydown', onKey);
|
|
268
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
269
|
+
return () => {
|
|
270
|
+
document.removeEventListener('keydown', onKey);
|
|
271
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
272
|
+
};
|
|
273
|
+
}, [open]);
|
|
274
|
+
|
|
275
|
+
// The handles' and the card's real sizes, for the placement.
|
|
276
|
+
useLayoutEffect(() => {
|
|
277
|
+
const next: Record<string, { width: number; height: number }> = {};
|
|
278
|
+
floats.current.forEach((el, id) => {
|
|
279
|
+
next[id] = { width: el.offsetWidth, height: el.offsetHeight };
|
|
280
|
+
});
|
|
281
|
+
const same =
|
|
282
|
+
Object.keys(next).length === Object.keys(sizes).length &&
|
|
283
|
+
Object.entries(next).every(
|
|
284
|
+
([k, v]) => sizes[k]?.width === v.width && sizes[k]?.height === v.height
|
|
285
|
+
);
|
|
286
|
+
if (!same) setSizes(next);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/** After an action the page shows what visitors see now: no switch, fresh definitions, a new decision. */
|
|
290
|
+
const settle = useCallback(
|
|
291
|
+
async (key: string) => {
|
|
292
|
+
if (onSettled) {
|
|
293
|
+
await onSettled(key);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
tracker?.experiments.setEditorOverride(key, null);
|
|
297
|
+
await tracker?.experiments.refresh();
|
|
298
|
+
},
|
|
299
|
+
[onSettled, tracker]
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const settled = <A extends unknown[]>(
|
|
303
|
+
key: string,
|
|
304
|
+
fn?: (key: string, ...args: A) => TMaybeAsync
|
|
305
|
+
) =>
|
|
306
|
+
fn ?
|
|
307
|
+
async (...args: A) => {
|
|
308
|
+
const result = await fn(key, ...args);
|
|
309
|
+
await settle(key);
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
: undefined;
|
|
313
|
+
|
|
314
|
+
const show =
|
|
315
|
+
onShow ??
|
|
316
|
+
(tracker ?
|
|
317
|
+
(key: string, variant: string) => tracker.experiments.setEditorOverride(key, variant)
|
|
318
|
+
: undefined);
|
|
319
|
+
|
|
320
|
+
if (!visible || typeof document === 'undefined') return null;
|
|
321
|
+
boxes.current.clear();
|
|
322
|
+
const viewport = {
|
|
323
|
+
width: document.documentElement.clientWidth || window.innerWidth,
|
|
324
|
+
height: window.innerHeight,
|
|
325
|
+
};
|
|
326
|
+
const scroll = { x: window.scrollX, y: window.scrollY };
|
|
327
|
+
const inset = typeof topInset === 'function' ? topInset() : topInset;
|
|
328
|
+
|
|
329
|
+
const setFloat = (id: string) => (el: HTMLDivElement | null) => {
|
|
330
|
+
if (el) floats.current.set(id, el);
|
|
331
|
+
else floats.current.delete(id);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return createPortal(
|
|
335
|
+
<div {...{ [OVERLAY_ATTRIBUTE]: '' }}>
|
|
336
|
+
{marked.map((m) => {
|
|
337
|
+
const entry = tests?.find((e) => e.experiment.key === m.key);
|
|
338
|
+
const concluded = m.reason === 'concluded' || entry?.experiment.status === 'concluded';
|
|
339
|
+
const name = entry?.experiment.name || m.key;
|
|
340
|
+
const isOpen = !concluded && open === m.key;
|
|
341
|
+
const place: IPlacement = placeCard({
|
|
342
|
+
box: m.box,
|
|
343
|
+
viewport,
|
|
344
|
+
scroll,
|
|
345
|
+
handle: sizes[`${m.key}|handle`] ?? HANDLE_ESTIMATE,
|
|
346
|
+
card: isOpen ? (sizes[`${m.key}|card`] ?? { height: 420 }) : null,
|
|
347
|
+
topInset: inset,
|
|
348
|
+
side: sides.current.get(m.key),
|
|
349
|
+
});
|
|
350
|
+
if (place.card) sides.current.set(m.key, place.card.side);
|
|
351
|
+
else sides.current.delete(m.key);
|
|
352
|
+
boxes.current.set(m.key, m.box);
|
|
353
|
+
if (!place.visible) return null;
|
|
354
|
+
const onBlur = (e: FocusEvent<HTMLDivElement>) => {
|
|
355
|
+
if (
|
|
356
|
+
!(e.relatedTarget as Element | null)?.closest?.(`[data-experiment-float="${m.key}"]`)
|
|
357
|
+
) {
|
|
358
|
+
dispatch({ type: 'blur', key: m.key });
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
const floatProps = {
|
|
362
|
+
'data-experiment-float': m.key,
|
|
363
|
+
onFocus: () => dispatch({ type: 'focus', key: m.key }),
|
|
364
|
+
onBlur,
|
|
365
|
+
};
|
|
366
|
+
return (
|
|
367
|
+
<div key={m.key}>
|
|
368
|
+
<StyledOutline
|
|
369
|
+
style={{ ...place.outline, outlineColor: theme.intents?.info }}
|
|
370
|
+
data-experiment-outline={m.key}
|
|
371
|
+
/>
|
|
372
|
+
<StyledFloat ref={setFloat(`${m.key}|handle`)} style={place.handle} {...floatProps}>
|
|
373
|
+
{concluded ?
|
|
374
|
+
<ReqoreTag
|
|
375
|
+
icon='TrophyLine'
|
|
376
|
+
label={winnerNote(entry, m.shown)}
|
|
377
|
+
tooltip='Every visitor sees this version. A developer removes the other versions’ code later.'
|
|
378
|
+
effect={{
|
|
379
|
+
gradient: { colors: { 0: 'success', 100: 'success:darken:1' } },
|
|
380
|
+
weight: 'bold',
|
|
381
|
+
}}
|
|
382
|
+
/>
|
|
383
|
+
: <ReqoreButton
|
|
384
|
+
pill
|
|
385
|
+
size='small'
|
|
386
|
+
icon='FlaskLine'
|
|
387
|
+
intent='info'
|
|
388
|
+
active={isOpen}
|
|
389
|
+
aria-expanded={isOpen}
|
|
390
|
+
tooltip={isOpen ? undefined : 'A/B test: see its versions and numbers'}
|
|
391
|
+
onClick={() => dispatch({ type: 'toggle', key: m.key })}
|
|
392
|
+
>
|
|
393
|
+
{handleLabel(name)}
|
|
394
|
+
</ReqoreButton>
|
|
395
|
+
}
|
|
396
|
+
</StyledFloat>
|
|
397
|
+
{isOpen && place.card && (
|
|
398
|
+
<StyledFloat
|
|
399
|
+
$card
|
|
400
|
+
ref={setFloat(`${m.key}|card`)}
|
|
401
|
+
style={{ top: place.card.top, left: place.card.left, width: place.card.width }}
|
|
402
|
+
data-side={place.card.side}
|
|
403
|
+
{...floatProps}
|
|
404
|
+
>
|
|
405
|
+
<ExperimentCard
|
|
406
|
+
experimentKey={m.key}
|
|
407
|
+
test={entry}
|
|
408
|
+
name={name}
|
|
409
|
+
shown={m.shown}
|
|
410
|
+
codeVariants={m.variants}
|
|
411
|
+
maxHeight={place.card.maxHeight}
|
|
412
|
+
loadError={loadError}
|
|
413
|
+
detailsUrl={detailsUrl?.(m.key)}
|
|
414
|
+
detailsLabel={detailsLabel}
|
|
415
|
+
versionDetailsUrl={
|
|
416
|
+
versionDetailsUrl ? (variant) => versionDetailsUrl(m.key, variant) : undefined
|
|
417
|
+
}
|
|
418
|
+
analyticsLabel={analyticsLabel}
|
|
419
|
+
notSetUpText={notSetUpText}
|
|
420
|
+
actorLabels={actorLabels}
|
|
421
|
+
onClose={() => dispatch({ type: 'close' })}
|
|
422
|
+
onShow={show ? (variant) => show(m.key, variant) : undefined}
|
|
423
|
+
onAccept={settled(m.key, onAccept)}
|
|
424
|
+
onRemove={settled(m.key, onRemove)}
|
|
425
|
+
onStart={settled(m.key, onStart)}
|
|
426
|
+
onPause={settled(m.key, onPause)}
|
|
427
|
+
onStop={settled(m.key, onStop)}
|
|
428
|
+
/>
|
|
429
|
+
</StyledFloat>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
})}
|
|
434
|
+
</div>,
|
|
435
|
+
document.body
|
|
436
|
+
);
|
|
437
|
+
};
|