@cascayd/experiment 0.2.0 → 0.3.0
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/README.md +18 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +48 -4
- package/dist/react/Experiment.d.ts +2 -1
- package/dist/react/Experiment.js +161 -43
- package/dist/shopify-handler-browser.js +323 -0
- package/dist/shopify-handler.d.ts +9 -0
- package/dist/shopify-handler.js +124 -0
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { InitOptions, ExperimentConfigResponse, EventType, RecordOptions } from './types';
|
|
1
|
+
import { InitOptions, ExperimentConfigResponse, EventType, RecordOptions, VariantStatusResponse } from './types';
|
|
2
2
|
export declare function initCascayd(opts: InitOptions): void;
|
|
3
|
+
export declare function getVariantStatus(experimentId: string, variantId: string): Promise<VariantStatusResponse>;
|
|
3
4
|
export declare function assignVariant(experimentId: string): Promise<{
|
|
4
5
|
variantId: string;
|
|
5
6
|
config: ExperimentConfigResponse;
|
package/dist/client.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { getOrCreateSession, readVariantChoice } from './cookies';
|
|
2
2
|
let API_KEY = '';
|
|
3
3
|
let BASE_URL = 'https://ab-mvp-backend.onrender.com';
|
|
4
|
-
const SDK_VERSION = '0.
|
|
4
|
+
const SDK_VERSION = '0.2.0-dev';
|
|
5
5
|
export function initCascayd(opts) {
|
|
6
6
|
API_KEY = opts.apiKey;
|
|
7
7
|
const masked = API_KEY ? API_KEY.slice(0, 3) + '***' + API_KEY.slice(-3) : '(empty)';
|
|
8
8
|
// Visible init log to confirm updated SDK loaded
|
|
9
9
|
// eslint-disable-next-line no-console
|
|
10
10
|
console.log('[cascayd-sdk]', SDK_VERSION, { baseUrl: BASE_URL, apiKey: masked });
|
|
11
|
+
console.log('[cascayd-sdk] init complete', {
|
|
12
|
+
version: SDK_VERSION,
|
|
13
|
+
url: BASE_URL,
|
|
14
|
+
});
|
|
11
15
|
}
|
|
12
16
|
async function fetchConfig(experimentId) {
|
|
13
17
|
const res = await fetch(`${BASE_URL}/experiments/${encodeURIComponent(experimentId)}/config`);
|
|
@@ -15,6 +19,18 @@ async function fetchConfig(experimentId) {
|
|
|
15
19
|
throw new Error(`Config load failed: ${res.status}`);
|
|
16
20
|
return (await res.json());
|
|
17
21
|
}
|
|
22
|
+
export async function getVariantStatus(experimentId, variantId) {
|
|
23
|
+
console.log('[cascayd-sdk] getVariantStatus', experimentId, variantId);
|
|
24
|
+
const res = await fetch(`${BASE_URL}/experiments/${encodeURIComponent(experimentId)}/variants/${encodeURIComponent(variantId)}/status`);
|
|
25
|
+
const data = (await res.json());
|
|
26
|
+
console.log('[cascayd-sdk] getVariantStatus response', data);
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const err = new Error(`Variant status failed: ${res.status}`);
|
|
29
|
+
err.payload = data;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
18
34
|
function chooseByWeight(variants) {
|
|
19
35
|
const total = variants.reduce((s, v) => s + v.weight, 0);
|
|
20
36
|
const r = Math.random() * total;
|
|
@@ -27,9 +43,37 @@ function chooseByWeight(variants) {
|
|
|
27
43
|
return variants[variants.length - 1]?.id ?? 'control';
|
|
28
44
|
}
|
|
29
45
|
export async function assignVariant(experimentId) {
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
46
|
+
const baseStatus = await getVariantStatus(experimentId, 'control');
|
|
47
|
+
const weights = baseStatus.weights || {};
|
|
48
|
+
const variants = Object.entries(weights).map(([id, weight]) => ({ id, weight }));
|
|
49
|
+
let candidate = chooseByWeight(variants.length > 0 ? variants : baseStatusToVariants(baseStatus));
|
|
50
|
+
let finalStatus = null;
|
|
51
|
+
if (candidate) {
|
|
52
|
+
try {
|
|
53
|
+
finalStatus = await getVariantStatus(experimentId, candidate);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error('[cascayd-sdk] getVariantStatus failed', { experimentId, candidate, error });
|
|
57
|
+
const weights = error?.payload?.weights;
|
|
58
|
+
if (weights) {
|
|
59
|
+
console.log('[cascayd-sdk] getVariantStatus failed weights', weights);
|
|
60
|
+
}
|
|
61
|
+
// swallow and fall back to candidate
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const serving = finalStatus?.serving_variant_id || candidate || 'control';
|
|
65
|
+
const config = {
|
|
66
|
+
experiment_id: baseStatus.experiment_id,
|
|
67
|
+
variants: variants.length > 0 ? variants : baseStatusToVariants(baseStatus),
|
|
68
|
+
};
|
|
69
|
+
return { variantId: serving, config };
|
|
70
|
+
}
|
|
71
|
+
function baseStatusToVariants(status) {
|
|
72
|
+
const entries = Object.entries(status.weights || {});
|
|
73
|
+
if (entries.length === 0) {
|
|
74
|
+
return [{ id: 'control', weight: 1 }];
|
|
75
|
+
}
|
|
76
|
+
return entries.map(([id, weight]) => ({ id, weight }));
|
|
33
77
|
}
|
|
34
78
|
export async function record(type, opts = {}) {
|
|
35
79
|
const sessionId = getOrCreateSession();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
1
2
|
export type ExperimentProps = {
|
|
2
3
|
id: string;
|
|
3
4
|
children: React.ReactNode;
|
|
4
5
|
};
|
|
5
|
-
export declare function Experiment({ id, children }: ExperimentProps):
|
|
6
|
+
export declare function Experiment({ id, children }: ExperimentProps): ReactElement<any, string | import("react").JSXElementConstructor<any>> | null;
|
package/dist/react/Experiment.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { record } from '../client';
|
|
2
|
+
import { getVariantStatus, record } from '../client';
|
|
3
3
|
import { getOrCreateSession, persistVariantChoice, readVariantChoice } from '../cookies';
|
|
4
4
|
const sentImpressions = new Set();
|
|
5
5
|
export function Experiment({ id, children }) {
|
|
@@ -15,61 +15,179 @@ export function Experiment({ id, children }) {
|
|
|
15
15
|
}
|
|
16
16
|
return arr;
|
|
17
17
|
}, [children]);
|
|
18
|
-
function chooseByWeight(items) {
|
|
19
|
-
const total = items.reduce((s, i) => s + i.weight, 0) || 1;
|
|
20
|
-
const r = Math.random() * total;
|
|
21
|
-
let acc = 0;
|
|
22
|
-
for (const it of items) {
|
|
23
|
-
acc += it.weight;
|
|
24
|
-
if (r <= acc)
|
|
25
|
-
return it.id;
|
|
26
|
-
}
|
|
27
|
-
return items[items.length - 1]?.id || 'control';
|
|
28
|
-
}
|
|
29
18
|
useEffect(() => {
|
|
30
19
|
if (hasRunRef.current)
|
|
31
20
|
return;
|
|
32
21
|
hasRunRef.current = true;
|
|
33
22
|
const existing = readVariantChoice(id);
|
|
34
|
-
if (existing) {
|
|
35
|
-
setActive(existing);
|
|
36
|
-
if (!sentImpressions.has(id)) {
|
|
37
|
-
sentImpressions.add(id);
|
|
38
|
-
void record('impression', { experimentId: id, variantId: existing });
|
|
39
|
-
}
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
23
|
let cancelled = false;
|
|
43
24
|
getOrCreateSession();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
25
|
+
void (async () => {
|
|
26
|
+
try {
|
|
27
|
+
if (existing) {
|
|
28
|
+
console.log('[cascayd-sdk] fetching variant status', {
|
|
29
|
+
experimentId: id,
|
|
30
|
+
variantId: existing,
|
|
31
|
+
});
|
|
32
|
+
console.log('[cascayd-sdk] current children', childrenArray.map((c) => c.id));
|
|
33
|
+
try {
|
|
34
|
+
const status = await getVariantStatus(id, existing);
|
|
35
|
+
if (cancelled)
|
|
36
|
+
return;
|
|
37
|
+
console.log('[cascayd-sdk] received variant status', status);
|
|
38
|
+
logWeights('[cascayd-sdk] existing variant weights', status.weights);
|
|
39
|
+
const serving = resolveVariant(status.serving_variant_id || existing, childrenArray);
|
|
40
|
+
applyAssignment(id, serving, setActive);
|
|
41
|
+
sendImpression(id, serving);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error('[cascayd-sdk] existing variant status fetch failed', {
|
|
46
|
+
experimentId: id,
|
|
47
|
+
variantId: existing,
|
|
48
|
+
error,
|
|
49
|
+
});
|
|
50
|
+
logWeights('[cascayd-sdk] existing variant weights (error path)', errorWeights(error));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
console.log('[cascayd-sdk] fetching base status', { experimentId: id, variantId: 'control' });
|
|
54
|
+
const baseStatus = await getVariantStatus(id, 'control');
|
|
55
|
+
if (cancelled)
|
|
56
|
+
return;
|
|
57
|
+
console.log('[cascayd-sdk] received base status', baseStatus);
|
|
58
|
+
logWeights('[cascayd-sdk] base weights', baseStatus.weights);
|
|
59
|
+
const weighted = buildWeights(childrenArray, baseStatus.weights);
|
|
60
|
+
console.log('[cascayd-sdk] child weights entries', weighted);
|
|
61
|
+
logWeights('[cascayd-sdk] computed child weights', Object.fromEntries(weighted.map((w) => [w.id, w.weight])));
|
|
62
|
+
const candidate = chooseByWeight(weighted);
|
|
63
|
+
let finalVariant = candidate;
|
|
64
|
+
if (candidate) {
|
|
65
|
+
console.log('[cascayd-sdk] verifying candidate status', {
|
|
66
|
+
experimentId: id,
|
|
67
|
+
variantId: candidate,
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
const status = await getVariantStatus(id, candidate);
|
|
71
|
+
if (cancelled)
|
|
72
|
+
return;
|
|
73
|
+
console.log('[cascayd-sdk] received candidate status', status);
|
|
74
|
+
logWeights('[cascayd-sdk] candidate weights', status.weights);
|
|
75
|
+
finalVariant = status.serving_variant_id || candidate;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.error('[cascayd-sdk] candidate status fetch failed, using fallback', {
|
|
79
|
+
experimentId: id,
|
|
80
|
+
variantId: candidate,
|
|
81
|
+
error,
|
|
82
|
+
});
|
|
83
|
+
logWeights('[cascayd-sdk] candidate weights (error path)', errorWeights(error));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const serving = resolveVariant(finalVariant, childrenArray);
|
|
87
|
+
applyAssignment(id, serving, setActive);
|
|
88
|
+
sendImpression(id, serving);
|
|
65
89
|
}
|
|
66
|
-
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error('[cascayd-sdk] variant assignment error, using fallback assignment', err);
|
|
92
|
+
console.log('[cascayd-sdk] fallback children', childrenArray);
|
|
93
|
+
const fallbackVariant = fallbackAssign(childrenArray);
|
|
94
|
+
if (cancelled)
|
|
95
|
+
return;
|
|
96
|
+
console.log('[cascayd-sdk] fallback assignment chosen', fallbackVariant);
|
|
97
|
+
applyAssignment(id, fallbackVariant, setActive);
|
|
98
|
+
sendImpression(id, fallbackVariant);
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
67
101
|
return () => {
|
|
68
102
|
cancelled = true;
|
|
69
103
|
};
|
|
70
|
-
}, [id]);
|
|
104
|
+
}, [id, childrenArray]);
|
|
71
105
|
if (!active)
|
|
72
106
|
return null;
|
|
73
107
|
const match = childrenArray.find((c) => c.id === active);
|
|
74
108
|
return match ? match.element : null;
|
|
75
109
|
}
|
|
110
|
+
function buildWeights(children, weights) {
|
|
111
|
+
if (weights && Object.keys(weights).length > 0) {
|
|
112
|
+
const subset = children
|
|
113
|
+
.map((child) => ({ id: child.id, weight: typeof weights[child.id] === 'number' ? weights[child.id] : 0 }))
|
|
114
|
+
.filter((item) => item.weight > 0);
|
|
115
|
+
if (subset.length > 0) {
|
|
116
|
+
const total = subset.reduce((sum, item) => sum + item.weight, 0) || 1;
|
|
117
|
+
return subset.map((item) => ({ id: item.id, weight: item.weight / total }));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const provided = children.filter((c) => typeof c.weight === 'number');
|
|
121
|
+
if (provided.length > 0) {
|
|
122
|
+
const sum = provided.reduce((acc, item) => acc + item.weight, 0) || 1;
|
|
123
|
+
return provided.map((item) => ({ id: item.id, weight: item.weight / sum }));
|
|
124
|
+
}
|
|
125
|
+
if (children.length === 0)
|
|
126
|
+
return [{ id: 'control', weight: 1 }];
|
|
127
|
+
const equal = 1 / children.length;
|
|
128
|
+
return children.map((c) => ({ id: c.id, weight: equal }));
|
|
129
|
+
}
|
|
130
|
+
function resolveVariant(candidate, children) {
|
|
131
|
+
if (candidate && children.some((c) => c.id === candidate))
|
|
132
|
+
return candidate;
|
|
133
|
+
if (children.some((c) => c.id === 'control'))
|
|
134
|
+
return 'control';
|
|
135
|
+
return children[0]?.id ?? 'control';
|
|
136
|
+
}
|
|
137
|
+
function applyAssignment(experimentId, variantId, setActive) {
|
|
138
|
+
persistVariantChoice(experimentId, variantId);
|
|
139
|
+
setActive(variantId);
|
|
140
|
+
}
|
|
141
|
+
function sendImpression(experimentId, variantId) {
|
|
142
|
+
if (!variantId)
|
|
143
|
+
return;
|
|
144
|
+
if (sentImpressions.has(`${experimentId}:${variantId}`))
|
|
145
|
+
return;
|
|
146
|
+
sentImpressions.add(`${experimentId}:${variantId}`);
|
|
147
|
+
void record('impression', { experimentId, variantId });
|
|
148
|
+
}
|
|
149
|
+
function fallbackAssign(children) {
|
|
150
|
+
console.log('[cascayd-sdk] fallback assign start', children);
|
|
151
|
+
const weighted = buildWeights(children, undefined);
|
|
152
|
+
console.log('[cascayd-sdk] fallback weighted', weighted);
|
|
153
|
+
const chosen = chooseByWeight(weighted);
|
|
154
|
+
console.log('[cascayd-sdk] fallback chosen', chosen);
|
|
155
|
+
return chosen;
|
|
156
|
+
}
|
|
157
|
+
function chooseByWeight(items) {
|
|
158
|
+
console.log('[cascayd-sdk] choosing by weight', items);
|
|
159
|
+
const total = items.reduce((sum, item) => sum + item.weight, 0) || 1;
|
|
160
|
+
const roll = Math.random() * total;
|
|
161
|
+
console.log('[cascayd-sdk] total weight', total, 'roll', roll);
|
|
162
|
+
let acc = 0;
|
|
163
|
+
for (const item of items) {
|
|
164
|
+
acc += item.weight;
|
|
165
|
+
console.log('[cascayd-sdk] accumulate', { item: item.id, acc });
|
|
166
|
+
if (roll <= acc)
|
|
167
|
+
return item.id;
|
|
168
|
+
}
|
|
169
|
+
return items[items.length - 1]?.id || 'control';
|
|
170
|
+
}
|
|
171
|
+
function logWeights(label, weights) {
|
|
172
|
+
if (!weights || Object.keys(weights).length === 0) {
|
|
173
|
+
console.log(label, '(none)');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
console.log(label, weights);
|
|
177
|
+
console.table(Object.entries(weights).map(([variantId, weight]) => ({
|
|
178
|
+
variantId,
|
|
179
|
+
weight: Number(weight.toFixed(4)),
|
|
180
|
+
percentage: `${(weight * 100).toFixed(2)}%`,
|
|
181
|
+
})));
|
|
182
|
+
}
|
|
183
|
+
function errorWeights(error) {
|
|
184
|
+
if (!error)
|
|
185
|
+
return undefined;
|
|
186
|
+
if (typeof error === 'object' && error && 'weights' in error) {
|
|
187
|
+
const weights = error.weights;
|
|
188
|
+
if (weights && typeof weights === 'object') {
|
|
189
|
+
return weights;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascayd A/B Testing - Shopify Theme Handler (Browser Standalone)
|
|
3
|
+
* This script automatically handles variant selection for elements with
|
|
4
|
+
* data-cascayd-experiment and data-cascayd-variant attributes
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* 1. Load the Cascayd SDK first:
|
|
8
|
+
* <script type="module" src="https://cdn.jsdelivr.net/npm/@cascayd/experiment@latest/dist/client.js"></script>
|
|
9
|
+
* 2. Load this handler:
|
|
10
|
+
* <script src="https://cdn.jsdelivr.net/npm/@cascayd/experiment@latest/dist/shopify-handler-browser.js"></script>
|
|
11
|
+
*
|
|
12
|
+
* Or if SDK is loaded as a module that exposes functions to window:
|
|
13
|
+
* <script>
|
|
14
|
+
* window.initCascayd({ apiKey: 'YOUR_KEY' });
|
|
15
|
+
* </script>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
(function() {
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
// Cookie helper functions (matching SDK's cookie logic)
|
|
22
|
+
function getCookie(name) {
|
|
23
|
+
const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.$?*|{}()\[\]\\\/\+^]/g, '\\$&') + '=([^;]*)'));
|
|
24
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setCookie(name, value) {
|
|
28
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; SameSite=Lax; max-age=31536000`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getVariantCookieKey(experimentId) {
|
|
32
|
+
return `cascayd:${experimentId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readVariantChoice(experimentId) {
|
|
36
|
+
return getCookie(getVariantCookieKey(experimentId));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function persistVariantChoice(experimentId, variantId) {
|
|
40
|
+
setCookie(getVariantCookieKey(experimentId), variantId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// API configuration (same as SDK)
|
|
44
|
+
const BASE_URL = 'https://ab-mvp-backend.onrender.com';
|
|
45
|
+
let API_KEY = '';
|
|
46
|
+
|
|
47
|
+
// Try to get API key from window (if SDK was initialized)
|
|
48
|
+
function getAPIKey() {
|
|
49
|
+
if (typeof window !== 'undefined' && window._cascaydAPIKey) {
|
|
50
|
+
return window._cascaydAPIKey;
|
|
51
|
+
}
|
|
52
|
+
return API_KEY;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Direct API call functions (same logic as SDK)
|
|
56
|
+
async function getVariantStatus(experimentId, variantId) {
|
|
57
|
+
const res = await fetch(
|
|
58
|
+
BASE_URL + '/experiments/' + encodeURIComponent(experimentId) + '/variants/' + encodeURIComponent(variantId) + '/status'
|
|
59
|
+
);
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const err = new Error('Variant status failed: ' + res.status);
|
|
63
|
+
err.payload = data;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function chooseByWeight(variants) {
|
|
70
|
+
const total = variants.reduce(function(s, v) { return s + v.weight; }, 0);
|
|
71
|
+
const r = Math.random() * total;
|
|
72
|
+
let acc = 0;
|
|
73
|
+
for (let i = 0; i < variants.length; i++) {
|
|
74
|
+
acc += variants[i].weight;
|
|
75
|
+
if (r <= acc) {
|
|
76
|
+
return variants[i].id;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return variants[variants.length - 1] ? variants[variants.length - 1].id : 'control';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function baseStatusToVariants(status) {
|
|
83
|
+
const entries = Object.entries(status.weights || {});
|
|
84
|
+
if (entries.length === 0) {
|
|
85
|
+
return [{ id: 'control', weight: 1 }];
|
|
86
|
+
}
|
|
87
|
+
return entries.map(function(entry) {
|
|
88
|
+
return { id: entry[0], weight: entry[1] };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Assign variant using same logic as SDK
|
|
93
|
+
async function assignVariantDirect(experimentId) {
|
|
94
|
+
const baseStatus = await getVariantStatus(experimentId, 'control');
|
|
95
|
+
const weights = baseStatus.weights || {};
|
|
96
|
+
const variants = Object.entries(weights).map(function(entry) {
|
|
97
|
+
return { id: entry[0], weight: entry[1] };
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const allVariants = variants.length > 0 ? variants : baseStatusToVariants(baseStatus);
|
|
101
|
+
let candidate = chooseByWeight(allVariants);
|
|
102
|
+
let finalStatus = null;
|
|
103
|
+
|
|
104
|
+
if (candidate) {
|
|
105
|
+
try {
|
|
106
|
+
finalStatus = await getVariantStatus(experimentId, candidate);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('[cascayd-shopify] getVariantStatus failed', { experimentId: experimentId, candidate: candidate, error: error });
|
|
109
|
+
// Fall back to candidate
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const serving = (finalStatus && finalStatus.serving_variant_id) || candidate || 'control';
|
|
114
|
+
return { variantId: serving };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Record event using same logic as SDK
|
|
118
|
+
async function recordDirect(type, opts) {
|
|
119
|
+
const sessionId = getOrCreateSession();
|
|
120
|
+
const body = {
|
|
121
|
+
type: type,
|
|
122
|
+
session_id: sessionId
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (opts.experimentId) {
|
|
126
|
+
body.experiment_id = opts.experimentId;
|
|
127
|
+
const fromCookie = readVariantChoice(opts.experimentId);
|
|
128
|
+
const variantId = opts.variantId || fromCookie;
|
|
129
|
+
if (variantId) {
|
|
130
|
+
body.variant_id = variantId;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof opts.value === 'number') {
|
|
135
|
+
body.value = opts.value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const headers = {
|
|
139
|
+
'Content-Type': 'application/json'
|
|
140
|
+
};
|
|
141
|
+
const apiKey = getAPIKey();
|
|
142
|
+
if (apiKey) {
|
|
143
|
+
headers.Authorization = 'Bearer ' + apiKey;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const res = await fetch(BASE_URL + '/events', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: headers,
|
|
149
|
+
body: JSON.stringify(body)
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
throw new Error('Record failed: ' + res.status);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getOrCreateSession() {
|
|
158
|
+
const key = 'cascayd:session';
|
|
159
|
+
let id = getCookie(key);
|
|
160
|
+
if (id) return id;
|
|
161
|
+
id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
162
|
+
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >>> 0;
|
|
163
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
164
|
+
return v.toString(16);
|
|
165
|
+
});
|
|
166
|
+
setCookie(key, id);
|
|
167
|
+
return id;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Try to use SDK functions if available, otherwise use direct API calls
|
|
171
|
+
async function waitForSDK(maxAttempts, interval) {
|
|
172
|
+
maxAttempts = maxAttempts || 10;
|
|
173
|
+
interval = interval || 100;
|
|
174
|
+
|
|
175
|
+
return new Promise(function(resolve) {
|
|
176
|
+
let attempts = 0;
|
|
177
|
+
const checkSDK = function() {
|
|
178
|
+
attempts++;
|
|
179
|
+
// Check if SDK functions are available on window
|
|
180
|
+
let assignVariantFn = null;
|
|
181
|
+
let recordFn = null;
|
|
182
|
+
|
|
183
|
+
if (typeof window !== 'undefined') {
|
|
184
|
+
if (window.assignVariant) assignVariantFn = window.assignVariant;
|
|
185
|
+
if (window.record) recordFn = window.record;
|
|
186
|
+
// Try Cascayd namespace
|
|
187
|
+
if (!assignVariantFn && window.Cascayd && window.Cascayd.assignVariant) {
|
|
188
|
+
assignVariantFn = window.Cascayd.assignVariant;
|
|
189
|
+
}
|
|
190
|
+
if (!recordFn && window.Cascayd && window.Cascayd.record) {
|
|
191
|
+
recordFn = window.Cascayd.record;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (assignVariantFn && recordFn) {
|
|
196
|
+
resolve({ assignVariant: assignVariantFn, record: recordFn });
|
|
197
|
+
} else if (attempts >= maxAttempts) {
|
|
198
|
+
// Use direct API calls as fallback
|
|
199
|
+
resolve({ assignVariant: assignVariantDirect, record: recordDirect });
|
|
200
|
+
} else {
|
|
201
|
+
setTimeout(checkSDK, interval);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
checkSDK();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Record impression event
|
|
209
|
+
async function recordImpression(experimentId, variantId, sdk) {
|
|
210
|
+
if (!sdk || !sdk.record) {
|
|
211
|
+
console.debug('[cascayd-shopify] Record function not available, skipping impression tracking');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await sdk.record('impression', {
|
|
217
|
+
experimentId: experimentId,
|
|
218
|
+
variantId: variantId
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error('[cascayd-shopify] Failed to record impression', error);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Process a single experiment
|
|
226
|
+
async function processExperiment(experimentId, variantElements, sdk) {
|
|
227
|
+
// Check if we already have a variant choice stored
|
|
228
|
+
let variantId = readVariantChoice(experimentId);
|
|
229
|
+
|
|
230
|
+
// If no stored choice, assign a variant
|
|
231
|
+
if (!variantId) {
|
|
232
|
+
if (sdk && sdk.assignVariant) {
|
|
233
|
+
try {
|
|
234
|
+
const result = await sdk.assignVariant(experimentId);
|
|
235
|
+
variantId = result.variantId;
|
|
236
|
+
persistVariantChoice(experimentId, variantId);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.error('[cascayd-shopify] Failed to assign variant', error);
|
|
239
|
+
// Fallback: use first available variant or control
|
|
240
|
+
const variants = [];
|
|
241
|
+
variantElements.forEach(function(el) {
|
|
242
|
+
const vId = el.getAttribute('data-cascayd-variant');
|
|
243
|
+
if (vId && variants.indexOf(vId) === -1) {
|
|
244
|
+
variants.push(vId);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
variantId = variants.indexOf('control') >= 0 ? 'control' : (variants[0] || 'control');
|
|
248
|
+
persistVariantChoice(experimentId, variantId);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
console.error('[cascayd-shopify] No assignVariant function available');
|
|
252
|
+
// This shouldn't happen since waitForSDK always returns functions (direct API or SDK)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Show the selected variant, hide all others
|
|
257
|
+
let hasRecorded = false;
|
|
258
|
+
variantElements.forEach(function(el) {
|
|
259
|
+
const elVariantId = el.getAttribute('data-cascayd-variant');
|
|
260
|
+
if (elVariantId === variantId) {
|
|
261
|
+
el.style.display = '';
|
|
262
|
+
el.removeAttribute('hidden');
|
|
263
|
+
// Record impression only once for the shown variant
|
|
264
|
+
if (!hasRecorded) {
|
|
265
|
+
recordImpression(experimentId, variantId, sdk);
|
|
266
|
+
hasRecorded = true;
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
el.style.display = 'none';
|
|
270
|
+
el.setAttribute('hidden', 'hidden');
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Main initialization function
|
|
276
|
+
async function initCascaydExperiments() {
|
|
277
|
+
// Wait for SDK to be loaded (with timeout)
|
|
278
|
+
const sdk = await waitForSDK(50, 100);
|
|
279
|
+
|
|
280
|
+
// Find all elements with data-cascayd-experiment attribute
|
|
281
|
+
const allExperimentElements = document.querySelectorAll('[data-cascayd-experiment]');
|
|
282
|
+
|
|
283
|
+
if (allExperimentElements.length === 0) {
|
|
284
|
+
return; // No experiments to process
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Group elements by experiment ID
|
|
288
|
+
const experiments = new Map();
|
|
289
|
+
|
|
290
|
+
allExperimentElements.forEach(function(el) {
|
|
291
|
+
const experimentId = el.getAttribute('data-cascayd-experiment');
|
|
292
|
+
if (!experimentId) return;
|
|
293
|
+
|
|
294
|
+
if (!experiments.has(experimentId)) {
|
|
295
|
+
experiments.set(experimentId, []);
|
|
296
|
+
}
|
|
297
|
+
experiments.get(experimentId).push(el);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Process each experiment
|
|
301
|
+
const promises = [];
|
|
302
|
+
experiments.forEach(function(variantElements, experimentId) {
|
|
303
|
+
promises.push(processExperiment(experimentId, variantElements, sdk));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await Promise.all(promises);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Initialize when DOM is ready
|
|
310
|
+
if (document.readyState === 'loading') {
|
|
311
|
+
document.addEventListener('DOMContentLoaded', initCascaydExperiments);
|
|
312
|
+
} else {
|
|
313
|
+
// DOM already ready
|
|
314
|
+
initCascaydExperiments();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Handle Shopify's dynamic content (sections loaded via AJAX)
|
|
318
|
+
if (typeof window.addEventListener !== 'undefined') {
|
|
319
|
+
document.addEventListener('shopify:section:load', initCascaydExperiments);
|
|
320
|
+
document.addEventListener('shopify:section:reorder', initCascaydExperiments);
|
|
321
|
+
}
|
|
322
|
+
})();
|
|
323
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascayd A/B Testing - Shopify Theme Handler
|
|
3
|
+
* This script automatically handles variant selection for elements with
|
|
4
|
+
* data-cascayd-experiment and data-cascayd-variant attributes
|
|
5
|
+
*
|
|
6
|
+
* Expects the Cascayd SDK to be loaded first via:
|
|
7
|
+
* <script src="https://cdn.jsdelivr.net/npm/@cascayd/experiment@latest/dist/client.js"></script>
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascayd A/B Testing - Shopify Theme Handler
|
|
3
|
+
* This script automatically handles variant selection for elements with
|
|
4
|
+
* data-cascayd-experiment and data-cascayd-variant attributes
|
|
5
|
+
*
|
|
6
|
+
* Expects the Cascayd SDK to be loaded first via:
|
|
7
|
+
* <script src="https://cdn.jsdelivr.net/npm/@cascayd/experiment@latest/dist/client.js"></script>
|
|
8
|
+
*/
|
|
9
|
+
import { assignVariant, record } from './client';
|
|
10
|
+
import { readVariantChoice, persistVariantChoice } from './cookies';
|
|
11
|
+
// Wait for SDK to be available
|
|
12
|
+
function waitForSDK(maxAttempts = 50, interval = 100) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let attempts = 0;
|
|
15
|
+
const checkSDK = () => {
|
|
16
|
+
attempts++;
|
|
17
|
+
// Check if SDK functions are available (either from import or window)
|
|
18
|
+
if (typeof assignVariant === 'function' && typeof record === 'function') {
|
|
19
|
+
resolve();
|
|
20
|
+
}
|
|
21
|
+
else if (attempts >= maxAttempts) {
|
|
22
|
+
console.warn('[cascayd-shopify] SDK not loaded after', maxAttempts, 'attempts');
|
|
23
|
+
reject(new Error('SDK not available'));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
setTimeout(checkSDK, interval);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
checkSDK();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
// Record impression event
|
|
33
|
+
async function recordImpression(experimentId, variantId) {
|
|
34
|
+
try {
|
|
35
|
+
await record('impression', {
|
|
36
|
+
experimentId,
|
|
37
|
+
variantId,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.error('[cascayd-shopify] Failed to record impression', error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Process a single experiment
|
|
45
|
+
async function processExperiment(experimentId, variantElements) {
|
|
46
|
+
// Check if we already have a variant choice stored
|
|
47
|
+
let variantId = readVariantChoice(experimentId);
|
|
48
|
+
// If no stored choice, assign a variant
|
|
49
|
+
if (!variantId) {
|
|
50
|
+
try {
|
|
51
|
+
const result = await assignVariant(experimentId);
|
|
52
|
+
variantId = result.variantId;
|
|
53
|
+
persistVariantChoice(experimentId, variantId);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error('[cascayd-shopify] Failed to assign variant', error);
|
|
57
|
+
// Fallback: use first available variant or control
|
|
58
|
+
const variants = variantElements.map(el => el.getAttribute('data-cascayd-variant')).filter(Boolean);
|
|
59
|
+
variantId = variants.find(v => v === 'control') || variants[0] || 'control';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Show the selected variant, hide all others
|
|
63
|
+
let hasRecorded = false;
|
|
64
|
+
variantElements.forEach(el => {
|
|
65
|
+
const htmlEl = el;
|
|
66
|
+
const elVariantId = el.getAttribute('data-cascayd-variant');
|
|
67
|
+
if (elVariantId === variantId) {
|
|
68
|
+
htmlEl.style.display = '';
|
|
69
|
+
el.removeAttribute('hidden');
|
|
70
|
+
// Record impression only once for the shown variant
|
|
71
|
+
if (!hasRecorded) {
|
|
72
|
+
recordImpression(experimentId, variantId);
|
|
73
|
+
hasRecorded = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
htmlEl.style.display = 'none';
|
|
78
|
+
el.setAttribute('hidden', 'hidden');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// Main initialization function
|
|
83
|
+
async function initCascaydExperiments() {
|
|
84
|
+
// Wait for SDK to be loaded
|
|
85
|
+
try {
|
|
86
|
+
await waitForSDK();
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error('[cascayd-shopify] SDK not available, experiments will not run', error);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Find all elements with data-cascayd-experiment attribute
|
|
93
|
+
const allExperimentElements = document.querySelectorAll('[data-cascayd-experiment]');
|
|
94
|
+
if (allExperimentElements.length === 0) {
|
|
95
|
+
return; // No experiments to process
|
|
96
|
+
}
|
|
97
|
+
// Group elements by experiment ID
|
|
98
|
+
const experiments = new Map();
|
|
99
|
+
allExperimentElements.forEach(el => {
|
|
100
|
+
const experimentId = el.getAttribute('data-cascayd-experiment');
|
|
101
|
+
if (!experimentId)
|
|
102
|
+
return;
|
|
103
|
+
if (!experiments.has(experimentId)) {
|
|
104
|
+
experiments.set(experimentId, []);
|
|
105
|
+
}
|
|
106
|
+
experiments.get(experimentId).push(el);
|
|
107
|
+
});
|
|
108
|
+
// Process each experiment in parallel
|
|
109
|
+
const promises = Array.from(experiments.entries()).map(([experimentId, variantElements]) => processExperiment(experimentId, variantElements));
|
|
110
|
+
await Promise.all(promises);
|
|
111
|
+
}
|
|
112
|
+
// Initialize when DOM is ready
|
|
113
|
+
if (document.readyState === 'loading') {
|
|
114
|
+
document.addEventListener('DOMContentLoaded', initCascaydExperiments);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// DOM already ready
|
|
118
|
+
initCascaydExperiments();
|
|
119
|
+
}
|
|
120
|
+
// Handle Shopify's dynamic content (sections loaded via AJAX)
|
|
121
|
+
if (typeof window.addEventListener !== 'undefined') {
|
|
122
|
+
document.addEventListener('shopify:section:load', initCascaydExperiments);
|
|
123
|
+
document.addEventListener('shopify:section:reorder', initCascaydExperiments);
|
|
124
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ export type ExperimentConfigResponse = {
|
|
|
9
9
|
experiment_id: string;
|
|
10
10
|
variants: VariantConfig[];
|
|
11
11
|
};
|
|
12
|
+
export type VariantStatusResponse = {
|
|
13
|
+
experiment_id: string;
|
|
14
|
+
requested_variant_id: string;
|
|
15
|
+
variant_status: string | null;
|
|
16
|
+
serving_variant_id: string;
|
|
17
|
+
weights: Record<string, number>;
|
|
18
|
+
};
|
|
12
19
|
export type EventType = 'impression' | 'conversion';
|
|
13
20
|
export type RecordOptions = {
|
|
14
21
|
experimentId?: string;
|