@xenia-martech/sdk-js 1.0.0-dev.26 → 1.0.0-dev.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CSS_VARIABLES.md +7 -0
  2. package/README.md +71 -2
  3. package/dist/{actions-DFLoSXO_.d.cts → actions-CcBYV--3.d.cts} +242 -6
  4. package/dist/{actions-DFLoSXO_.d.ts → actions-CcBYV--3.d.ts} +242 -6
  5. package/dist/{chunk-3KYKKIIG.cjs → chunk-WYM4CQVG.cjs} +126 -4
  6. package/dist/chunk-WYM4CQVG.cjs.map +1 -0
  7. package/dist/{chunk-D37RD3UC.js → chunk-XXNDHYAS.js} +125 -5
  8. package/dist/chunk-XXNDHYAS.js.map +1 -0
  9. package/dist/client/index.cjs +613 -35
  10. package/dist/client/index.cjs.map +1 -1
  11. package/dist/client/index.d.cts +112 -3
  12. package/dist/client/index.d.ts +112 -3
  13. package/dist/client/index.js +610 -36
  14. package/dist/client/index.js.map +1 -1
  15. package/dist/client/react/index.cjs +609 -35
  16. package/dist/client/react/index.cjs.map +1 -1
  17. package/dist/client/react/index.d.cts +2 -2
  18. package/dist/client/react/index.d.ts +2 -2
  19. package/dist/client/react/index.js +609 -35
  20. package/dist/client/react/index.js.map +1 -1
  21. package/dist/{cookie-names-LgNDAreS.d.cts → cookie-names-B3b0XNc2.d.cts} +187 -1
  22. package/dist/{cookie-names-LgNDAreS.d.ts → cookie-names-B3b0XNc2.d.ts} +187 -1
  23. package/dist/index.cjs +23 -19
  24. package/dist/index.d.cts +23 -3
  25. package/dist/index.d.ts +23 -3
  26. package/dist/index.js +1 -1
  27. package/dist/server/index.cjs +53 -22
  28. package/dist/server/index.cjs.map +1 -1
  29. package/dist/server/index.d.cts +31 -11
  30. package/dist/server/index.d.ts +31 -11
  31. package/dist/server/index.js +43 -12
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/xenia-sdk.iife.js +37 -3
  34. package/dist/xenia-sdk.iife.js.map +1 -1
  35. package/package.json +1 -1
  36. package/dist/chunk-3KYKKIIG.cjs.map +0 -1
  37. package/dist/chunk-D37RD3UC.js.map +0 -1
@@ -20,6 +20,7 @@ var MESSAGE_POSITIONS = /* @__PURE__ */ new Set([
20
20
  "top",
21
21
  "bottom"
22
22
  ]);
