@xenia-martech/sdk-js 1.0.0-dev.27 → 1.0.0-dev.29

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