@xenia-martech/sdk-js 1.0.0-dev.27 → 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 (35) hide show
  1. package/CSS_VARIABLES.md +7 -0
  2. package/README.md +50 -1
  3. package/dist/{actions-CkiSdvSa.d.cts → actions-CcBYV--3.d.cts} +177 -1
  4. package/dist/{actions-CkiSdvSa.d.ts → actions-CcBYV--3.d.ts} +177 -1
  5. package/dist/{chunk-UOW3GK4I.cjs → chunk-WYM4CQVG.cjs} +96 -4
  6. package/dist/chunk-WYM4CQVG.cjs.map +1 -0
  7. package/dist/{chunk-7D2HJMW6.js → chunk-XXNDHYAS.js} +96 -4
  8. package/dist/chunk-XXNDHYAS.js.map +1 -0
  9. package/dist/client/index.cjs +540 -27
  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 +537 -28
  14. package/dist/client/index.js.map +1 -1
  15. package/dist/client/react/index.cjs +536 -27
  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 +536 -27
  20. package/dist/client/react/index.js.map +1 -1
  21. package/dist/{cookie-names-C3LKDhsg.d.cts → cookie-names-B3b0XNc2.d.cts} +153 -1
  22. package/dist/{cookie-names-C3LKDhsg.d.ts → cookie-names-B3b0XNc2.d.ts} +153 -1
  23. package/dist/index.cjs +20 -20
  24. package/dist/index.d.cts +2 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +1 -1
  27. package/dist/server/index.cjs +15 -15
  28. package/dist/server/index.d.cts +2 -2
  29. package/dist/server/index.d.ts +2 -2
  30. package/dist/server/index.js +2 -2
  31. package/dist/xenia-sdk.iife.js +37 -3
  32. package/dist/xenia-sdk.iife.js.map +1 -1
  33. package/package.json +1 -1
  34. package/dist/chunk-7D2HJMW6.js.map +0 -1
  35. package/dist/chunk-UOW3GK4I.cjs.map +0 -1
@@ -13,6 +13,7 @@ var MESSAGE_POSITIONS = /* @__PURE__ */ new Set([
13
13
  "top",
14
14
  "bottom"
15
15
  ]);
