@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small client for the qorus-api experiment routes the A/B test card acts on: the list with
|
|
3
|
+
* results, accept, remove-variant, start, pause, stop, and creating a draft (§9.3, §9.5 of the
|
|
4
|
+
* contract). Optional: the card and the overlay only take data and callbacks, so a product can
|
|
5
|
+
* reach its server any other way.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
IAdminExperiment,
|
|
9
|
+
IExperimentEntry,
|
|
10
|
+
INewExperiment,
|
|
11
|
+
TExperimentActionResult,
|
|
12
|
+
TLifecycleAction,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
export class ExperimentsAdminError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
readonly status: number,
|
|
19
|
+
readonly code?: string
|
|
20
|
+
) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'ExperimentsAdminError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TExperimentsFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
export interface IExperimentsAdminOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The list route, with any query it needs: `/qorus-cloud/editor/experiments?property=landing`
|
|
31
|
+
* (the editor route) or `/qorus-saas-admin/analytics/experiments?property=ide` (the admin API).
|
|
32
|
+
*/
|
|
33
|
+
listUrl: string;
|
|
34
|
+
/**
|
|
35
|
+
* The route of one action. Default: the list route's path, then `/<key>/<action>`
|
|
36
|
+
* (`…/experiments/signup-cta/accept`), without the list's query.
|
|
37
|
+
*/
|
|
38
|
+
actionUrl?: (key: string, action: string) => string;
|
|
39
|
+
/** Where a new draft is posted. Default: the list route's path. */
|
|
40
|
+
createUrl?: string;
|
|
41
|
+
/** Sent with every request (e.g. an `Authorization` header). */
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
/** `same-origin` by default. */
|
|
44
|
+
credentials?: RequestCredentials;
|
|
45
|
+
/** Default: the global `fetch`. */
|
|
46
|
+
fetch?: TExperimentsFetch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IExperimentsAdminClient {
|
|
50
|
+
list(): Promise<IExperimentEntry[]>;
|
|
51
|
+
accept(key: string, variant: string): Promise<TExperimentActionResult>;
|
|
52
|
+
remove(key: string, variant: string): Promise<TExperimentActionResult>;
|
|
53
|
+
lifecycle(key: string, action: TLifecycleAction): Promise<IAdminExperiment>;
|
|
54
|
+
create(def: INewExperiment): Promise<IAdminExperiment>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pathOf = (url: string) => url.split('?')[0].replace(/\/+$/, '');
|
|
58
|
+
|
|
59
|
+
export const createExperimentsAdminClient = (
|
|
60
|
+
options: IExperimentsAdminOptions
|
|
61
|
+
): IExperimentsAdminClient => {
|
|
62
|
+
const base = pathOf(options.listUrl);
|
|
63
|
+
const actionUrl =
|
|
64
|
+
options.actionUrl ??
|
|
65
|
+
((key: string, action: string) => `${base}/${encodeURIComponent(key)}/${action}`);
|
|
66
|
+
const doFetch: TExperimentsFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
67
|
+
|
|
68
|
+
const call = async <T>(url: string, body?: unknown): Promise<T> => {
|
|
69
|
+
const res = await doFetch(url, {
|
|
70
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
71
|
+
credentials: options.credentials ?? 'same-origin',
|
|
72
|
+
headers: {
|
|
73
|
+
Accept: 'application/json',
|
|
74
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
75
|
+
...options.headers,
|
|
76
|
+
},
|
|
77
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
78
|
+
});
|
|
79
|
+
const parsed = (await res.json().catch(() => null)) as {
|
|
80
|
+
err?: string;
|
|
81
|
+
error?: string;
|
|
82
|
+
desc?: string;
|
|
83
|
+
message?: string;
|
|
84
|
+
} | null;
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const code = parsed?.err ?? parsed?.error;
|
|
87
|
+
if (res.status === 401 || res.status === 403) {
|
|
88
|
+
throw new ExperimentsAdminError(
|
|
89
|
+
'Not allowed to manage A/B tests on this server (the API key is missing or wrong).',
|
|
90
|
+
res.status,
|
|
91
|
+
code
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
throw new ExperimentsAdminError(
|
|
95
|
+
parsed?.desc ?? parsed?.message ?? code ?? `HTTP ${res.status}`,
|
|
96
|
+
res.status,
|
|
97
|
+
code
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return parsed as T;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
list: () =>
|
|
105
|
+
call<IExperimentEntry[] | { experiments?: IExperimentEntry[] }>(options.listUrl).then(
|
|
106
|
+
(body) =>
|
|
107
|
+
Array.isArray(body) ? body
|
|
108
|
+
: Array.isArray(body?.experiments) ? body.experiments
|
|
109
|
+
: []
|
|
110
|
+
),
|
|
111
|
+
accept: (key, variant) => call<TExperimentActionResult>(actionUrl(key, 'accept'), { variant }),
|
|
112
|
+
remove: (key, variant) =>
|
|
113
|
+
call<TExperimentActionResult>(actionUrl(key, 'remove-variant'), { variant }),
|
|
114
|
+
lifecycle: (key, action) => call<IAdminExperiment>(actionUrl(key, action), {}),
|
|
115
|
+
create: (def) => call<IAdminExperiment>(options.createUrl ?? base, def),
|
|
116
|
+
};
|
|
117
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A/B test UI: `<ExperimentCard>` (one test, driven by props), `<ExperimentsOverlay>` (every
|
|
3
|
+
* `<Experiment>` on the page outlined, with its card anchored to it), and the optional
|
|
4
|
+
* `useExperimentsAdmin` / `createExperimentsAdminClient` for the qorus-api routes. Built on Reqore;
|
|
5
|
+
* the engine (`../index`) does not import this layer. How to embed it: design/TRACKING.md, §8.
|
|
6
|
+
*/
|
|
7
|
+
export { ExperimentCard } from './ExperimentCard';
|
|
8
|
+
export type { IExperimentCardProps } from './ExperimentCard';
|
|
9
|
+
export { boxOf, ExperimentsOverlay, OVERLAY_ATTRIBUTE } from './ExperimentsOverlay';
|
|
10
|
+
export type { IExperimentsOverlayProps } from './ExperimentsOverlay';
|
|
11
|
+
export { useExperimentsAdmin } from './useExperimentsAdmin';
|
|
12
|
+
export type { IUseExperimentsAdmin, IUseExperimentsAdminOptions } from './useExperimentsAdmin';
|
|
13
|
+
export { createExperimentsAdminClient, ExperimentsAdminError } from './client';
|
|
14
|
+
export type {
|
|
15
|
+
IExperimentsAdminClient,
|
|
16
|
+
IExperimentsAdminOptions,
|
|
17
|
+
TExperimentsFetch,
|
|
18
|
+
} from './client';
|
|
19
|
+
export {
|
|
20
|
+
activatesRow,
|
|
21
|
+
CARD_MAX_WIDTH,
|
|
22
|
+
CARD_MIN_HEIGHT,
|
|
23
|
+
cardReducer,
|
|
24
|
+
CLOSED,
|
|
25
|
+
openCard,
|
|
26
|
+
OUTSIDE_IGNORE,
|
|
27
|
+
placeCard,
|
|
28
|
+
} from './anchor';
|
|
29
|
+
export type { IBox, ICardState, IPlaceInput, IPlacement, TCardEvent } from './anchor';
|
|
30
|
+
export {
|
|
31
|
+
actionOutcome,
|
|
32
|
+
barValues,
|
|
33
|
+
canAccept,
|
|
34
|
+
canPause,
|
|
35
|
+
canRemove,
|
|
36
|
+
canStart,
|
|
37
|
+
canStop,
|
|
38
|
+
confirmCopy,
|
|
39
|
+
count,
|
|
40
|
+
DEFAULT_MIN_SAMPLE,
|
|
41
|
+
helpSections,
|
|
42
|
+
leadingVersion,
|
|
43
|
+
NOT_SET_UP_TEXT,
|
|
44
|
+
sortVersionRows,
|
|
45
|
+
UNREACHABLE_TEXT,
|
|
46
|
+
lifecycleCopy,
|
|
47
|
+
numbersLine,
|
|
48
|
+
percent,
|
|
49
|
+
phaseNote,
|
|
50
|
+
primaryMetric,
|
|
51
|
+
STATUS_LABEL,
|
|
52
|
+
statusTag,
|
|
53
|
+
verdictIntent,
|
|
54
|
+
verdictLine,
|
|
55
|
+
versionName,
|
|
56
|
+
versionRows,
|
|
57
|
+
versionsMaxHeight,
|
|
58
|
+
VISIBLE_VERSIONS,
|
|
59
|
+
winnerNote,
|
|
60
|
+
} from './card';
|
|
61
|
+
export type { IConfirmCopy, IHelpSection, IVersionRow, TStatusIntent } from './card';
|
|
62
|
+
export type {
|
|
63
|
+
IAdminExperiment,
|
|
64
|
+
IAdminExperimentEvent,
|
|
65
|
+
IAdminExperimentPhase,
|
|
66
|
+
IAdminVariant,
|
|
67
|
+
IExperimentEntry,
|
|
68
|
+
IExperimentResults,
|
|
69
|
+
INewExperiment,
|
|
70
|
+
IResultsMetric,
|
|
71
|
+
IResultsVariant,
|
|
72
|
+
TExperimentAction,
|
|
73
|
+
TExperimentActionResult,
|
|
74
|
+
TExperimentStatus,
|
|
75
|
+
TLifecycleAction,
|
|
76
|
+
TVersionAction,
|
|
77
|
+
} from './types';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The experiment and results objects of the qorus-api admin and editor routes (§5, §7, §9.5 of
|
|
3
|
+
* `design/analytics-and-experiments-design.md`), with the fields the A/B test UI reads.
|
|
4
|
+
* Change the contract there first, then here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type TExperimentStatus =
|
|
8
|
+
'draft' | 'running' | 'paused' | 'stopped' | 'concluded' | 'archived';
|
|
9
|
+
|
|
10
|
+
export interface IAdminVariant {
|
|
11
|
+
key: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
weight: number;
|
|
14
|
+
control?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IAdminExperimentPhase {
|
|
18
|
+
phase: number;
|
|
19
|
+
started_at: string;
|
|
20
|
+
ended_at: string | null;
|
|
21
|
+
variants: IAdminVariant[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IAdminExperimentEvent {
|
|
25
|
+
at: string;
|
|
26
|
+
action: string;
|
|
27
|
+
variant?: string | null;
|
|
28
|
+
/** Who did it (`admin`, or the product's editor, e.g. `landing-editor`). */
|
|
29
|
+
actor: string;
|
|
30
|
+
user?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The admin experiment object. */
|
|
34
|
+
export interface IAdminExperiment {
|
|
35
|
+
key: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
property?: string;
|
|
38
|
+
status: TExperimentStatus;
|
|
39
|
+
variants: IAdminVariant[];
|
|
40
|
+
winner?: string | null;
|
|
41
|
+
/** The current phase: a removed version starts a new one, and the numbers count from zero. */
|
|
42
|
+
phase?: number;
|
|
43
|
+
/** Earlier phases and the current one (last, `ended_at: null` while it runs). */
|
|
44
|
+
phases?: IAdminExperimentPhase[];
|
|
45
|
+
/** Every state change, oldest first. */
|
|
46
|
+
history?: IAdminExperimentEvent[];
|
|
47
|
+
metrics?: { primary?: string; guardrails?: string[]; secondary?: string[] };
|
|
48
|
+
/** Share of visitors in the test, 0..1. */
|
|
49
|
+
traffic?: number;
|
|
50
|
+
/** The too-early gate: every version needs `per_variant` visitors and `days` must pass (default 200, 7). */
|
|
51
|
+
min_sample?: { per_variant: number; days: number };
|
|
52
|
+
started_at?: string | null;
|
|
53
|
+
ended_at?: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface IResultsVariant {
|
|
57
|
+
variant: string;
|
|
58
|
+
exposed: number;
|
|
59
|
+
converters: number;
|
|
60
|
+
rate: number | null;
|
|
61
|
+
uplift?: number | null;
|
|
62
|
+
chance_to_beat: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IResultsMetric {
|
|
66
|
+
key: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
role: 'primary' | 'guardrail' | 'secondary';
|
|
69
|
+
verdict?: string;
|
|
70
|
+
verdict_text?: string;
|
|
71
|
+
variants: IResultsVariant[];
|
|
72
|
+
unavailable?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The admin results object. */
|
|
76
|
+
export interface IExperimentResults {
|
|
77
|
+
verdict?: string;
|
|
78
|
+
/** The verdict in plain words, written by the server. */
|
|
79
|
+
verdict_text?: string;
|
|
80
|
+
winner_variant?: string | null;
|
|
81
|
+
phase?: number;
|
|
82
|
+
metrics: IResultsMetric[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One entry of the editor list: the experiment and its results (null for a draft). */
|
|
86
|
+
export interface IExperimentEntry {
|
|
87
|
+
experiment: IAdminExperiment;
|
|
88
|
+
results: IExperimentResults | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The experiment an action returns; `auto_accepted` when removing left one version. */
|
|
92
|
+
export type TExperimentActionResult = IAdminExperiment & { auto_accepted?: boolean };
|
|
93
|
+
|
|
94
|
+
export type TLifecycleAction = 'start' | 'pause' | 'stop';
|
|
95
|
+
export type TVersionAction = 'accept' | 'remove';
|
|
96
|
+
export type TExperimentAction = TVersionAction | TLifecycleAction;
|
|
97
|
+
|
|
98
|
+
/** A new experiment definition (admin shape); the server always creates it as a draft. */
|
|
99
|
+
export interface INewExperiment {
|
|
100
|
+
key: string;
|
|
101
|
+
name: string;
|
|
102
|
+
hypothesis?: string;
|
|
103
|
+
property?: string;
|
|
104
|
+
variants: IAdminVariant[];
|
|
105
|
+
traffic?: number;
|
|
106
|
+
targeting?: { paths?: string[] | null; device?: string | null; lang?: string | null };
|
|
107
|
+
trigger?: { section?: string | null };
|
|
108
|
+
metrics: { primary: string; guardrails?: string[]; secondary?: string[] };
|
|
109
|
+
min_sample?: { per_variant: number; days: number };
|
|
110
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
createExperimentsAdminClient,
|
|
4
|
+
type IExperimentsAdminClient,
|
|
5
|
+
type IExperimentsAdminOptions,
|
|
6
|
+
} from './client';
|
|
7
|
+
import type { IExperimentEntry, TExperimentActionResult, TLifecycleAction } from './types';
|
|
8
|
+
|
|
9
|
+
export interface IUseExperimentsAdminOptions extends IExperimentsAdminOptions {
|
|
10
|
+
/** Reload the list this often (someone else may accept a test meanwhile). Default 60 s; 0 turns it off. */
|
|
11
|
+
refreshMs?: number;
|
|
12
|
+
/** Load nothing while false (e.g. while the overlay is hidden). Default true. */
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IUseExperimentsAdmin {
|
|
17
|
+
/** The list, or null before the first answer. */
|
|
18
|
+
tests: IExperimentEntry[] | null;
|
|
19
|
+
/** The last load's error message, or null. */
|
|
20
|
+
error: string | null;
|
|
21
|
+
reload: () => Promise<void>;
|
|
22
|
+
/** Each action reloads the list when it resolves, and rejects with the server's words. */
|
|
23
|
+
accept: (key: string, variant: string) => Promise<TExperimentActionResult>;
|
|
24
|
+
remove: (key: string, variant: string) => Promise<TExperimentActionResult>;
|
|
25
|
+
start: (key: string) => Promise<unknown>;
|
|
26
|
+
pause: (key: string) => Promise<unknown>;
|
|
27
|
+
stop: (key: string) => Promise<unknown>;
|
|
28
|
+
client: IExperimentsAdminClient;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The A/B test list and its actions from the qorus-api routes, for `<ExperimentsOverlay>` or
|
|
33
|
+
* `<ExperimentCard>`. Optional and replaceable: both components take plain data and callbacks.
|
|
34
|
+
*
|
|
35
|
+
* const admin = useExperimentsAdmin({ listUrl: '/qorus-cloud/editor/experiments?property=landing' });
|
|
36
|
+
* <ExperimentsOverlay tests={admin.tests} loadError={admin.error} onAccept={admin.accept} … />
|
|
37
|
+
*/
|
|
38
|
+
export const useExperimentsAdmin = (options: IUseExperimentsAdminOptions): IUseExperimentsAdmin => {
|
|
39
|
+
const { refreshMs = 60_000, enabled = true } = options;
|
|
40
|
+
// The options object is usually a fresh literal: the client follows its values, not its identity.
|
|
41
|
+
const latest = useRef(options);
|
|
42
|
+
latest.current = options;
|
|
43
|
+
const headersKey = JSON.stringify(options.headers ?? {});
|
|
44
|
+
const client = useMemo(
|
|
45
|
+
() =>
|
|
46
|
+
createExperimentsAdminClient({
|
|
47
|
+
...latest.current,
|
|
48
|
+
fetch: (input, init) => (latest.current.fetch ?? fetch)(input, init),
|
|
49
|
+
}),
|
|
50
|
+
[options.listUrl, options.createUrl, options.credentials, headersKey]
|
|
51
|
+
);
|
|
52
|
+
const [tests, setTests] = useState<IExperimentEntry[] | null>(null);
|
|
53
|
+
const [error, setError] = useState<string | null>(null);
|
|
54
|
+
const alive = useRef(true);
|
|
55
|
+
|
|
56
|
+
const reload = useCallback(async () => {
|
|
57
|
+
try {
|
|
58
|
+
const list = await client.list();
|
|
59
|
+
if (!alive.current) return;
|
|
60
|
+
setTests(list);
|
|
61
|
+
setError(null);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (alive.current) setError((err as Error).message);
|
|
64
|
+
}
|
|
65
|
+
}, [client]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
alive.current = true;
|
|
69
|
+
if (!enabled) return () => undefined;
|
|
70
|
+
void reload();
|
|
71
|
+
const id = refreshMs > 0 ? setInterval(() => void reload(), refreshMs) : null;
|
|
72
|
+
return () => {
|
|
73
|
+
alive.current = false;
|
|
74
|
+
if (id) clearInterval(id);
|
|
75
|
+
};
|
|
76
|
+
}, [reload, enabled, refreshMs]);
|
|
77
|
+
|
|
78
|
+
const then = useCallback(
|
|
79
|
+
async <T>(promise: Promise<T>): Promise<T> => {
|
|
80
|
+
const result = await promise;
|
|
81
|
+
await reload();
|
|
82
|
+
return result;
|
|
83
|
+
},
|
|
84
|
+
[reload]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const lifecycle = useCallback(
|
|
88
|
+
(key: string, action: TLifecycleAction) => then(client.lifecycle(key, action)),
|
|
89
|
+
[client, then]
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
tests,
|
|
94
|
+
error,
|
|
95
|
+
reload,
|
|
96
|
+
accept: useCallback((key, variant) => then(client.accept(key, variant)), [client, then]),
|
|
97
|
+
remove: useCallback((key, variant) => then(client.remove(key, variant)), [client, then]),
|
|
98
|
+
start: useCallback((key) => lifecycle(key, 'start'), [lifecycle]),
|
|
99
|
+
pause: useCallback((key) => lifecycle(key, 'pause'), [lifecycle]),
|
|
100
|
+
stop: useCallback((key) => lifecycle(key, 'stop'), [lifecycle]),
|
|
101
|
+
client,
|
|
102
|
+
};
|
|
103
|
+
};
|