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