16
+ var WIDGET_TYPES = /* @__PURE__ */ new Set(["standard", "spin-wheel"]);
16
17
  var ALL_TYPES = /* @__PURE__ */ new Set([
17
18
  "swap",
18
19
  "hide",
@@ -59,15 +60,67 @@ function toAction(raw) {
59
60
  message: asString(raw.message),
60
61
  imageUrl: asString(raw.imageUrl),
61
62
  html: asString(raw.html),
62
- cta: cta && cta.label ? cta : void 0,
63
+ // A link-only CTA (empty label, e.g. a spin-wheel's data-xenia-wheel-cta —
64
+ // label is meaningless there, the winHtml/lossHtml design supplies its own button text)
65
+ // must still parse through — gating on `cta.label` alone silently dropped it.
66
+ cta: cta && (cta.label || cta.url) ? cta : void 0,
63
67
  dismissAfterMs: asNumber(raw.dismissAfterMs),
64
- position: asMessagePosition(raw.position)
68
+ position: asMessagePosition(raw.position),
69
+ widgetType: asWidgetType(raw.widgetType),
70
+ segments: asSegments(raw.segments),
71
+ formSubmit: asFormSubmit(raw.formSubmit),
72
+ style: asWidgetStyle(raw.style),
73
+ spinnerHtml: asString(raw.spinnerHtml),
74
+ winHtml: asString(raw.winHtml),
75
+ lossHtml: asString(raw.lossHtml)
65
76
  };
66
77
  }
67
78
  function asMessagePosition(v) {
68
79
  const s = asString(v);
69
80
  return s && MESSAGE_POSITIONS.has(s) ? s : void 0;
70
81
  }
82
+ function asWidgetType(v) {
83
+ const s = asString(v);
84
+ return s && WIDGET_TYPES.has(s) ? s : void 0;
85
+ }
86
+ function asSegments(v) {
87
+ if (!Array.isArray(v)) return void 0;
88
+ const segments = [];
89
+ for (const entry of v) {
90
+ if (!isObject(entry)) continue;
91
+ const id = asString(entry.id);
92
+ const label = asString(entry.label);
93
+ if (!id || !label) continue;
94
+ segments.push({
95
+ id,
96
+ label,
97
+ colorHex: asString(entry.colorHex),
98
+ isLoss: entry.isLoss === true
99
+ });
100
+ }
101
+ return segments.length ? segments : void 0;
102
+ }
103
+ function asFormSubmit(v) {
104
+ if (!isObject(v)) return void 0;
105
+ const mode = asString(v.mode);
106
+ if (!mode) return void 0;
107
+ return {
108
+ requiresResolve: v.requiresResolve === true,
109
+ mode,
110
+ flowRef: asString(v.flowRef),
111
+ bindingRef: asString(v.bindingRef),
112
+ successDisplayMs: asNumber(v.successDisplayMs)
113
+ };
114
+ }
115
+ function asWidgetStyle(v) {
116
+ if (!isObject(v)) return void 0;
117
+ const style = {
118
+ hideCloseButton: v.hideCloseButton === true,
119
+ hideOverlay: v.hideOverlay === true
120
+ };
121
+ const hasAny = style.hideCloseButton || style.hideOverlay;
122
+ return hasAny ? style : void 0;
123
+ }
71
124
  function parseActions(body) {
72
125
  let list;
73
126
  if (Array.isArray(body)) {
@@ -250,6 +303,12 @@ var TokenProvider = class {
250
303
  };
251
304
 
252
305
  // src/core/api.ts
306
+ function isJsonObject(v) {
307
+ return typeof v === "object" && v !== null && !Array.isArray(v);
308
+ }
309
+ function asWidgetPrize(v) {
310
+ return v !== void 0 && isJsonObject(v) ? v : void 0;
311
+ }
253
312
  function compact(obj) {
254
313
  const out = {};
255
314
  for (const [k, v] of Object.entries(obj)) {
@@ -319,6 +378,39 @@ var XeniaApi = class {
319
378
  receivedAt: Date.now()
320
379
  };
321
380
  }
381
+ /**
382
+ * Widget form submission — `POST /widget-submissions?ref={bindingRef}`. Used whenever the
383
+ * action's `formSubmit.requiresResolve` is true (spin-wheel, which must have a
384
+ * server-resolved outcome before anything renders) or `mode === "endpoint"` — the common
385
+ * plain-popup case (`mode === "journey" && !requiresResolve`) bypasses this entirely and calls
386
+ * {@link webhook} against `flowRef` instead (see `XeniaClient.submitWidgetForm`'s endpoint
387
+ * selection).
388
+ *
389
+ * Not retried (`noRetry: true`), styled like {@link webhook} — a retried POST must never
390
+ * double-fire a journey or double-resolve a prize server-side.
391
+ */
392
+ async submitWidget(ref, body) {
393
+ const { response, data: rawBody } = await this.http.request({
394
+ method: "POST",
395
+ path: "/widget-submissions",
396
+ query: { ref },
397
+ headers: await this.authHeaders(),
398
+ body: body ?? {},
399
+ noRetry: true
400
+ });
401
+ const obj = isJsonObject(rawBody) ? rawBody : {};
402
+ return {
403
+ status: response.status,
404
+ body: rawBody,
405
+ actions: parseActions(rawBody),
406
+ prize: asWidgetPrize(obj.prize),
407
+ alreadyPlayed: obj.alreadyPlayed === true,
408
+ dispatched: obj.dispatched === true,
409
+ dispatchError: typeof obj.dispatchError === "string" ? obj.dispatchError : void 0,
410
+ widgetType: typeof obj.widgetType === "string" ? obj.widgetType : void 0,
411
+ receivedAt: Date.now()
412
+ };
413
+ }
322
414
  /**
323
415
  * Unified event batch (max 100/request per the backend) — `POST /api/events/v1`.
324
416
  * Records analytics for every event and matches/advances a journey for any
@@ -1086,6 +1178,316 @@ function resolveCookieConfig(opt) {
1086
1178
  return { sameSite, secure, maxAgeDays: o.maxAgeDays ?? 365, domain: o.domain };
1087
1179
  }
1088
1180
 
1181
+ // src/client/widgets/form.ts
1182
+ function collectFields(root) {
1183
+ const fields = {};
1184
+ root.querySelectorAll("[data-xenia-field]").forEach((el) => {
1185
+ const name = el.getAttribute("data-xenia-field");
1186
+ if (!name) return;
1187
+ fields[name] = fieldValue(el);
1188
+ });
1189
+ return fields;
1190
+ }
1191
+ function fieldValue(el) {
1192
+ if (el instanceof HTMLInputElement) {
1193
+ if (el.type === "checkbox") return el.checked ? el.value || "on" : "";
1194
+ if (el.type === "radio") return el.checked ? el.value : "";
1195
+ return el.value;
1196
+ }
1197
+ if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return el.value;
1198
+ return el.textContent?.trim() ?? "";
1199
+ }
1200
+ function setDisabled(el, disabled) {
1201
+ if (!el) return;
1202
+ if (el instanceof HTMLButtonElement || el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement || el instanceof HTMLFieldSetElement) {
1203
+ el.disabled = disabled;
1204
+ } else if (disabled) {
1205
+ el.setAttribute("aria-disabled", "true");
1206
+ } else {
1207
+ el.removeAttribute("aria-disabled");
1208
+ }
1209
+ }
1210
+ function showError(root, message) {
1211
+ const el = root.querySelector("[data-xenia-error]");
1212
+ if (el) el.textContent = message;
1213
+ }
1214
+ var SUBMIT_STYLE_ID = "xenia-form-submit-style";
1215
+ function ensureSubmitStyles(root) {
1216
+ if (root.querySelector(`#${SUBMIT_STYLE_ID}`)) return;
1217
+ const style = document.createElement("style");
1218
+ style.id = SUBMIT_STYLE_ID;
1219
+ 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}";
1220
+ root.prepend(style);
1221
+ }
1222
+ function setSubmitting(el, submitting) {
1223
+ if (!el) return;
1224
+ if (submitting) el.setAttribute("data-xenia-submitting", "");
1225
+ else el.removeAttribute("data-xenia-submitting");
1226
+ }
1227
+ function fadeOutAndDismiss(root, dismiss) {
1228
+ if (!dismiss) return;
1229
+ const host = root instanceof ShadowRoot ? root.host : root;
1230
+ if (host instanceof HTMLElement) {
1231
+ host.style.transition = "opacity 220ms ease, transform 220ms ease";
1232
+ host.style.opacity = "0";
1233
+ host.style.transform = "scale(0.96)";
1234
+ window.setTimeout(dismiss, 220);
1235
+ } else {
1236
+ dismiss();
1237
+ }
1238
+ }
1239
+ var DEFAULT_SUCCESS_DISPLAY_MS = 900;
1240
+ function revealSuccess(root, form) {
1241
+ const successEl = root.querySelector("[data-xenia-success]");
1242
+ if (!successEl) return false;
1243
+ if (successEl instanceof HTMLElement) successEl.hidden = false;
1244
+ else successEl.removeAttribute("hidden");
1245
+ if (form) {
1246
+ form.hidden = true;
1247
+ } else {
1248
+ root.querySelectorAll("[data-xenia-field], [data-xenia-submit]").forEach((el) => {
1249
+ el.hidden = true;
1250
+ });
1251
+ }
1252
+ return true;
1253
+ }
1254
+ function wireFormSubmit(root, action, ctx) {
1255
+ const form = root.querySelector("form");
1256
+ let submitting = false;
1257
+ ensureSubmitStyles(root);
1258
+ const run = (control, fields) => {
1259
+ if (submitting) return;
1260
+ if (!action.formSubmit) {
1261
+ showError(root, "This form isn't connected yet.");
1262
+ return;
1263
+ }
1264
+ submitting = true;
1265
+ showError(root, "");
1266
+ setDisabled(control, true);
1267
+ setSubmitting(control, true);
1268
+ const startedAt = Date.now();
1269
+ const minDisplayMs = action.formSubmit.successDisplayMs ?? DEFAULT_SUCCESS_DISPLAY_MS;
1270
+ ctx.submitWidgetForm(fields).then(() => {
1271
+ setSubmitting(control, false);
1272
+ revealSuccess(root, form);
1273
+ const wait = Math.max(0, minDisplayMs - (Date.now() - startedAt));
1274
+ window.setTimeout(() => fadeOutAndDismiss(root, ctx.dismiss), wait);
1275
+ }).catch(() => {
1276
+ setSubmitting(control, false);
1277
+ setDisabled(control, false);
1278
+ showError(root, "Something went wrong. Please try again.");
1279
+ }).finally(() => {
1280
+ submitting = false;
1281
+ });
1282
+ };
1283
+ if (form) {
1284
+ form.addEventListener("submit", (ev) => {
1285
+ ev.preventDefault();
1286
+ const formFields = {};
1287
+ new FormData(form).forEach((value, key) => {
1288
+ if (typeof value === "string") formFields[key] = value;
1289
+ });
1290
+ const submitter = ev.submitter ?? form.querySelector('[type="submit"], button:not([type])');
1291
+ run(submitter, { ...collectFields(root), ...formFields });
1292
+ });
1293
+ }
1294
+ root.querySelectorAll("[data-xenia-submit]").forEach((el) => {
1295
+ if (form?.contains(el)) return;
1296
+ el.addEventListener("click", () => run(el, collectFields(root)));
1297
+ });
1298
+ }
1299
+
1300
+ // src/client/widgets/types.ts
1301
+ function escapeHtml(s) {
1302
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1303
+ }
1304
+ function escapeAttr(s) {
1305
+ return escapeHtml(s).replace(/"/g, "&quot;");
1306
+ }
1307
+ function wireCloseAffordances(root, dismiss) {
1308
+ if (dismiss) {
1309
+ const closeEls = root.querySelectorAll(
1310
+ ".xenia-close, [data-xenia-close], [class*='close' i], [id*='close' i], [class*='backdrop' i], [id*='backdrop' i]"
1311
+ );
1312
+ closeEls.forEach((el) => {
1313
+ el.removeAttribute("onclick");
1314
+ el.addEventListener("click", dismiss);
1315
+ });
1316
+ }
1317
+ root.querySelectorAll("[class*='backdrop' i], [id*='backdrop' i]").forEach((el) => {
1318
+ if (el instanceof HTMLElement && el.children.length === 0 && !el.textContent?.trim()) {
1319
+ el.style.opacity = "0.5";
1320
+ }
1321
+ });
1322
+ }
1323
+
1324
+ // src/client/widgets/spin-wheel.ts
1325
+ var WHEEL_SIZE = 260;
1326
+ var RADIUS = WHEEL_SIZE / 2;
1327
+ var CENTER = WHEEL_SIZE / 2;
1328
+ var DEFAULT_COLORS = ["#7c3aed", "#2563eb", "#059669", "#d97706", "#dc2626", "#0891b2"];
1329
+ var SPIN_TRANSITION = "transform 3.2s cubic-bezier(0.15, 0.85, 0.25, 1)";
1330
+ var WHEEL_POINTER_HTML = '<div class="xenia-wheel-pointer" aria-hidden="true"></div>';
1331
+ var REVEAL_TRANSITION_MS = 320;
1332
+ var REVEAL_TRANSITION = `opacity ${REVEAL_TRANSITION_MS}ms ease, transform ${REVEAL_TRANSITION_MS}ms ease`;
1333
+ var REVEAL_PERSPECTIVE_PX = 900;
1334
+ var revealTransform = (scale, depthPx) => `perspective(${REVEAL_PERSPECTIVE_PX}px) translateZ(${depthPx}px) scale(${scale})`;
1335
+ function polarPoint(angleDeg, radius) {
1336
+ const rad = angleDeg * Math.PI / 180;
1337
+ return { x: CENTER + radius * Math.sin(rad), y: CENTER - radius * Math.cos(rad) };
1338
+ }
1339
+ function segmentPath(startAngle, endAngle) {
1340
+ const start = polarPoint(startAngle, RADIUS);
1341
+ const end = polarPoint(endAngle, RADIUS);
1342
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0;
1343
+ return `M ${CENTER},${CENTER} L ${start.x},${start.y} A ${RADIUS},${RADIUS} 0 ${largeArc} 1 ${end.x},${end.y} Z`;
1344
+ }
1345
+ function computeSpinRotation(segmentIndex, segmentCount, extraSpins = 0) {
1346
+ if (segmentCount <= 0) return 0;
1347
+ const segmentAngle = 360 / segmentCount;
1348
+ const centerAngle = segmentIndex * segmentAngle + segmentAngle / 2;
1349
+ const landingAngle = (360 - centerAngle) % 360;
1350
+ return extraSpins * 360 + landingAngle;
1351
+ }
1352
+ function crossFadeReveal(outgoing, incoming) {
1353
+ const outContent = outgoing ? Array.from(outgoing.children) : [];
1354
+ const inContent = incoming ? Array.from(incoming.children) : [];
1355
+ if (incoming) incoming.hidden = false;
1356
+ inContent.forEach((el) => {
1357
+ if (!(el instanceof HTMLElement)) return;
1358
+ el.style.transition = "none";
1359
+ el.style.opacity = "0";
1360
+ el.style.transform = revealTransform(0.94, -60);
1361
+ });
1362
+ nextFrame(() => {
1363
+ outContent.forEach((el) => {
1364
+ if (!(el instanceof HTMLElement)) return;
1365
+ el.style.transition = REVEAL_TRANSITION;
1366
+ el.style.opacity = "0";
1367
+ el.style.transform = revealTransform(0.94, -60);
1368
+ });
1369
+ inContent.forEach((el) => {
1370
+ if (!(el instanceof HTMLElement)) return;
1371
+ el.style.transition = REVEAL_TRANSITION;
1372
+ el.style.opacity = "1";
1373
+ el.style.transform = revealTransform(1, 0);
1374
+ });
1375
+ });
1376
+ if (outgoing) {
1377
+ setTimeout(() => {
1378
+ outgoing.hidden = true;
1379
+ }, REVEAL_TRANSITION_MS);
1380
+ }
1381
+ }
1382
+ function nextFrame(fn) {
1383
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(fn);
1384
+ else setTimeout(fn, 0);
1385
+ }
1386
+ function wheelSvg(segments) {
1387
+ const n = segments.length || 1;
1388
+ const segmentAngle = 360 / n;
1389
+ const slices = segments.map((seg, i) => {
1390
+ const start = i * segmentAngle;
1391
+ const end = start + segmentAngle;
1392
+ const color = seg.colorHex || DEFAULT_COLORS[i % DEFAULT_COLORS.length];
1393
+ const labelAngle = start + segmentAngle / 2;
1394
+ const labelPoint = polarPoint(labelAngle, RADIUS * 0.62);
1395
+ 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>`;
1396
+ }).join("");
1397
+ 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>`;
1398
+ }
1399
+ var spinWheelRenderer = {
1400
+ html(action) {
1401
+ 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>`;
1402
+ },
1403
+ wire(root, action, ctx) {
1404
+ wireCloseAffordances(root, ctx.dismiss);
1405
+ const segments = action.segments ?? [];
1406
+ const wheelFace = root.querySelector("[data-xenia-wheel-face]");
1407
+ const spinBtn = root.querySelector("[data-xenia-spin]");
1408
+ const winEl = root.querySelector("[data-xenia-wheel-result]");
1409
+ const lossEl = root.querySelector("[data-xenia-wheel-loss]");
1410
+ const errorEl = root.querySelector("[data-xenia-error]");
1411
+ const slotEl = root.querySelector("[data-xenia-wheel-slot]");
1412
+ if (slotEl) slotEl.insertAdjacentHTML("afterbegin", wheelSvg(segments) + WHEEL_POINTER_HTML);
1413
+ const dial = root.querySelector(".xenia-wheel-dial");
1414
+ if (!spinBtn || !action.formSubmit) return;
1415
+ root.querySelectorAll("[data-xenia-wheel-cta]").forEach((btn) => {
1416
+ btn.addEventListener("click", () => ctx.fireCta());
1417
+ });
1418
+ const revealResult = (prizeLabel, isLoss, canRetry) => {
1419
+ root.querySelectorAll("[data-xenia-wheel-prize]").forEach((el) => {
1420
+ el.textContent = prizeLabel;
1421
+ });
1422
+ const shown = isLoss ? lossEl : winEl;
1423
+ const other = isLoss ? winEl : lossEl;
1424
+ if (other) other.hidden = true;
1425
+ crossFadeReveal(wheelFace, shown);
1426
+ if (!canRetry) {
1427
+ playsExhausted = true;
1428
+ root.querySelectorAll("[data-xenia-spin-again]").forEach((btn) => {
1429
+ btn.hidden = true;
1430
+ });
1431
+ }
1432
+ };
1433
+ let spinning = false;
1434
+ let playsExhausted = false;
1435
+ root.querySelectorAll("[data-xenia-spin-again]").forEach((btn) => {
1436
+ btn.addEventListener("click", () => {
1437
+ if (playsExhausted) return;
1438
+ const shown = winEl && !winEl.hidden ? winEl : lossEl && !lossEl.hidden ? lossEl : null;
1439
+ if (!shown) return;
1440
+ spinning = false;
1441
+ spinBtn.disabled = false;
1442
+ if (errorEl) errorEl.textContent = "";
1443
+ crossFadeReveal(shown, wheelFace);
1444
+ });
1445
+ });
1446
+ spinBtn.addEventListener("click", () => {
1447
+ if (spinning) return;
1448
+ spinning = true;
1449
+ spinBtn.disabled = true;
1450
+ if (errorEl) errorEl.textContent = "";
1451
+ const fields = collectFields(root);
1452
+ ctx.submitWidgetForm(fields).then((response) => {
1453
+ const index = segments.findIndex((s) => s.id === response.prize?.segmentId);
1454
+ const prizeLabel = response.prize?.label ?? segments[index]?.label ?? "";
1455
+ const isLoss = index >= 0 && !!segments[index]?.isLoss;
1456
+ const canRetry = response.alreadyPlayed !== true;
1457
+ if (index < 0 || !dial) {
1458
+ revealResult(prizeLabel, isLoss, canRetry);
1459
+ return;
1460
+ }
1461
+ const extraSpins = 3 + Math.floor(Math.random() * 3);
1462
+ const rotation = computeSpinRotation(index, segments.length || 1, extraSpins);
1463
+ const priorMatch = /rotate\(([-\d.]+)deg\)/.exec(dial.style.transform);
1464
+ const priorRotation = priorMatch ? parseFloat(priorMatch[1]) : 0;
1465
+ dial.style.transition = "none";
1466
+ dial.style.transform = `rotate(${priorRotation % 360}deg)`;
1467
+ void dial.getBoundingClientRect();
1468
+ dial.style.transition = SPIN_TRANSITION;
1469
+ const onTransitionEnd = () => {
1470
+ dial.removeEventListener("transitionend", onTransitionEnd);
1471
+ revealResult(prizeLabel, isLoss, canRetry);
1472
+ };
1473
+ dial.addEventListener("transitionend", onTransitionEnd);
1474
+ nextFrame(() => {
1475
+ dial.style.transform = `rotate(${rotation}deg)`;
1476
+ });
1477
+ }).catch(() => {
1478
+ spinning = false;
1479
+ spinBtn.disabled = false;
1480
+ if (errorEl) errorEl.textContent = "Something went wrong. Please try again.";
1481
+ });
1482
+ });
1483
+ }
1484
+ };
1485
+
1486
+ // src/client/widgets/index.ts
1487
+ var WIDGET_RENDERERS = {
1488
+ "spin-wheel": spinWheelRenderer
1489
+ };
1490
+
1089
1491
  // src/client/renderer.ts
1090
1492
  function resolveTarget(target, root = document) {
1091
1493
  if (!target) return null;
@@ -1143,6 +1545,7 @@ var ActionRenderer = class {
1143
1545
  messageTemplate;
1144
1546
  messageStyles;
1145
1547
  messageComponent;
1548
+ submitWidgetFormFn;
1146
1549
  /** Unmount callbacks for currently-mounted framework components, keyed by their container. */
1147
1550
  mounted = /* @__PURE__ */ new Map();
1148
1551
  constructor(onCta, opts) {
@@ -1150,6 +1553,7 @@ var ActionRenderer = class {
1150
1553
  this.messageTemplate = opts?.messageTemplate;
1151
1554
  this.messageStyles = opts?.messageStyles;
1152
1555
  this.messageComponent = opts?.messageComponent;
1556
+ this.submitWidgetFormFn = opts?.submitWidgetForm;
1153
1557
  }
1154
1558
  /** Apply a list of actions. Returns the count successfully applied. */
1155
1559
  apply(actions) {
@@ -1232,7 +1636,8 @@ ${this.messageStyles}` : STYLES;
1232
1636
  const wrap = document.createElement("div");
1233
1637
  const posClass = this.positionClass(action.type, action.position);
1234
1638
  const customBgClass = action.html !== void 0 ? " xenia-custom-bg" : "";
1235
- wrap.className = `xenia-msg xenia-${action.type}${posClass ? ` ${posClass}` : ""}${customBgClass}`;
1639
+ const noOverlayClass = action.style?.hideOverlay ? " xenia-no-overlay" : "";
1640
+ wrap.className = `xenia-msg xenia-${action.type}${posClass ? ` ${posClass}` : ""}${customBgClass}${noOverlayClass}`;
1236
1641
  shadow.appendChild(wrap);
1237
1642
  const dismiss = () => {
1238
1643
  this.unmount(wrap);
@@ -1245,7 +1650,10 @@ ${this.messageStyles}` : STYLES;
1245
1650
  this.wireCard(wrap, action, dismiss);
1246
1651
  }
1247
1652
  if (action.dismissAfterMs && action.dismissAfterMs > 0) {
1248
- setTimeout(dismiss, action.dismissAfterMs);
1653
+ const timer = setTimeout(dismiss, action.dismissAfterMs);
1654
+ const cancelAutoDismiss = () => clearTimeout(timer);
1655
+ wrap.addEventListener("click", cancelAutoDismiss, { once: true });
1656
+ wrap.addEventListener("focusin", cancelAutoDismiss, { once: true });
1249
1657
  }
1250
1658
  return true;
1251
1659
  }
@@ -1289,38 +1697,32 @@ ${this.messageStyles}` : STYLES;
1289
1697
  return "";
1290
1698
  }
1291
1699
  cardHtml(action, variant) {
1700
+ const widget = action.widgetType ? WIDGET_RENDERERS[action.widgetType] : void 0;
1701
+ if (widget) return widget.html(action, variant);
1292
1702
  if (action.html !== void 0) return wrapCustomHtml(action.html, variant);
1293
1703
  if (this.messageTemplate) {
1294
1704
  const ctx = {
1295
1705
  action,
1296
1706
  variant,
1297
- escapeHtml,
1298
- escapeAttr
1707
+ escapeHtml: escapeHtml2,
1708
+ escapeAttr: escapeAttr2
1299
1709
  };
1300
1710
  return this.messageTemplate(ctx);
1301
1711
  }
1302
- const img = action.imageUrl ? `<img class="xenia-img" src="${escapeAttr(action.imageUrl)}" alt="" />` : "";
1303
- const title = action.title ? `<div class="xenia-title">${escapeHtml(action.title)}</div>` : "";
1304
- const msg = action.message ? `<div class="xenia-body">${escapeHtml(action.message)}</div>` : "";
1305
- const cta = action.cta ? `<button class="xenia-cta" type="button">${escapeHtml(action.cta.label)}</button>` : "";
1712
+ const img = action.imageUrl ? `<img class="xenia-img" src="${escapeAttr2(action.imageUrl)}" alt="" />` : "";
1713
+ const title = action.title ? `<div class="xenia-title">${escapeHtml2(action.title)}</div>` : "";
1714
+ const msg = action.message ? `<div class="xenia-body">${escapeHtml2(action.message)}</div>` : "";
1715
+ const cta = action.cta ? `<button class="xenia-cta" type="button">${escapeHtml2(action.cta.label)}</button>` : "";
1306
1716
  const close = variant === "inline" ? "" : `<button class="xenia-close" type="button" aria-label="Close">\xD7</button>`;
1307
1717
  return `<div class="xenia-card">${close}${img}<div class="xenia-content">${title}${msg}${cta}</div></div>`;
1308
1718
  }
1309
1719
  wireCard(root, action, dismiss) {
1310
- if (dismiss) {
1311
- const closeEls = root.querySelectorAll(
1312
- ".xenia-close, [data-xenia-close], [class*='close' i], [id*='close' i], [class*='backdrop' i], [id*='backdrop' i]"
1313
- );
1314
- closeEls.forEach((el) => {
1315
- el.removeAttribute("onclick");
1316
- el.addEventListener("click", dismiss);
1317
- });
1720
+ const widget = action.widgetType ? WIDGET_RENDERERS[action.widgetType] : void 0;
1721
+ if (widget) {
1722
+ widget.wire(root, action, this.buildWidgetCtx(action, dismiss));
1723
+ return;
1318
1724
  }
1319
- root.querySelectorAll("[class*='backdrop' i], [id*='backdrop' i]").forEach((el) => {
1320
- if (el instanceof HTMLElement && el.children.length === 0 && !el.textContent?.trim()) {
1321
- el.style.opacity = "0.5";
1322
- }
1323
- });
1725
+ wireCloseAffordances(root, dismiss);
1324
1726
  const ctaBtn = root.querySelector(".xenia-cta, [data-xenia-cta]");
1325
1727
  if (ctaBtn && action.cta) {
1326
1728
  ctaBtn.addEventListener("click", () => {
@@ -1329,6 +1731,29 @@ ${this.messageStyles}` : STYLES;
1329
1731
  dismiss?.();
1330
1732
  });
1331
1733
  }
1734
+ wireFormSubmit(root, action, this.buildWidgetCtx(action, dismiss));
1735
+ }
1736
+ /** Build the imperative hooks (`dismiss`/`fireCta`/`submitWidgetForm`) handed to a widget's `wire()`. */
1737
+ buildWidgetCtx(action, dismiss) {
1738
+ const idempotencyKey = generateUlid();
1739
+ return {
1740
+ dismiss,
1741
+ fireCta: () => {
1742
+ if (action.cta?.event) this.onCta?.(action.cta.event, action.cta);
1743
+ if (action.cta?.url) window.location.assign(action.cta.url);
1744
+ dismiss?.();
1745
+ },
1746
+ submitWidgetForm: (fields) => {
1747
+ if (!action.formSubmit || !this.submitWidgetFormFn) {
1748
+ return Promise.reject(
1749
+ new Error(
1750
+ "xenia: this action has no formSubmit config, or no submitWidgetForm handler is wired (construct the ActionRenderer via XeniaClient to get one)"
1751
+ )
1752
+ );
1753
+ }
1754
+ return this.submitWidgetFormFn(action.formSubmit, fields, { idempotencyKey });
1755
+ }
1756
+ };
1332
1757
  }
