@cascayd/experiment 0.1.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 CHANGED
@@ -138,3 +138,21 @@ import type { InitOptions, EventType, RecordOptions } from '@cascayd/experiment'
138
138
 
139
139
  MIT
140
140
 
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
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,14 +1,17 @@
1
1
  import { getOrCreateSession, readVariantChoice } from './cookies';
2
2
  let API_KEY = '';
3
- let BASE_URL = 'http://localhost:8000';
4
- const SDK_VERSION = '0.1.0-dev';
3
+ let BASE_URL = 'https://ab-mvp-backend.onrender.com';
4
+ const SDK_VERSION = '0.2.0-dev';
5
5
  export function initCascayd(opts) {
6
6
  API_KEY = opts.apiKey;
7
- BASE_URL = opts.baseUrl ?? BASE_URL;
8
7
  const masked = API_KEY ? API_KEY.slice(0, 3) + '***' + API_KEY.slice(-3) : '(empty)';
9
8
  // Visible init log to confirm updated SDK loaded
10
9
  // eslint-disable-next-line no-console
11
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
+ });
12
15
  }
13
16
  async function fetchConfig(experimentId) {
14
17
  const res = await fetch(`${BASE_URL}/experiments/${encodeURIComponent(experimentId)}/config`);
@@ -16,6 +19,18 @@ async function fetchConfig(experimentId) {
16
19
  throw new Error(`Config load failed: ${res.status}`);
17
20
  return (await res.json());
18
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
+ }
19
34
  function chooseByWeight(variants) {
20
35
  const total = variants.reduce((s, v) => s + v.weight, 0);
21
36
  const r = Math.random() * total;
@@ -28,9 +43,37 @@ function chooseByWeight(variants) {
28
43
  return variants[variants.length - 1]?.id ?? 'control';
29
44
  }
30
45
  export async function assignVariant(experimentId) {
31
- const cfg = await fetchConfig(experimentId);
32
- const variantId = chooseByWeight(cfg.variants);
33
- return { variantId, config: cfg };
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 }));
34
77
  }
35
78
  export async function record(type, opts = {}) {
36
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): import("react").ReactElement<any, string | import("react").JSXElementConstructor<any>> | null;
6
+ export declare function Experiment({ id, children }: ExperimentProps): ReactElement<any, string | import("react").JSXElementConstructor<any>> | null;
@@ -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
- // Decide assignment: use provided weights if any, else split evenly among variant children
45
- const provided = childrenArray.filter((c) => typeof c.weight === 'number');
46
- let chosen = null;
47
- if (provided.length > 0) {
48
- const normalized = (() => {
49
- const sum = provided.reduce((s, c) => s + c.weight, 0) || 1;
50
- return provided.map((c) => ({ id: c.id, weight: c.weight / sum }));
51
- })();
52
- chosen = chooseByWeight(normalized);
53
- }
54
- else if (childrenArray.length > 0) {
55
- const equal = 1 / childrenArray.length;
56
- const items = childrenArray.map((c) => ({ id: c.id, weight: equal }));
57
- chosen = chooseByWeight(items);
58
- }
59
- if (chosen && !cancelled) {
60
- persistVariantChoice(id, chosen);
61
- setActive(chosen);
62
- if (!sentImpressions.has(id)) {
63
- sentImpressions.add(id);
64
- void record('impression', { experimentId: id, variantId: chosen });
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
@@ -1,6 +1,5 @@
1
1
  export type InitOptions = {
2
2
  apiKey: string;
3
- baseUrl?: string;
4
3
  };
5
4
  export type VariantConfig = {
6
5
  id: string;
@@ -10,6 +9,13 @@ export type ExperimentConfigResponse = {
10
9
  experiment_id: string;
11
10
  variants: VariantConfig[];
12
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
+ };
13
19
  export type EventType = 'impression' | 'conversion';
14
20
  export type RecordOptions = {
15
21
  experimentId?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cascayd/experiment",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A lightweight A/B testing SDK for React applications with server-side analytics integration",
5
5
  "keywords": [
6
6
  "ab-testing",