23
+ var WIDGET_TYPES = /* @__PURE__ */ new Set(["standard", "spin-wheel"]);
23
24
  var ALL_TYPES = /* @__PURE__ */ new Set([
24
25
  "swap",
25
26
  "hide",
@@ -66,15 +67,67 @@ function toAction(raw) {
66
67
  message: asString(raw.message),
67
68
  imageUrl: asString(raw.imageUrl),
68
69
  html: asString(raw.html),
69
- cta: cta && cta.label ? cta : void 0,
70
+ // A link-only CTA (empty label, e.g. a spin-wheel's data-xenia-wheel-cta —
71
+ // label is meaningless there, the winHtml/lossHtml design supplies its own button text)
72
+ // must still parse through — gating on `cta.label` alone silently dropped it.
73
+ cta: cta && (cta.label || cta.url) ? cta : void 0,
70
74
  dismissAfterMs: asNumber(raw.dismissAfterMs),
71
- position: asMessagePosition(raw.position)
75
+ position: asMessagePosition(raw.position),
76
+ widgetType: asWidgetType(raw.widgetType),
77
+ segments: asSegments(raw.segments),
78
+ formSubmit: asFormSubmit(raw.formSubmit),
79
+ style: asWidgetStyle(raw.style),
80
+ spinnerHtml: asString(raw.spinnerHtml),
81
+ winHtml: asString(raw.winHtml),
82
+ lossHtml: asString(raw.lossHtml)
72
83
  };
73
84
  }
74
85
  function asMessagePosition(v) {
75
86
  const s = asString(v);
76
87
  return s && MESSAGE_POSITIONS.has(s) ? s : void 0;
77
88
  }
89
+ function asWidgetType(v) {
90
+ const s = asString(v);
91
+ return s && WIDGET_TYPES.has(s) ? s : void 0;
92
+ }
93
+ function asSegments(v) {
94
+ if (!Array.isArray(v)) return void 0;
95
+ const segments = [];
96
+ for (const entry of v) {
97
+ if (!isObject(entry)) continue;
98
+ const id = asString(entry.id);
99
+ const label = asString(entry.label);
100
+ if (!id || !label) continue;
101
+ segments.push({
102
+ id,
103
+ label,
104
+ colorHex: asString(entry.colorHex),
105
+ isLoss: entry.isLoss === true
106
+ });
107
+ }
108
+ return segments.length ? segments : void 0;
109
+ }
110
+ function asFormSubmit(v) {
111
+ if (!isObject(v)) return void 0;
112
+ const mode = asString(v.mode);
113
+ if (!mode) return void 0;
114
+ return {
115
+ requiresResolve: v.requiresResolve === true,
116
+ mode,
117
+ flowRef: asString(v.flowRef),
118
+ bindingRef: asString(v.bindingRef),
119
+ successDisplayMs: asNumber(v.successDisplayMs)
120
+ };
121
+ }
122
+ function asWidgetStyle(v) {
123
+ if (!isObject(v)) return void 0;
124
+ const style = {
125
+ hideCloseButton: v.hideCloseButton === true,
126
+ hideOverlay: v.hideOverlay === true
127
+ };
128
+ const hasAny = style.hideCloseButton || style.hideOverlay;
129
+ return hasAny ? style : void 0;
130
+ }
78
131
  function parseActions(body) {
79
132
  let list;
80
133
  if (Array.isArray(body)) {
@@ -257,6 +310,12 @@ var TokenProvider = class {
257
310
  };
258
311
 
259
312
  // src/core/api.ts
313
+ function isJsonObject(v) {
314
+ return typeof v === "object" && v !== null && !Array.isArray(v);
315
+ }
316
+ function asWidgetPrize(v) {
317
+ return v !== void 0 && isJsonObject(v) ? v : void 0;
318
+ }
260
319
  function compact(obj) {
261
320
  const out = {};
262
321
  for (const [k, v] of Object.entries(obj)) {
@@ -326,6 +385,39 @@ var XeniaApi = class {
326
385
  receivedAt: Date.now()
327
386
  };
328
387
  }
388
+ /**
389
+ * Widget form submission — `POST /widget-submissions?ref={bindingRef}`. Used whenever the
390
+ * action's `formSubmit.requiresResolve` is true (spin-wheel, which must have a
391
+ * server-resolved outcome before anything renders) or `mode === "endpoint"` — the common
392
+ * plain-popup case (`mode === "journey" && !requiresResolve`) bypasses this entirely and calls
393
+ * {@link webhook} against `flowRef` instead (see `XeniaClient.submitWidgetForm`'s endpoint
394
+ * selection).
395
+ *
396
+ * Not retried (`noRetry: true`), styled like {@link webhook} — a retried POST must never
397
+ * double-fire a journey or double-resolve a prize server-side.
398
+ */
399
+ async submitWidget(ref, body) {
400
+ const { response, data: rawBody } = await this.http.request({
401
+ method: "POST",
402
+ path: "/widget-submissions",
403
+ query: { ref },
404
+ headers: await this.authHeaders(),
405
+ body: body ?? {},
406
+ noRetry: true
407
+ });
408
+ const obj = isJsonObject(rawBody) ? rawBody : {};
409
+ return {
410
+ status: response.status,
411
+ body: rawBody,
412
+ actions: parseActions(rawBody),
413
+ prize: asWidgetPrize(obj.prize),
414
+ alreadyPlayed: obj.alreadyPlayed === true,
415
+ dispatched: obj.dispatched === true,
416
+ dispatchError: typeof obj.dispatchError === "string" ? obj.dispatchError : void 0,
417
+ widgetType: typeof obj.widgetType === "string" ? obj.widgetType : void 0,
418
+ receivedAt: Date.now()
419
+ };
420
+ }
329
421
  /**
330
422
  * Unified event batch (max 100/request per the backend) — `POST /api/events/v1`.
331
423
  * Records analytics for every event and matches/advances a journey for any
@@ -971,6 +1063,40 @@ var Identity = class {
971
1063
  return null;
972
1064
  }
973
1065
  }
1066
+ // ── Customer id / email ──────────────────────────────────────────────────
1067
+ //
1068
+ // Ambient, persisted the same way as the visitor id, so a value set once
1069
+ // (typically alongside `identify()`) rides on every later `resolve()` call
1070
+ // without the caller having to pass it again — the SDK-provided-fields
1071
+ // parity list on the Personalization Resolve trigger includes both.
1072
+ setCustomerId(customerId) {
1073
+ try {
1074
+ if (customerId) localStorage.setItem(this.key("customer_id"), customerId);
1075
+ else localStorage.removeItem(this.key("customer_id"));
1076
+ } catch {
1077
+ }
1078
+ }
1079
+ getCustomerId() {
1080
+ try {
1081
+ return localStorage.getItem(this.key("customer_id"));
1082
+ } catch {
1083
+ return null;
1084
+ }
1085
+ }
1086
+ setEmail(email) {
1087
+ try {
1088
+ if (email) localStorage.setItem(this.key("email"), email);
1089
+ else localStorage.removeItem(this.key("email"));
1090
+ } catch {
1091
+ }
1092
+ }
1093
+ getEmail() {
1094
+ try {
1095
+ return localStorage.getItem(this.key("email"));
1096
+ } catch {
1097
+ return null;
1098
+ }
1099
+ }
974
1100
  // ── Session id ───────────────────────────────────────────────────────────────
975
1101
  /**
976
1102
  * Resolve the current session id: reused while activity continues within
@@ -1059,6 +1185,316 @@ function resolveCookieConfig(opt) {
1059
1185
  return { sameSite, secure, maxAgeDays: o.maxAgeDays ?? 365, domain: o.domain };
1060
1186
  }
1061
1187
 
1188
+ // src/client/widgets/form.ts
1189
+ function collectFields(root) {
1190
+ const fields = {};
1191
+ root.querySelectorAll("[data-xenia-field]").forEach((el) => {
1192
+ const name = el.getAttribute("data-xenia-field");
1193
+ if (!name) return;
1194
+ fields[name] = fieldValue(el);
1195
+ });
1196
+ return fields;
1197
+ }
1198
+ function fieldValue(el) {
1199
+ if (el instanceof HTMLInputElement) {
1200
+ if (el.type === "checkbox") return el.checked ? el.value || "on" : "";
1201
+ if (el.type === "radio") return el.checked ? el.value : "";
1202
+ return el.value;
1203
+ }
1204
+ if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return el.value;
1205
+ return el.textContent?.trim() ?? "";
1206
+ }
1207
+ function setDisabled(el, disabled) {
1208
+ if (!el) return;
1209
+ if (el instanceof HTMLButtonElement || el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement || el instanceof HTMLFieldSetElement) {
1210
+ el.disabled = disabled;
1211
+ } else if (disabled) {
1212
+ el.setAttribute("aria-disabled", "true");
1213
+ } else {
1214
+ el.removeAttribute("aria-disabled");
1215
+ }
1216
+ }
1217
+ function showError(root, message) {
1218
+ const el = root.querySelector("[data-xenia-error]");
1219
+ if (el) el.textContent = message;
1220
+ }
1221
+ var SUBMIT_STYLE_ID = "xenia-form-submit-style";
1222
+ function ensureSubmitStyles(root) {
1223
+ if (root.querySelector(`#${SUBMIT_STYLE_ID}`)) return;
1224
+ const style = document.createElement("style");
1225
+ style.id = SUBMIT_STYLE_ID;
1226
+ style.textContent = "@keyframes xenia-submit-spin{to{transform:rotate(360deg)}}[data-xenia-submitting]::after{content:'';display:inline-block;width:0.85em;height:0.85em;margin-left:0.5em;vertical-align:-0.15em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:xenia-submit-spin 0.6s linear infinite}";
1227
+ root.prepend(style);
1228
+ }
1229
+ function setSubmitting(el, submitting) {
1230
+ if (!el) return;
1231
+ if (submitting) el.setAttribute("data-xenia-submitting", "");
1232
+ else el.removeAttribute("data-xenia-submitting");
1233
+ }
1234
+ function fadeOutAndDismiss(root, dismiss) {
1235
+ if (!dismiss) return;
1236
+ const host = root instanceof ShadowRoot ? root.host : root;
1237
+ if (host instanceof HTMLElement) {
1238
+ host.style.transition = "opacity 220ms ease, transform 220ms ease";
1239
+ host.style.opacity = "0";
1240
+ host.style.transform = "scale(0.96)";
1241
+ window.setTimeout(dismiss, 220);
1242
+ } else {
1243
+ dismiss();
1244
+ }
1245
+ }
1246
+ var DEFAULT_SUCCESS_DISPLAY_MS = 900;
1247
+ function revealSuccess(root, form) {
1248
+ const successEl = root.querySelector("[data-xenia-success]");
1249
+ if (!successEl) return false;
1250
+ if (successEl instanceof HTMLElement) successEl.hidden = false;
1251
+ else successEl.removeAttribute("hidden");
1252
+ if (form) {
1253
+ form.hidden = true;
1254
+ } else {
1255
+ root.querySelectorAll("[data-xenia-field], [data-xenia-submit]").forEach((el) => {
1256
+ el.hidden = true;
1257
+ });
1258
+ }
1259
+ return true;
1260
+ }
1261
+ function wireFormSubmit(root, action, ctx) {
1262
+ const form = root.querySelector("form");
1263
+ let submitting = false;
1264
+ ensureSubmitStyles(root);
1265
+ const run = (control, fields) => {
1266
+ if (submitting) return;
1267
+ if (!action.formSubmit) {
1268
+ showError(root, "This form isn't connected yet.");
1269
+ return;
1270
+ }
1271
+ submitting = true;
1272
+ showError(root, "");
1273
+ setDisabled(control, true);
1274
+ setSubmitting(control, true);
1275
+ const startedAt = Date.now();
1276
+ const minDisplayMs = action.formSubmit.successDisplayMs ?? DEFAULT_SUCCESS_DISPLAY_MS;
1277
+ ctx.submitWidgetForm(fields).then(() => {
1278
+ setSubmitting(control, false);
1279
+ revealSuccess(root, form);
1280
+ const wait = Math.max(0, minDisplayMs - (Date.now() - startedAt));
1281
+ window.setTimeout(() => fadeOutAndDismiss(root, ctx.dismiss), wait);
1282
+ }).catch(() => {
1283
+ setSubmitting(control, false);
1284
+ setDisabled(control, false);
1285
+ showError(root, "Something went wrong. Please try again.");
1286
+ }).finally(() => {
1287
+ submitting = false;
1288
+ });
1289
+ };
1290
+ if (form) {
1291
+ form.addEventListener("submit", (ev) => {
1292
+ ev.preventDefault();
1293
+ const formFields = {};
1294
+ new FormData(form).forEach((value, key) => {
1295
+ if (typeof value === "string") formFields[key] = value;
1296
+ });
1297
+ const submitter = ev.submitter ?? form.querySelector('[type="submit"], button:not([type])');
1298
+ run(submitter, { ...collectFields(root), ...formFields });
1299
+ });
1300
+ }
1301
+ root.querySelectorAll("[data-xenia-submit]").forEach((el) => {
1302
+ if (form?.contains(el)) return;
1303
+ el.addEventListener("click", () => run(el, collectFields(root)));
1304
+ });
1305
+ }
1306
+
1307
+ // src/client/widgets/types.ts
1308
+ function escapeHtml(s) {
1309
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1310
+ }
1311
+ function escapeAttr(s) {
1312
+ return escapeHtml(s).replace(/"/g, "&quot;");
1313
+ }
1314
+ function wireCloseAffordances(root, dismiss) {
1315
+ if (dismiss) {
1316
+ const closeEls = root.querySelectorAll(
1317
+ ".xenia-close, [data-xenia-close], [class*='close' i], [id*='close' i], [class*='backdrop' i], [id*='backdrop' i]"
1318
+ );
1319
+ closeEls.forEach((el) => {
1320
+ el.removeAttribute("onclick");
1321
+ el.addEventListener("click", dismiss);
1322
+ });
1323
+ }
1324
+ root.querySelectorAll("[class*='backdrop' i], [id*='backdrop' i]").forEach((el) => {
1325
+ if (el instanceof HTMLElement && el.children.length === 0 && !el.textContent?.trim()) {
1326
+ el.style.opacity = "0.5";
1327
+ }
1328
+ });
1329
+ }
1330
+
1331
+ // src/client/widgets/spin-wheel.ts
1332
+ var WHEEL_SIZE = 260;
1333
+ var RADIUS = WHEEL_SIZE / 2;
1334
+ var CENTER = WHEEL_SIZE / 2;
1335
+ var DEFAULT_COLORS = ["#7c3aed", "#2563eb", "#059669", "#d97706", "#dc2626", "#0891b2"];
1336
+ var SPIN_TRANSITION = "transform 3.2s cubic-bezier(0.15, 0.85, 0.25, 1)";
1337
+ var WHEEL_POINTER_HTML = '<div class="xenia-wheel-pointer" aria-hidden="true"></div>';
1338
+ var REVEAL_TRANSITION_MS = 320;
1339
+ var REVEAL_TRANSITION = `opacity ${REVEAL_TRANSITION_MS}ms ease, transform ${REVEAL_TRANSITION_MS}ms ease`;
1340
+ var REVEAL_PERSPECTIVE_PX = 900;
1341
+ var revealTransform = (scale, depthPx) => `perspective(${REVEAL_PERSPECTIVE_PX}px) translateZ(${depthPx}px) scale(${scale})`;
1342
+ function polarPoint(angleDeg, radius) {
1343
+ const rad = angleDeg * Math.PI / 180;
1344
+ return { x: CENTER + radius * Math.sin(rad), y: CENTER - radius * Math.cos(rad) };
1345
+ }
1346
+ function segmentPath(startAngle, endAngle) {
1347
+ const start = polarPoint(startAngle, RADIUS);
1348
+ const end = polarPoint(endAngle, RADIUS);
1349
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0;
1350
+ return `M ${CENTER},${CENTER} L ${start.x},${start.y} A ${RADIUS},${RADIUS} 0 ${largeArc} 1 ${end.x},${end.y} Z`;
1351
+ }
1352
+ function computeSpinRotation(segmentIndex, segmentCount, extraSpins = 0) {
1353
+ if (segmentCount <= 0) return 0;
1354
+ const segmentAngle = 360 / segmentCount;
1355
+ const centerAngle = segmentIndex * segmentAngle + segmentAngle / 2;
1356
+ const landingAngle = (360 - centerAngle) % 360;
1357
+ return extraSpins * 360 + landingAngle;
1358
+ }
1359
+ function crossFadeReveal(outgoing, incoming) {
1360
+ const outContent = outgoing ? Array.from(outgoing.children) : [];
1361
+ const inContent = incoming ? Array.from(incoming.children) : [];
1362
+ if (incoming) incoming.hidden = false;
1363
+ inContent.forEach((el) => {
1364
+ if (!(el instanceof HTMLElement)) return;
1365
+ el.style.transition = "none";
1366
+ el.style.opacity = "0";
1367
+ el.style.transform = revealTransform(0.94, -60);
1368
+ });
1369
+ nextFrame(() => {
1370
+ outContent.forEach((el) => {
1371
+ if (!(el instanceof HTMLElement)) return;
1372
+ el.style.transition = REVEAL_TRANSITION;
1373
+ el.style.opacity = "0";
1374
+ el.style.transform = revealTransform(0.94, -60);
1375
+ });
1376
+ inContent.forEach((el) => {
1377
+ if (!(el instanceof HTMLElement)) return;
1378
+ el.style.transition = REVEAL_TRANSITION;
1379
+ el.style.opacity = "1";
1380
+ el.style.transform = revealTransform(1, 0);
1381
+ });
1382
+ });
1383
+ if (outgoing) {
1384
+ setTimeout(() => {
1385
+ outgoing.hidden = true;
1386
+ }, REVEAL_TRANSITION_MS);
1387
+ }
1388
+ }
1389
+ function nextFrame(fn) {
1390
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(fn);
1391
+ else setTimeout(fn, 0);
1392
+ }
1393
+ function wheelSvg(segments) {
1394
+ const n = segments.length || 1;
1395
+ const segmentAngle = 360 / n;
1396
+ const slices = segments.map((seg, i) => {
1397
+ const start = i * segmentAngle;
1398
+ const end = start + segmentAngle;
1399
+ const color = seg.colorHex || DEFAULT_COLORS[i % DEFAULT_COLORS.length];
1400
+ const labelAngle = start + segmentAngle / 2;
1401
+ const labelPoint = polarPoint(labelAngle, RADIUS * 0.62);
1402
+ return `<path d="${segmentPath(start, end)}" fill="${escapeAttr(color)}" stroke="#fff" stroke-width="1"></path><text x="${labelPoint.x}" y="${labelPoint.y}" fill="#fff" font-size="12" text-anchor="middle" transform="rotate(${labelAngle} ${labelPoint.x} ${labelPoint.y})">${escapeHtml(seg.label)}</text>`;
1403
+ }).join("");
1404
+ return `<svg class="xenia-wheel-svg" viewBox="0 0 ${WHEEL_SIZE} ${WHEEL_SIZE}" width="${WHEEL_SIZE}" height="${WHEEL_SIZE}"><g class="xenia-wheel-dial">${slices}</g><circle class="xenia-wheel-hub" cx="${CENTER}" cy="${CENTER}" r="6"></circle></svg>`;
1405
+ }
1406
+ var spinWheelRenderer = {
1407
+ html(action) {
1408
+ return `<div class="xenia-spin-wheel"><div data-xenia-wheel-face>${action.spinnerHtml ?? ""}</div><div class="xenia-widget-result" data-xenia-wheel-result hidden>${action.winHtml ?? ""}</div><div class="xenia-widget-result" data-xenia-wheel-loss hidden>${action.lossHtml ?? ""}</div></div>`;
1409
+ },
1410
+ wire(root, action, ctx) {
1411
+ wireCloseAffordances(root, ctx.dismiss);
1412
+ const segments = action.segments ?? [];
1413
+ const wheelFace = root.querySelector("[data-xenia-wheel-face]");
1414
+ const spinBtn = root.querySelector("[data-xenia-spin]");
1415
+ const winEl = root.querySelector("[data-xenia-wheel-result]");
1416
+ const lossEl = root.querySelector("[data-xenia-wheel-loss]");
1417
+ const errorEl = root.querySelector("[data-xenia-error]");
1418
+ const slotEl = root.querySelector("[data-xenia-wheel-slot]");
1419
+ if (slotEl) slotEl.insertAdjacentHTML("afterbegin", wheelSvg(segments) + WHEEL_POINTER_HTML);
1420
+ const dial = root.querySelector(".xenia-wheel-dial");
1421
+ if (!spinBtn || !action.formSubmit) return;
1422
+ root.querySelectorAll("[data-xenia-wheel-cta]").forEach((btn) => {
1423
+ btn.addEventListener("click", () => ctx.fireCta());
1424
+ });
1425
+ const revealResult = (prizeLabel, isLoss, canRetry) => {
1426
+ root.querySelectorAll("[data-xenia-wheel-prize]").forEach((el) => {
1427
+ el.textContent = prizeLabel;
1428
+ });
1429
+ const shown = isLoss ? lossEl : winEl;
1430
+ const other = isLoss ? winEl : lossEl;
1431
+ if (other) other.hidden = true;
1432
+ crossFadeReveal(wheelFace, shown);
1433
+ if (!canRetry) {
1434
+ playsExhausted = true;
1435
+ root.querySelectorAll("[data-xenia-spin-again]").forEach((btn) => {
1436
+ btn.hidden = true;
1437
+ });
1438
+ }
1439
+ };
1440
+ let spinning = false;
1441
+ let playsExhausted = false;
1442
+ root.querySelectorAll("[data-xenia-spin-again]").forEach((btn) => {
1443
+ btn.addEventListener("click", () => {
1444
+ if (playsExhausted) return;
1445
+ const shown = winEl && !winEl.hidden ? winEl : lossEl && !lossEl.hidden ? lossEl : null;
1446
+ if (!shown) return;
1447
+ spinning = false;
1448
+ spinBtn.disabled = false;
1449
+ if (errorEl) errorEl.textContent = "";
1450
+ crossFadeReveal(shown, wheelFace);
1451
+ });
1452
+ });
1453
+ spinBtn.addEventListener("click", () => {
1454
+ if (spinning) return;
1455
+ spinning = true;
1456
+ spinBtn.disabled = true;
1457
+ if (errorEl) errorEl.textContent = "";
1458
+ const fields = collectFields(root);
1459
+ ctx.submitWidgetForm(fields).then((response) => {
1460
+ const index = segments.findIndex((s) => s.id === response.prize?.segmentId);
1461
+ const prizeLabel = response.prize?.label ?? segments[index]?.label ?? "";
1462
+ const isLoss = index >= 0 && !!segments[index]?.isLoss;
1463
+ const canRetry = response.alreadyPlayed !== true;
1464
+ if (index < 0 || !dial) {
1465
+ revealResult(prizeLabel, isLoss, canRetry);
1466
+ return;
1467
+ }
1468
+ const extraSpins = 3 + Math.floor(Math.random() * 3);
1469
+ const rotation = computeSpinRotation(index, segments.length || 1, extraSpins);
1470
+ const priorMatch = /rotate\(([-\d.]+)deg\)/.exec(dial.style.transform);
1471
+ const priorRotation = priorMatch ? parseFloat(priorMatch[1]) : 0;
1472
+ dial.style.transition = "none";
1473
+ dial.style.transform = `rotate(${priorRotation % 360}deg)`;
1474
+ void dial.getBoundingClientRect();
1475
+ dial.style.transition = SPIN_TRANSITION;
1476
+ const onTransitionEnd = () => {
1477
+ dial.removeEventListener("transitionend", onTransitionEnd);
1478
+ revealResult(prizeLabel, isLoss, canRetry);
1479
+ };
1480
+ dial.addEventListener("transitionend", onTransitionEnd);
1481
+ nextFrame(() => {
1482
+ dial.style.transform = `rotate(${rotation}deg)`;
1483
+ });
1484
+ }).catch(() => {
1485
+ spinning = false;
1486
+ spinBtn.disabled = false;
1487
+ if (errorEl) errorEl.textContent = "Something went wrong. Please try again.";
1488
+ });
1489
+ });
1490
+ }
1491
+ };
1492
+
1493
+ // src/client/widgets/index.ts
1494
+ var WIDGET_RENDERERS = {
1495
+ "spin-wheel": spinWheelRenderer
1496
+ };
1497
+
1062
1498
  // src/client/renderer.ts
1063
1499
  function resolveTarget(target, root = document) {
1064
1500
  if (!target) return null;
@@ -1116,6 +1552,7 @@ var ActionRenderer = class {
1116
1552
  messageTemplate;
1117
1553
  messageStyles;
1118
1554
  messageComponent;
1555
+ submitWidgetFormFn;
1119
1556
  /** Unmount callbacks for currently-mounted framework components, keyed by their container. */
1120
1557
  mounted = /* @__PURE__ */ new Map();
1121
1558
  constructor(onCta, opts) {
@@ -1123,6 +1560,7 @@ var ActionRenderer = class {
1123
1560
  this.messageTemplate = opts?.messageTemplate;
1124
1561
  this.messageStyles = opts?.messageStyles;
1125
1562
  this.messageComponent = opts?.messageComponent;
1563
+ this.submitWidgetFormFn = opts?.submitWidgetForm;
1126
1564
  }
1127
1565
  /** Apply a list of actions. Returns the count successfully applied. */
1128
1566
  apply(actions) {
@@ -1205,7 +1643,8 @@ ${this.messageStyles}` : STYLES;
1205
1643
  const wrap = document.createElement("div");
1206
1644
  const posClass = this.positionClass(action.type, action.position);
1207
1645
  const customBgClass = action.html !== void 0 ? " xenia-custom-bg" : "";
1208
- wrap.className = `xenia-msg xenia-${action.type}${posClass ? ` ${posClass}` : ""}${customBgClass}`;
1646
+ const noOverlayClass = action.style?.hideOverlay ? " xenia-no-overlay" : "";
1647
+ wrap.className = `xenia-msg xenia-${action.type}${posClass ? ` ${posClass}` : ""}${customBgClass}${noOverlayClass}`;
1209
1648
  shadow.appendChild(wrap);
1210
1649
  const dismiss = () => {
1211
1650
  this.unmount(wrap);
@@ -1218,7 +1657,10 @@ ${this.messageStyles}` : STYLES;
1218
1657
  this.wireCard(wrap, action, dismiss);
1219
1658
  }
1220
1659
  if (action.dismissAfterMs && action.dismissAfterMs > 0) {
1221
- setTimeout(dismiss, action.dismissAfterMs);
1660
+ const timer = setTimeout(dismiss, action.dismissAfterMs);
1661
+ const cancelAutoDismiss = () => clearTimeout(timer);
1662
+ wrap.addEventListener("click", cancelAutoDismiss, { once: true });
1663
+ wrap.addEventListener("focusin", cancelAutoDismiss, { once: true });
1222
1664
  }
1223
1665
  return true;
1224
1666
  }
@@ -1262,38 +1704,32 @@ ${this.messageStyles}` : STYLES;
1262
1704
  return "";
1263
1705
  }
1264
1706
  cardHtml(action, variant) {
1707
+ const widget = action.widgetType ? WIDGET_RENDERERS[action.widgetType] : void 0;
1708
+ if (widget) return widget.html(action, variant);
1265
1709
  if (action.html !== void 0) return wrapCustomHtml(action.html, variant);
1266
1710
  if (this.messageTemplate) {
1267
1711
  const ctx = {
1268
1712
  action,
1269
1713
  variant,
1270
- escapeHtml,
1271
- escapeAttr
1714
+ escapeHtml: escapeHtml2,
1715
+ escapeAttr: escapeAttr2
1272
1716
  };
1273
1717
  return this.messageTemplate(ctx);
1274
1718
  }
1275
- const img = action.imageUrl ? `<img class="xenia-img" src="${escapeAttr(action.imageUrl)}" alt="" />` : "";
1276
- const title = action.title ? `<div class="xenia-title">${escapeHtml(action.title)}</div>` : "";
1277
- const msg = action.message ? `<div class="xenia-body">${escapeHtml(action.message)}</div>` : "";
1278
- const cta = action.cta ? `<button class="xenia-cta" type="button">${escapeHtml(action.cta.label)}</button>` : "";
1719
+ const img = action.imageUrl ? `<img class="xenia-img" src="${escapeAttr2(action.imageUrl)}" alt="" />` : "";
1720
+ const title = action.title ? `<div class="xenia-title">${escapeHtml2(action.title)}</div>` : "";
1721
+ const msg = action.message ? `<div class="xenia-body">${escapeHtml2(action.message)}</div>` : "";
1722
+ const cta = action.cta ? `<button class="xenia-cta" type="button">${escapeHtml2(action.cta.label)}</button>` : "";
1279
1723
  const close = variant === "inline" ? "" : `<button class="xenia-close" type="button" aria-label="Close">\xD7</button>`;
1280
1724
  return `<div class="xenia-card">${close}${img}<div class="xenia-content">${title}${msg}${cta}</div></div>`;
1281
1725
  }
1282
1726
  wireCard(root, action, dismiss) {
1283
- if (dismiss) {
1284
- const closeEls = root.querySelectorAll(
1285
- ".xenia-close, [data-xenia-close], [class*='close' i], [id*='close' i], [class*='backdrop' i], [id*='backdrop' i]"
1286
- );
1287
- closeEls.forEach((el) => {
1288
- el.removeAttribute("onclick");
1289
- el.addEventListener("click", dismiss);
1290
- });
1727
+ const widget = action.widgetType ? WIDGET_RENDERERS[action.widgetType] : void 0;
1728
+ if (widget) {
1729
+ widget.wire(root, action, this.buildWidgetCtx(action, dismiss));
1730
+ return;
1291
1731
  }
1292
- root.querySelectorAll("[class*='backdrop' i], [id*='backdrop' i]").forEach((el) => {
1293
- if (el instanceof HTMLElement && el.children.length === 0 && !el.textContent?.trim()) {
1294
- el.style.opacity = "0.5";
1295
- }
1296
- });
1732
+ wireCloseAffordances(root, dismiss);
1297
1733
  const ctaBtn = root.querySelector(".xenia-cta, [data-xenia-cta]");
1298
1734
  if (ctaBtn && action.cta) {
1299
1735
  ctaBtn.addEventListener("click", () => {
@@ -1302,6 +1738,29 @@ ${this.messageStyles}` : STYLES;
1302
1738
  dismiss?.();
1303
1739
  });
1304
1740
  }
1741
+ wireFormSubmit(root, action, this.buildWidgetCtx(action, dismiss));
1742
+ }
1743
+ /** Build the imperative hooks (`dismiss`/`fireCta`/`submitWidgetForm`) handed to a widget's `wire()`. */
1744
+ buildWidgetCtx(action, dismiss) {
1745
+ const idempotencyKey = generateUlid();
1746
+ return {
1747
+ dismiss,
1748
+ fireCta: () => {
1749
+ if (action.cta?.event) this.onCta?.(action.cta.event, action.cta);
1750
+ if (action.cta?.url) window.location.assign(action.cta.url);
1751
+ dismiss?.();
1752
+ },
1753
+ submitWidgetForm: (fields) => {
1754
+ if (!action.formSubmit || !this.submitWidgetFormFn) {
1755
+ return Promise.reject(
1756
+ new Error(
1757
+ "xenia: this action has no formSubmit config, or no submitWidgetForm handler is wired (construct the ActionRenderer via XeniaClient to get one)"
1758
+ )
1759
+ );
1760
+ }
1761
+ return this.submitWidgetFormFn(action.formSubmit, fields, { idempotencyKey });
1762
+ }
1763
+ };
1305
1764
  }