1333
1758
  };
1334
1759
  function wrapCustomHtml(html, variant) {
@@ -1337,11 +1762,11 @@ function wrapCustomHtml(html, variant) {
1337
1762
  }
1338
1763
  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>`;
1339
1764
  }
1340
- function escapeHtml(s) {
1765
+ function escapeHtml2(s) {
1341
1766
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1342
1767
  }
1343
- function escapeAttr(s) {
1344
- return escapeHtml(s).replace(/"/g, "&quot;");
1768
+ function escapeAttr2(s) {
1769
+ return escapeHtml2(s).replace(/"/g, "&quot;");
1345
1770
  }
1346
1771
  var STYLES = `
1347
1772
  :host { all: initial; }
@@ -1358,12 +1783,14 @@ var STYLES = `
1358
1783
  .xenia-modal { inset: 0; display: flex; align-items: center; justify-content: center;
1359
1784
  background: var(--xenia-modal-backdrop, rgba(0,0,0,.12)); }
1360
1785
  .xenia-modal.xenia-custom-bg { background: transparent; }
1786
+ .xenia-modal.xenia-no-overlay { background: transparent; }
1361
1787
  .xenia-card { position: relative; background: var(--xenia-card-bg, #fff); color: var(--xenia-card-color, #111);
1362
1788
  border-radius: var(--xenia-card-radius, 12px); box-shadow: var(--xenia-card-shadow, 0 10px 40px rgba(0,0,0,.18));
1363
1789
  padding: var(--xenia-card-padding, 16px 18px); display: flex; gap: var(--xenia-card-gap, 12px);
1364
1790
  align-items: center; }
1365
1791
  .xenia-banner .xenia-card { border-radius: var(--xenia-banner-card-radius, 0); justify-content: center; }
1366
1792
  .xenia-modal .xenia-card { max-width: var(--xenia-modal-max-width, 440px); width: calc(100% - 32px); }
1793
+ .xenia-card.xenia-widget { flex-direction: column; align-items: center; text-align: center; }
1367
1794
  .xenia-img { width: var(--xenia-img-size, 56px); height: var(--xenia-img-size, 56px); object-fit: cover;
1368
1795
  border-radius: var(--xenia-img-radius, 8px); }
1369
1796
  .xenia-title { font-weight: var(--xenia-title-weight, 600); font-size: var(--xenia-title-size, 15px);
@@ -1375,6 +1802,38 @@ var STYLES = `
1375
1802
  font-size: var(--xenia-cta-size, 14px); cursor: pointer; }
