@xenia-martech/sdk-js 1.0.0-dev.26 → 1.0.0-dev.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CSS_VARIABLES.md +7 -0
- package/README.md +71 -2
- package/dist/{actions-DFLoSXO_.d.cts → actions-CcBYV--3.d.cts} +242 -6
- package/dist/{actions-DFLoSXO_.d.ts → actions-CcBYV--3.d.ts} +242 -6
- package/dist/{chunk-3KYKKIIG.cjs → chunk-WYM4CQVG.cjs} +126 -4
- package/dist/chunk-WYM4CQVG.cjs.map +1 -0
- package/dist/{chunk-D37RD3UC.js → chunk-XXNDHYAS.js} +125 -5
- package/dist/chunk-XXNDHYAS.js.map +1 -0
- package/dist/client/index.cjs +613 -35
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.cts +112 -3
- package/dist/client/index.d.ts +112 -3
- package/dist/client/index.js +610 -36
- package/dist/client/index.js.map +1 -1
- package/dist/client/react/index.cjs +609 -35
- package/dist/client/react/index.cjs.map +1 -1
- package/dist/client/react/index.d.cts +2 -2
- package/dist/client/react/index.d.ts +2 -2
- package/dist/client/react/index.js +609 -35
- package/dist/client/react/index.js.map +1 -1
- package/dist/{cookie-names-LgNDAreS.d.cts → cookie-names-B3b0XNc2.d.cts} +187 -1
- package/dist/{cookie-names-LgNDAreS.d.ts → cookie-names-B3b0XNc2.d.ts} +187 -1
- package/dist/index.cjs +23 -19
- package/dist/index.d.cts +23 -3
- package/dist/index.d.ts +23 -3
- package/dist/index.js +1 -1
- package/dist/server/index.cjs +53 -22
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +31 -11
- package/dist/server/index.d.ts +31 -11
- package/dist/server/index.js +43 -12
- package/dist/server/index.js.map +1 -1
- package/dist/xenia-sdk.iife.js +37 -3
- package/dist/xenia-sdk.iife.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-3KYKKIIG.cjs.map +0 -1
- package/dist/chunk-D37RD3UC.js.map +0 -1
package/dist/client/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -964,6 +1056,40 @@ var Identity = class {
|
|
|
964
1056
|
return null;
|
|
965
1057
|
}
|
|
966
1058
|
}
|
|
1059
|
+
// ── Customer id / email ──────────────────────────────────────────────────
|
|
1060
|
+
//
|
|
1061
|
+
// Ambient, persisted the same way as the visitor id, so a value set once
|
|
1062
|
+
// (typically alongside `identify()`) rides on every later `resolve()` call
|
|
1063
|
+
// without the caller having to pass it again — the SDK-provided-fields
|
|
1064
|
+
// parity list on the Personalization Resolve trigger includes both.
|
|
1065
|
+
setCustomerId(customerId) {
|
|
1066
|
+
try {
|
|
1067
|
+
if (customerId) localStorage.setItem(this.key("customer_id"), customerId);
|
|
1068
|
+
else localStorage.removeItem(this.key("customer_id"));
|
|
1069
|
+
} catch {
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
getCustomerId() {
|
|
1073
|
+
try {
|
|
1074
|
+
return localStorage.getItem(this.key("customer_id"));
|
|
1075
|
+
} catch {
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
setEmail(email) {
|
|
1080
|
+
try {
|
|
1081
|
+
if (email) localStorage.setItem(this.key("email"), email);
|
|
1082
|
+
else localStorage.removeItem(this.key("email"));
|
|
1083
|
+
} catch {
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
getEmail() {
|
|
1087
|
+
try {
|
|
1088
|
+
return localStorage.getItem(this.key("email"));
|
|
1089
|
+
} catch {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
967
1093
|
// ── Session id ───────────────────────────────────────────────────────────────
|
|
968
1094
|
/**
|
|
969
1095
|
* Resolve the current session id: reused while activity continues within
|
|
@@ -1052,6 +1178,316 @@ function resolveCookieConfig(opt) {
|
|
|
1052
1178
|
return { sameSite, secure, maxAgeDays: o.maxAgeDays ?? 365, domain: o.domain };
|
|
1053
1179
|
}
|
|
1054
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1303
|
+
}
|
|
1304
|
+
function escapeAttr(s) {
|
|
1305
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
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
|
+
|
|
1055
1491
|
// src/client/renderer.ts
|
|
1056
1492
|
function resolveTarget(target, root = document) {
|
|
1057
1493
|
if (!target) return null;
|
|
@@ -1109,6 +1545,7 @@ var ActionRenderer = class {
|
|
|
1109
1545
|
messageTemplate;
|
|
1110
1546
|
messageStyles;
|
|
1111
1547
|
messageComponent;
|
|
1548
|
+
submitWidgetFormFn;
|
|
1112
1549
|
/** Unmount callbacks for currently-mounted framework components, keyed by their container. */
|
|
1113
1550
|
mounted = /* @__PURE__ */ new Map();
|
|
1114
1551
|
constructor(onCta, opts) {
|
|
@@ -1116,6 +1553,7 @@ var ActionRenderer = class {
|
|
|
1116
1553
|
this.messageTemplate = opts?.messageTemplate;
|
|
1117
1554
|
this.messageStyles = opts?.messageStyles;
|
|
1118
1555
|
this.messageComponent = opts?.messageComponent;
|
|
1556
|
+
this.submitWidgetFormFn = opts?.submitWidgetForm;
|
|
1119
1557
|
}
|
|
1120
1558
|
/** Apply a list of actions. Returns the count successfully applied. */
|
|
1121
1559
|
apply(actions) {
|
|
@@ -1198,7 +1636,8 @@ ${this.messageStyles}` : STYLES;
|
|
|
1198
1636
|
const wrap = document.createElement("div");
|
|
1199
1637
|
const posClass = this.positionClass(action.type, action.position);
|
|
1200
1638
|
const customBgClass = action.html !== void 0 ? " xenia-custom-bg" : "";
|
|
1201
|
-
|
|
1639
|
+
const noOverlayClass = action.style?.hideOverlay ? " xenia-no-overlay" : "";
|
|
1640
|
+
wrap.className = `xenia-msg xenia-${action.type}${posClass ? ` ${posClass}` : ""}${customBgClass}${noOverlayClass}`;
|
|
1202
1641
|
shadow.appendChild(wrap);
|
|
1203
1642
|
const dismiss = () => {
|
|
1204
1643
|
this.unmount(wrap);
|
|
@@ -1211,7 +1650,10 @@ ${this.messageStyles}` : STYLES;
|
|
|
1211
1650
|
this.wireCard(wrap, action, dismiss);
|
|
1212
1651
|
}
|
|
1213
1652
|
if (action.dismissAfterMs && action.dismissAfterMs > 0) {
|
|
1214
|
-
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 });
|
|
1215
1657
|
}
|
|
1216
1658
|
return true;
|
|
1217
1659
|
}
|
|
@@ -1255,38 +1697,32 @@ ${this.messageStyles}` : STYLES;
|
|
|
1255
1697
|
return "";
|
|
1256
1698
|
}
|
|
1257
1699
|
cardHtml(action, variant) {
|
|
1700
|
+
const widget = action.widgetType ? WIDGET_RENDERERS[action.widgetType] : void 0;
|
|
1701
|
+
if (widget) return widget.html(action, variant);
|
|
1258
1702
|
if (action.html !== void 0) return wrapCustomHtml(action.html, variant);
|
|
1259
1703
|
if (this.messageTemplate) {
|
|
1260
1704
|
const ctx = {
|
|
1261
1705
|
action,
|
|
1262
1706
|
variant,
|
|
1263
|
-
escapeHtml,
|
|
1264
|
-
escapeAttr
|
|
1707
|
+
escapeHtml: escapeHtml2,
|
|
1708
|
+
escapeAttr: escapeAttr2
|
|
1265
1709
|
};
|
|
1266
1710
|
return this.messageTemplate(ctx);
|
|
1267
1711
|
}
|
|
1268
|
-
const img = action.imageUrl ? `<img class="xenia-img" src="${
|
|
1269
|
-
const title = action.title ? `<div class="xenia-title">${
|
|
1270
|
-
const msg = action.message ? `<div class="xenia-body">${
|
|
1271
|
-
const cta = action.cta ? `<button class="xenia-cta" type="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>` : "";
|
|
1272
1716
|
const close = variant === "inline" ? "" : `<button class="xenia-close" type="button" aria-label="Close">\xD7</button>`;
|
|
1273
1717
|
return `<div class="xenia-card">${close}${img}<div class="xenia-content">${title}${msg}${cta}</div></div>`;
|
|
1274
1718
|
}
|
|
1275
1719
|
wireCard(root, action, dismiss) {
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
closeEls.forEach((el) => {
|
|
1281
|
-
el.removeAttribute("onclick");
|
|
1282
|
-
el.addEventListener("click", dismiss);
|
|
1283
|
-
});
|
|
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;
|
|
1284
1724
|
}
|
|
1285
|
-
root
|
|
1286
|
-
if (el instanceof HTMLElement && el.children.length === 0 && !el.textContent?.trim()) {
|
|
1287
|
-
el.style.opacity = "0.5";
|
|
1288
|
-
}
|
|
1289
|
-
});
|
|
1725
|
+
wireCloseAffordances(root, dismiss);
|
|
1290
1726
|
const ctaBtn = root.querySelector(".xenia-cta, [data-xenia-cta]");
|
|
1291
1727
|
if (ctaBtn && action.cta) {
|
|
1292
1728
|
ctaBtn.addEventListener("click", () => {
|
|
@@ -1295,6 +1731,29 @@ ${this.messageStyles}` : STYLES;
|
|
|
1295
1731
|
dismiss?.();
|
|
1296
1732
|
});
|
|
1297
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
|
+
};
|
|
1298
1757
|
}
|
|
1299
1758
|
};
|
|
1300
1759
|
function wrapCustomHtml(html, variant) {
|
|
@@ -1303,11 +1762,11 @@ function wrapCustomHtml(html, variant) {
|
|
|
1303
1762
|
}
|
|
1304
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>`;
|
|
1305
1764
|
}
|
|
1306
|
-
function
|
|
1765
|
+
function escapeHtml2(s) {
|
|
1307
1766
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1308
1767
|
}
|
|
1309
|
-
function
|
|
1310
|
-
return
|
|
1768
|
+
function escapeAttr2(s) {
|
|
1769
|
+
return escapeHtml2(s).replace(/"/g, """);
|
|
1311
1770
|
}
|
|
1312
1771
|
var STYLES = `
|
|
1313
1772
|
:host { all: initial; }
|
|
@@ -1324,12 +1783,14 @@ var STYLES = `
|
|
|
1324
1783
|
.xenia-modal { inset: 0; display: flex; align-items: center; justify-content: center;
|
|
1325
1784
|
background: var(--xenia-modal-backdrop, rgba(0,0,0,.12)); }
|
|
1326
1785
|
.xenia-modal.xenia-custom-bg { background: transparent; }
|
|
1786
|
+
.xenia-modal.xenia-no-overlay { background: transparent; }
|
|
1327
1787
|
.xenia-card { position: relative; background: var(--xenia-card-bg, #fff); color: var(--xenia-card-color, #111);
|
|
1328
1788
|
border-radius: var(--xenia-card-radius, 12px); box-shadow: var(--xenia-card-shadow, 0 10px 40px rgba(0,0,0,.18));
|
|
1329
1789
|
padding: var(--xenia-card-padding, 16px 18px); display: flex; gap: var(--xenia-card-gap, 12px);
|
|
1330
1790
|
align-items: center; }
|
|
1331
1791
|
.xenia-banner .xenia-card { border-radius: var(--xenia-banner-card-radius, 0); justify-content: center; }
|
|
1332
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; }
|
|
1333
1794
|
.xenia-img { width: var(--xenia-img-size, 56px); height: var(--xenia-img-size, 56px); object-fit: cover;
|
|
1334
1795
|
border-radius: var(--xenia-img-radius, 8px); }
|
|
1335
1796
|
.xenia-title { font-weight: var(--xenia-title-weight, 600); font-size: var(--xenia-title-size, 15px);
|
|
@@ -1341,6 +1802,38 @@ var STYLES = `
|
|
|
1341
1802
|
font-size: var(--xenia-cta-size, 14px); cursor: pointer; }
|
|
1342
1803
|
.xenia-close { position: absolute; top: 6px; right: 8px; background: none; border: 0;
|
|
1343
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; }
|
|
1344
1837
|
`;
|
|
1345
1838
|
|
|
1346
1839
|
// src/client/xenia-client.ts
|
|
@@ -1365,7 +1858,8 @@ var XeniaClient = class {
|
|
|
1365
1858
|
this.renderer = new ActionRenderer((event, cta) => this.onCtaEvent(event, cta), {
|
|
1366
1859
|
messageTemplate: options.messageTemplate,
|
|
1367
1860
|
messageStyles: options.messageStyles,
|
|
1368
|
-
messageComponent: options.messageComponent
|
|
1861
|
+
messageComponent: options.messageComponent,
|
|
1862
|
+
submitWidgetForm: (submit, fields, subOpts) => this.submitWidgetForm(submit, fields, subOpts)
|
|
1369
1863
|
});
|
|
1370
1864
|
this.consentState = { ...options.consent };
|
|
1371
1865
|
this.analyticsQueue = new AnalyticsQueue({
|
|
@@ -1427,10 +1921,16 @@ var XeniaClient = class {
|
|
|
1427
1921
|
* Persist a visitor id, attached to every subsequent event, and bind it to
|
|
1428
1922
|
* a known user server-side (`POST /api/events/v1/identify`, fire-and-
|
|
1429
1923
|
* forget — resolves to an `"identify"` client event carrying `personId`).
|
|
1924
|
+
*
|
|
1925
|
+
* `extra.customerId`/`extra.email`, when supplied, are also persisted
|
|
1926
|
+
* ambiently (like the visitor id) so they ride on every later `resolve()`
|
|
1927
|
+
* call automatically — part of the SDK-provided-fields parity list.
|
|
1430
1928
|
*/
|
|
1431
|
-
identify(visitorId) {
|
|
1929
|
+
identify(visitorId, extra) {
|
|
1432
1930
|
this.identity.identify(visitorId);
|
|
1433
|
-
|
|
1931
|
+
if (extra?.customerId !== void 0) this.identity.setCustomerId(extra.customerId);
|
|
1932
|
+
if (extra?.email !== void 0) this.identity.setEmail(extra.email);
|
|
1933
|
+
void this.api.identify({ xeniaVisitorId: visitorId, userId: extra?.customerId, email: extra?.email }).then((res) => this.emit("identify", res)).catch((err) => this.emitError(err));
|
|
1434
1934
|
}
|
|
1435
1935
|
/** Override the customer-journey UUID (else derived from utm_id). */
|
|
1436
1936
|
setJourneyId(id) {
|
|
@@ -1548,15 +2048,40 @@ var XeniaClient = class {
|
|
|
1548
2048
|
return this.identity.getVisitorId() ?? this.identity.getDeviceId() ?? void 0;
|
|
1549
2049
|
}
|
|
1550
2050
|
/**
|
|
1551
|
-
* Stamp the SDK-
|
|
1552
|
-
* `
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
2051
|
+
* Stamp the full set of SDK-provided fields into the resolve `data` —
|
|
2052
|
+
* `path`, `query`, `deviceId`, `visitorId`, `customerId`, `email`, `url`,
|
|
2053
|
+
* `referrer`, and the five `utm*` fields — the same field-name/type parity
|
|
2054
|
+
* the webhook trigger's "SDK-provided fields" already promises, now native
|
|
2055
|
+
* (always attached, no marketer toggle) for the Personalization Resolve
|
|
2056
|
+
* trigger too. Plus the reserved `_xenia_vid` key the backend reads for
|
|
2057
|
+
* holdout/exposure bucketing. All of these are spread LAST (after the
|
|
2058
|
+
* caller's own `data`) so none can be overridden per call — the same
|
|
2059
|
+
* override-protection guarantee `_xenia_vid` alone used to have, now
|
|
2060
|
+
* widened to the full set. A no-op (returns `opts` unchanged) when nothing
|
|
2061
|
+
* is resolvable yet (e.g. called before `init()` on a fresh visit with no
|
|
2062
|
+
* page context available).
|
|
1555
2063
|
*/
|
|
1556
2064
|
withVisitor(opts) {
|
|
2065
|
+
const ctx = this.identity.collectContext();
|
|
1557
2066
|
const vid = this.resolveVisitorId();
|
|
1558
|
-
|
|
1559
|
-
|
|
2067
|
+
const sdkFields = compact({
|
|
2068
|
+
path: ctx.path,
|
|
2069
|
+
query: ctx.query,
|
|
2070
|
+
deviceId: this.identity.getDeviceId() ?? void 0,
|
|
2071
|
+
visitorId: vid,
|
|
2072
|
+
customerId: this.identity.getCustomerId() ?? void 0,
|
|
2073
|
+
email: this.identity.getEmail() ?? void 0,
|
|
2074
|
+
url: ctx.url,
|
|
2075
|
+
referrer: ctx.referrer,
|
|
2076
|
+
utmSource: ctx.utmSource,
|
|
2077
|
+
utmMedium: ctx.utmMedium,
|
|
2078
|
+
utmCampaign: ctx.utmCampaign,
|
|
2079
|
+
utmContent: ctx.utmContent,
|
|
2080
|
+
utmId: ctx.utmId
|
|
2081
|
+
});
|
|
2082
|
+
if (vid) sdkFields._xenia_vid = vid;
|
|
2083
|
+
if (Object.keys(sdkFields).length === 0) return opts;
|
|
2084
|
+
return { ...opts, data: { ...opts?.data ?? {}, ...sdkFields } };
|
|
1560
2085
|
}
|
|
1561
2086
|
dntBlocked() {
|
|
1562
2087
|
if (this.options.respectDnt === false) return false;
|
|
@@ -1606,6 +2131,55 @@ var XeniaClient = class {
|
|
|
1606
2131
|
getResponse(key) {
|
|
1607
2132
|
return this.responses.get(key);
|
|
1608
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
|
+
}
|
|
1609
2183
|
// ── Imperative render helpers ─────────────────────────────────────────────────
|
|
1610
2184
|
swapContent(target, content) {
|
|
1611
2185
|
return swapContent(target, content);
|
|
@@ -1715,6 +2289,6 @@ function extractContentSwaps(body) {
|
|
|
1715
2289
|
return Array.isArray(swaps) ? swaps : void 0;
|
|
1716
2290
|
}
|
|
1717
2291
|
|
|
1718
|
-
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 };
|
|
1719
2293
|
//# sourceMappingURL=index.js.map
|
|
1720
2294
|
//# sourceMappingURL=index.js.map
|