1306
1765
  };
1307
1766
  function wrapCustomHtml(html, variant) {
@@ -1310,11 +1769,11 @@ function wrapCustomHtml(html, variant) {
1310
1769
  }
1311
1770
  return `<div class="xenia-custom-html"><style>.xenia-custom-html{position:relative}.xenia-custom-html>*{position:static!important;inset:auto!important;top:auto!important;right:auto!important;bottom:auto!important;left:auto!important}</style>${html}</div>`;
1312
1771
  }
1313
- function escapeHtml(s) {
1772
+ function escapeHtml2(s) {
1314
1773
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1315
1774
  }
1316
- function escapeAttr(s) {
1317
- return escapeHtml(s).replace(/"/g, "&quot;");
1775
+ function escapeAttr2(s) {
1776
+ return escapeHtml2(s).replace(/"/g, "&quot;");
1318
1777
  }
1319
1778
  var STYLES = `
1320
1779
  :host { all: initial; }
@@ -1331,12 +1790,14 @@ var STYLES = `
1331
1790
  .xenia-modal { inset: 0; display: flex; align-items: center; justify-content: center;
1332
1791
  background: var(--xenia-modal-backdrop, rgba(0,0,0,.12)); }
1333
1792
  .xenia-modal.xenia-custom-bg { background: transparent; }
1793
+ .xenia-modal.xenia-no-overlay { background: transparent; }
1334
1794
  .xenia-card { position: relative; background: var(--xenia-card-bg, #fff); color: var(--xenia-card-color, #111);
1335
1795
  border-radius: var(--xenia-card-radius, 12px); box-shadow: var(--xenia-card-shadow, 0 10px 40px rgba(0,0,0,.18));
1336
1796
  padding: var(--xenia-card-padding, 16px 18px); display: flex; gap: var(--xenia-card-gap, 12px);
1337
1797
  align-items: center; }
1338
1798
  .xenia-banner .xenia-card { border-radius: var(--xenia-banner-card-radius, 0); justify-content: center; }
1339
1799
  .xenia-modal .xenia-card { max-width: var(--xenia-modal-max-width, 440px); width: calc(100% - 32px); }
1800
+ .xenia-card.xenia-widget { flex-direction: column; align-items: center; text-align: center; }
1340
1801
  .xenia-img { width: var(--xenia-img-size, 56px); height: var(--xenia-img-size, 56px); object-fit: cover;
1341
1802
  border-radius: var(--xenia-img-radius, 8px); }
1342
1803
  .xenia-title { font-weight: var(--xenia-title-weight, 600); font-size: var(--xenia-title-size, 15px);
@@ -1348,6 +1809,38 @@ var STYLES = `
1348
1809
  font-size: var(--xenia-cta-size, 14px); cursor: pointer; }
1349
1810
  .xenia-close { position: absolute; top: 6px; right: 8px; background: none; border: 0;
1350
1811
  font-size: var(--xenia-close-size, 20px); line-height: 1; cursor: pointer; color: var(--xenia-close-color, #999); }
1812
+ .xenia-wheel-dial { transform-box: fill-box; transform-origin: center; }
1813
+ .xenia-wheel-hub { fill: var(--xenia-wheel-pointer-color, #111); }
1814
+ .xenia-wheel-wrap { position: relative; display: inline-block; }
1815
+ .xenia-wheel-pointer { position: absolute; top: -6px; left: 50%; transform: translateX(-50%);
1816
+ width: 0; height: 0; border-left: 9px solid transparent; border-right: 9px solid transparent;
1817
+ border-top: 14px solid var(--xenia-wheel-pointer-color, #111); pointer-events: none; }
1818
+ .xenia-wheel-spin { margin-top: 10px; background: var(--xenia-cta-bg, #111); color: var(--xenia-cta-color, #fff);
1819
+ border: 0; border-radius: var(--xenia-cta-radius, 8px); padding: var(--xenia-cta-padding, 8px 14px);
1820
+ font-size: var(--xenia-cta-size, 14px); font-family: inherit; cursor: pointer; }
1821
+ .xenia-wheel-spin:disabled { opacity: 0.6; cursor: default; }
1822
+ /* No animation/transform here \u2014 spin-wheel's win/loss screens are ALWAYS full
1823
+ custom HTML now (winHtml/lossHtml), never this class's own generated icon/title/prize/cta
1824
+ content (those sub-classes below are unreachable dead CSS, kept only for backwards-compat with
1825
+ any already-saved artifact that might still reference them). A transform on THIS wrapper
1826
+ would make it the CSS containing block for any fixed/absolute-positioned descendant the
1827
+ author's own HTML uses (a real, common case \u2014 a "full-screen overlay" win/loss design) \u2014 the
1828
+ fixed child would then size/position itself relative to this wrapper's own small, in-flow,
1829
+ often off-center box instead of the true browser viewport, visibly popping in from/toward
1830
+ whatever corner the wrapper happens to sit at instead of scaling from the real screen center.
1831
+ spin-wheel.ts's crossFadeReveal owns the actual reveal transition (applied to the author's own
1832
+ top-level element, never this wrapper) \u2014 this class stays a bare, transform-free layout box. */
1833
+ .xenia-widget-result { display: flex; flex-direction: column; align-items: center; gap: 4px;
1834
+ padding: 8px 4px 2px; text-align: center; }
1835
+ .xenia-widget-result[hidden] { display: none; }
1836
+ .xenia-widget-result-icon { font-size: 40px; line-height: 1; }
1837
+ .xenia-widget-result-title { font-weight: var(--xenia-title-weight, 700); font-size: var(--xenia-title-size, 17px); }
1838
+ .xenia-widget-result-prize { font-weight: 600; font-size: 15px; color: var(--xenia-cta-bg, #111);
1839
+ background: var(--xenia-result-prize-bg, rgba(0,0,0,.06)); border-radius: var(--xenia-cta-radius, 8px);
1840
+ padding: 6px 14px; }
1841
+ .xenia-widget-result-cta { margin-top: 8px; background: var(--xenia-cta-bg, #111); color: var(--xenia-cta-color, #fff);
1842
+ border: 0; border-radius: var(--xenia-cta-radius, 8px); padding: var(--xenia-cta-padding, 8px 14px);
1843
+ font-size: var(--xenia-cta-size, 14px); font-family: inherit; cursor: pointer; }
1351
1844
  `;
1352
1845
 
1353
1846
  // src/client/xenia-client.ts
@@ -1372,7 +1865,8 @@ var XeniaClient = class {
1372
1865
  this.renderer = new ActionRenderer((event, cta) => this.onCtaEvent(event, cta), {
1373
1866
  messageTemplate: options.messageTemplate,
1374
1867
  messageStyles: options.messageStyles,
1375
- messageComponent: options.messageComponent
1868
+ messageComponent: options.messageComponent,
1869
+ submitWidgetForm: (submit, fields, subOpts) => this.submitWidgetForm(submit, fields, subOpts)
1376
1870
  });
1377
1871
  this.consentState = { ...options.consent };
1378
1872
  this.analyticsQueue = new AnalyticsQueue({
@@ -1434,10 +1928,16 @@ var XeniaClient = class {
1434
1928
  * Persist a visitor id, attached to every subsequent event, and bind it to
1435
1929
  * a known user server-side (`POST /api/events/v1/identify`, fire-and-
1436
1930
  * forget — resolves to an `"identify"` client event carrying `personId`).
1931
+ *
1932
+ * `extra.customerId`/`extra.email`, when supplied, are also persisted
1933
+ * ambiently (like the visitor id) so they ride on every later `resolve()`
1934
+ * call automatically — part of the SDK-provided-fields parity list.
1437
1935
  */
1438
- identify(visitorId) {
1936
+ identify(visitorId, extra) {
1439
1937
  this.identity.identify(visitorId);
1440
- void this.api.identify({ xeniaVisitorId: visitorId }).then((res) => this.emit("identify", res)).catch((err) => this.emitError(err));
1938
+ if (extra?.customerId !== void 0) this.identity.setCustomerId(extra.customerId);
1939
+ if (extra?.email !== void 0) this.identity.setEmail(extra.email);
1940
+ void this.api.identify({ xeniaVisitorId: visitorId, userId: extra?.customerId, email: extra?.email }).then((res) => this.emit("identify", res)).catch((err) => this.emitError(err));
1441
1941
  }
1442
1942
  /** Override the customer-journey UUID (else derived from utm_id). */
1443
1943
  setJourneyId(id) {
@@ -1555,15 +2055,40 @@ var XeniaClient = class {
1555
2055
  return this.identity.getVisitorId() ?? this.identity.getDeviceId() ?? void 0;
1556
2056
  }
1557
2057
  /**
1558
- * Stamp the SDK-owned visitor id into the resolve `data` under the reserved
1559
- * `_xenia_vid` key so the backend can bucket holdout experiments and record
1560
- * exposure per visitor (it reads visitor identity from `data`). Set last so a
1561
- * caller can't override it; a no-op when no visitor id is resolvable yet.
2058
+ * Stamp the full set of SDK-provided fields into the resolve `data` —
2059
+ * `path`, `query`, `deviceId`, `visitorId`, `customerId`, `email`, `url`,
2060
+ * `referrer`, and the five `utm*` fields — the same field-name/type parity
2061
+ * the webhook trigger's "SDK-provided fields" already promises, now native
2062
+ * (always attached, no marketer toggle) for the Personalization Resolve
2063
+ * trigger too. Plus the reserved `_xenia_vid` key the backend reads for
2064
+ * holdout/exposure bucketing. All of these are spread LAST (after the
2065
+ * caller's own `data`) so none can be overridden per call — the same
2066
+ * override-protection guarantee `_xenia_vid` alone used to have, now
2067
+ * widened to the full set. A no-op (returns `opts` unchanged) when nothing
2068
+ * is resolvable yet (e.g. called before `init()` on a fresh visit with no
2069
+ * page context available).
1562
2070
  */
1563
2071
  withVisitor(opts) {
2072
+ const ctx = this.identity.collectContext();
1564
2073
  const vid = this.resolveVisitorId();
1565
- if (!vid) return opts;
1566
- return { ...opts, data: { ...opts?.data ?? {}, _xenia_vid: vid } };
2074
+ const sdkFields = compact({
2075
+ path: ctx.path,
2076
+ query: ctx.query,
2077
+ deviceId: this.identity.getDeviceId() ?? void 0,
2078
+ visitorId: vid,
2079
+ customerId: this.identity.getCustomerId() ?? void 0,
2080
+ email: this.identity.getEmail() ?? void 0,
2081
+ url: ctx.url,
2082
+ referrer: ctx.referrer,
2083
+ utmSource: ctx.utmSource,
2084
+ utmMedium: ctx.utmMedium,
2085
+ utmCampaign: ctx.utmCampaign,
2086
+ utmContent: ctx.utmContent,
2087
+ utmId: ctx.utmId
2088
+ });
2089
+ if (vid) sdkFields._xenia_vid = vid;
2090
+ if (Object.keys(sdkFields).length === 0) return opts;
2091
+ return { ...opts, data: { ...opts?.data ?? {}, ...sdkFields } };
1567
2092
  }
1568
2093
  dntBlocked() {
1569
2094
  if (this.options.respectDnt === false) return false;
@@ -1613,6 +2138,55 @@ var XeniaClient = class {
1613
2138
  getResponse(key) {
1614
2139
  return this.responses.get(key);
1615
2140
  }
2141
+ /**
2142
+ * Submit a widget's captured form values (standard/custom popup submit, or a spin-wheel/
2143
+ * spin-wheel's resolve request) and apply any returned `xeniaActions`, exactly like
2144
+ * {@link request}.
2145
+ *
2146
+ * **Endpoint selection is config-driven, not SDK-decided** (per the cross-repo contract): a
2147
+ * `flowRef` (present only when `mode === "journey" && !requiresResolve` — the common
2148
+ * plain-popup case) bypasses the resolve endpoint entirely and POSTs straight to the existing
2149
+ * public `/flow` fast path; otherwise this POSTs to `/widget-submissions` via `bindingRef`. The
2150
+ * SDK is agnostic to *why* — it just follows whichever ref field the backend populated.
2151
+ *
2152
+ * The full ambient-context parity list {@link request}/`withVisitor()` already build
2153
+ * (visitorId, deviceId, url, referrer, path, query, `utm*`) rides on every call, plus `fields`
2154
+ * and `submit.mode`/the resolved ref, relayed verbatim/uninterpreted — the SDK never branches
2155
+ * on `mode`'s value itself, that's server-side.
2156
+ *
2157
+ * `opts.idempotencyKey` defaults to a freshly generated ULID; pass the SAME key back in on a
2158
+ * retried submit of the same interaction so a network retry can't double-fire a journey or
2159
+ * double-resolve a prize server-side.
2160
+ */
2161
+ async submitWidgetForm(submit, fields, opts) {
2162
+ const ctx = this.identity.collectContext();
2163
+ const idempotencyKey = opts?.idempotencyKey ?? generateUlid();
2164
+ const ref = submit.flowRef ?? submit.bindingRef;
2165
+ const ambient = {
2166
+ visitorId: this.resolveVisitorId(),
2167
+ deviceId: this.identity.getDeviceId() ?? void 0,
2168
+ idempotencyKey,
2169
+ mode: submit.mode,
2170
+ ref,
2171
+ url: ctx.url,
2172
+ referrer: ctx.referrer,
2173
+ path: ctx.path,
2174
+ query: ctx.query,
2175
+ utmSource: ctx.utmSource,
2176
+ utmMedium: ctx.utmMedium,
2177
+ utmCampaign: ctx.utmCampaign,
2178
+ utmContent: ctx.utmContent,
2179
+ utmId: ctx.utmId
2180
+ };
2181
+ const response = submit.flowRef ? await this.api.webhook(submit.flowRef, compact({ ...ambient, ...fields }), "POST") : await this.api.submitWidget(
2182
+ submit.bindingRef ?? "",
2183
+ compact({ ...ambient, fields })
2184
+ );
2185
+ this.emit("response", { triggerId: ref, response });
2186
+ const apply = opts?.apply ?? this.options.autoApplyActions !== false;
2187
+ if (apply && response.actions.length) this.applyActions(response.actions);
2188
+ return response;
2189
+ }
1616
2190
  // ── Imperative render helpers ─────────────────────────────────────────────────
1617
2191
  swapContent(target, content) {
1618
2192
  return swapContent(target, content);