1376
1803
  .xenia-close { position: absolute; top: 6px; right: 8px; background: none; border: 0;
1377
1804
  font-size: var(--xenia-close-size, 20px); line-height: 1; cursor: pointer; color: var(--xenia-close-color, #999); }
1805
+ .xenia-wheel-dial { transform-box: fill-box; transform-origin: center; }
1806
+ .xenia-wheel-hub { fill: var(--xenia-wheel-pointer-color, #111); }
1807
+ .xenia-wheel-wrap { position: relative; display: inline-block; }
1808
+ .xenia-wheel-pointer { position: absolute; top: -6px; left: 50%; transform: translateX(-50%);
1809
+ width: 0; height: 0; border-left: 9px solid transparent; border-right: 9px solid transparent;
1810
+ border-top: 14px solid var(--xenia-wheel-pointer-color, #111); pointer-events: none; }
1811
+ .xenia-wheel-spin { margin-top: 10px; background: var(--xenia-cta-bg, #111); color: var(--xenia-cta-color, #fff);
1812
+ border: 0; border-radius: var(--xenia-cta-radius, 8px); padding: var(--xenia-cta-padding, 8px 14px);
1813
+ font-size: var(--xenia-cta-size, 14px); font-family: inherit; cursor: pointer; }
1814
+ .xenia-wheel-spin:disabled { opacity: 0.6; cursor: default; }
1815
+ /* No animation/transform here \u2014 spin-wheel's win/loss screens are ALWAYS full
1816
+ custom HTML now (winHtml/lossHtml), never this class's own generated icon/title/prize/cta
1817
+ content (those sub-classes below are unreachable dead CSS, kept only for backwards-compat with
1818
+ any already-saved artifact that might still reference them). A transform on THIS wrapper
1819
+ would make it the CSS containing block for any fixed/absolute-positioned descendant the
1820
+ author's own HTML uses (a real, common case \u2014 a "full-screen overlay" win/loss design) \u2014 the
1821
+ fixed child would then size/position itself relative to this wrapper's own small, in-flow,
1822
+ often off-center box instead of the true browser viewport, visibly popping in from/toward
1823
+ whatever corner the wrapper happens to sit at instead of scaling from the real screen center.
1824
+ spin-wheel.ts's crossFadeReveal owns the actual reveal transition (applied to the author's own
1825
+ top-level element, never this wrapper) \u2014 this class stays a bare, transform-free layout box. */
1826
+ .xenia-widget-result { display: flex; flex-direction: column; align-items: center; gap: 4px;
1827
+ padding: 8px 4px 2px; text-align: center; }
1828
+ .xenia-widget-result[hidden] { display: none; }
1829
+ .xenia-widget-result-icon { font-size: 40px; line-height: 1; }
1830
+ .xenia-widget-result-title { font-weight: var(--xenia-title-weight, 700); font-size: var(--xenia-title-size, 17px); }
1831
+ .xenia-widget-result-prize { font-weight: 600; font-size: 15px; color: var(--xenia-cta-bg, #111);
1832
+ background: var(--xenia-result-prize-bg, rgba(0,0,0,.06)); border-radius: var(--xenia-cta-radius, 8px);
1833
+ padding: 6px 14px; }
1834
+ .xenia-widget-result-cta { margin-top: 8px; background: var(--xenia-cta-bg, #111); color: var(--xenia-cta-color, #fff);
1835
+ border: 0; border-radius: var(--xenia-cta-radius, 8px); padding: var(--xenia-cta-padding, 8px 14px);
1836
+ font-size: var(--xenia-cta-size, 14px); font-family: inherit; cursor: pointer; }
1378
1837
  `;
1379
1838
 
1380
1839
  // src/client/xenia-client.ts
@@ -1399,7 +1858,8 @@ var XeniaClient = class {
1399
1858
  this.renderer = new ActionRenderer((event, cta) => this.onCtaEvent(event, cta), {
1400
1859
  messageTemplate: options.messageTemplate,
1401
1860
  messageStyles: options.messageStyles,
1402
- messageComponent: options.messageComponent
1861
+ messageComponent: options.messageComponent,
1862
+ submitWidgetForm: (submit, fields, subOpts) => this.submitWidgetForm(submit, fields, subOpts)
1403
1863
  });
1404
1864
  this.consentState = { ...options.consent };
1405
1865
  this.analyticsQueue = new AnalyticsQueue({
@@ -1671,6 +2131,55 @@ var XeniaClient = class {
1671
2131
  getResponse(key) {
1672
2132
  return this.responses.get(key);
1673
2133
  }
2134
+ /**
2135
+ * Submit a widget's captured form values (standard/custom popup submit, or a spin-wheel/
2136
+ * spin-wheel's resolve request) and apply any returned `xeniaActions`, exactly like
2137
+ * {@link request}.
2138
+ *
2139
+ * **Endpoint selection is config-driven, not SDK-decided** (per the cross-repo contract): a
2140
+ * `flowRef` (present only when `mode === "journey" && !requiresResolve` — the common
2141
+ * plain-popup case) bypasses the resolve endpoint entirely and POSTs straight to the existing
2142
+ * public `/flow` fast path; otherwise this POSTs to `/widget-submissions` via `bindingRef`. The
2143
+ * SDK is agnostic to *why* — it just follows whichever ref field the backend populated.
2144
+ *
2145
+ * The full ambient-context parity list {@link request}/`withVisitor()` already build
2146
+ * (visitorId, deviceId, url, referrer, path, query, `utm*`) rides on every call, plus `fields`
2147
+ * and `submit.mode`/the resolved ref, relayed verbatim/uninterpreted — the SDK never branches
2148
+ * on `mode`'s value itself, that's server-side.
2149
+ *
2150
+ * `opts.idempotencyKey` defaults to a freshly generated ULID; pass the SAME key back in on a
2151
+ * retried submit of the same interaction so a network retry can't double-fire a journey or
2152
+ * double-resolve a prize server-side.
2153
+ */
2154
+ async submitWidgetForm(submit, fields, opts) {
2155
+ const ctx = this.identity.collectContext();
2156
+ const idempotencyKey = opts?.idempotencyKey ?? generateUlid();
2157
+ const ref = submit.flowRef ?? submit.bindingRef;
2158
+ const ambient = {
2159
+ visitorId: this.resolveVisitorId(),
2160
+ deviceId: this.identity.getDeviceId() ?? void 0,
2161
+ idempotencyKey,
2162
+ mode: submit.mode,
2163
+ ref,
2164
+ url: ctx.url,
2165
+ referrer: ctx.referrer,
2166
+ path: ctx.path,
2167
+ query: ctx.query,
2168
+ utmSource: ctx.utmSource,
2169
+ utmMedium: ctx.utmMedium,
2170
+ utmCampaign: ctx.utmCampaign,
2171
+ utmContent: ctx.utmContent,
2172
+ utmId: ctx.utmId
2173
+ };
2174
+ const response = submit.flowRef ? await this.api.webhook(submit.flowRef, compact({ ...ambient, ...fields }), "POST") : await this.api.submitWidget(
2175
+ submit.bindingRef ?? "",
2176
+ compact({ ...ambient, fields })
2177
+ );
2178
+ this.emit("response", { triggerId: ref, response });
2179
+ const apply = opts?.apply ?? this.options.autoApplyActions !== false;
2180
+ if (apply && response.actions.length) this.applyActions(response.actions);
2181
+ return response;
2182
+ }
1674
2183
  // ── Imperative render helpers ─────────────────────────────────────────────────
1675
2184
  swapContent(target, content) {
1676
2185
  return swapContent(target, content);
@@ -1780,6 +2289,6 @@ function extractContentSwaps(body) {
1780
2289
  return Array.isArray(swaps) ? swaps : void 0;
1781
2290
  }
1782
2291
 
1783
- export { ActionRenderer, AnalyticsQueue, AutoTracker, Identity, TokenProvider, XeniaClient, createXeniaClient, extractContentSwaps, hideContent, parseActions, parseDataAttribute, readSlotMeta, resolveTarget, showContent, spliceSlots, swapContent };
2292
+ export { ActionRenderer, AnalyticsQueue, AutoTracker, Identity, TokenProvider, WIDGET_RENDERERS, XeniaClient, collectFields, computeSpinRotation, createXeniaClient, extractContentSwaps, hideContent, parseActions, parseDataAttribute, readSlotMeta, resolveTarget, showContent, spliceSlots, swapContent, wireFormSubmit };
1784
2293
  //# sourceMappingURL=index.js.map
1785
2294
  //# sourceMappingURL=index.js.map