veo-sdk 0.1.0
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/CONTRACT.md +144 -0
- package/README.md +141 -0
- package/dist/index.cjs +2536 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +238 -0
- package/dist/index.d.ts +238 -0
- package/dist/index.mjs +2528 -0
- package/dist/index.mjs.map +1 -0
- package/dist/veo.js +170 -0
- package/dist/veo.js.map +1 -0
- package/package.json +64 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2528 @@
|
|
|
1
|
+
import { computePosition, offset, flip, shift } from '@floating-ui/dom';
|
|
2
|
+
|
|
3
|
+
// src/contract/v1.ts
|
|
4
|
+
var SDK_VERSION = "0.0.1";
|
|
5
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
|
|
6
|
+
var DEFAULT_FLUSH_BATCH_SIZE = 20;
|
|
7
|
+
|
|
8
|
+
// src/utils/safe-env.ts
|
|
9
|
+
function hasWindow() {
|
|
10
|
+
return typeof window !== "undefined";
|
|
11
|
+
}
|
|
12
|
+
function hasDocument() {
|
|
13
|
+
return typeof document !== "undefined";
|
|
14
|
+
}
|
|
15
|
+
function hasLocalStorage() {
|
|
16
|
+
try {
|
|
17
|
+
if (typeof localStorage === "undefined") return false;
|
|
18
|
+
const testKey = "__veo_test__";
|
|
19
|
+
localStorage.setItem(testKey, "1");
|
|
20
|
+
localStorage.removeItem(testKey);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function hasNavigatorSendBeacon() {
|
|
27
|
+
return typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/utils/uuid.ts
|
|
31
|
+
function uuidv7() {
|
|
32
|
+
const timestamp = Date.now();
|
|
33
|
+
const timeHex = timestamp.toString(16).padStart(12, "0");
|
|
34
|
+
const random = new Uint8Array(10);
|
|
35
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
36
|
+
crypto.getRandomValues(random);
|
|
37
|
+
} else {
|
|
38
|
+
for (let i = 0; i < random.length; i++) {
|
|
39
|
+
random[i] = Math.floor(Math.random() * 256);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
random[0] = (random[0] ?? 0) & 15 | 112;
|
|
43
|
+
random[2] = (random[2] ?? 0) & 63 | 128;
|
|
44
|
+
const hex = Array.from(random, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
45
|
+
return [
|
|
46
|
+
timeHex.slice(0, 8),
|
|
47
|
+
timeHex.slice(8, 12),
|
|
48
|
+
hex.slice(0, 4),
|
|
49
|
+
hex.slice(4, 8),
|
|
50
|
+
hex.slice(8, 20)
|
|
51
|
+
].join("-");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/core/queue.ts
|
|
55
|
+
var RETRY_MAX_DELAY_MS = 3e4;
|
|
56
|
+
var Queue = class {
|
|
57
|
+
constructor(config) {
|
|
58
|
+
this.config = config;
|
|
59
|
+
this.buffer = [];
|
|
60
|
+
this.timer = null;
|
|
61
|
+
this.flushing = false;
|
|
62
|
+
this.retryCount = 0;
|
|
63
|
+
this.retryTimer = null;
|
|
64
|
+
this.startTimer();
|
|
65
|
+
}
|
|
66
|
+
/** Encola un evento; dispara flush si se alcanza `batchSize`. */
|
|
67
|
+
add(item) {
|
|
68
|
+
this.buffer.push(item);
|
|
69
|
+
if (this.buffer.length >= this.config.batchSize) {
|
|
70
|
+
void this.flush();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Flush manual vía `fetch`. Resuelve cuando termina (los retries son diferidos). */
|
|
74
|
+
async flush() {
|
|
75
|
+
if (this.flushing || this.buffer.length === 0) return;
|
|
76
|
+
this.flushing = true;
|
|
77
|
+
const batch = this.buffer.splice(0, this.buffer.length);
|
|
78
|
+
try {
|
|
79
|
+
await this.config.transport.send("/v1/events", { events: batch });
|
|
80
|
+
this.retryCount = 0;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.buffer.unshift(...batch);
|
|
83
|
+
this.scheduleRetry();
|
|
84
|
+
this.config.onError?.(error);
|
|
85
|
+
} finally {
|
|
86
|
+
this.flushing = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Flush síncrono vía `sendBeacon`, para `pagehide`/`visibilitychange`. */
|
|
90
|
+
flushBeacon() {
|
|
91
|
+
if (this.buffer.length === 0) return;
|
|
92
|
+
const batch = this.buffer.splice(0, this.buffer.length);
|
|
93
|
+
const success = this.config.transport.sendBeacon("/v1/events", { events: batch });
|
|
94
|
+
if (!success) {
|
|
95
|
+
this.buffer.unshift(...batch);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Detiene timers internos. Llamar al destruir el cliente. */
|
|
99
|
+
destroy() {
|
|
100
|
+
if (this.timer) clearInterval(this.timer);
|
|
101
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
102
|
+
this.timer = null;
|
|
103
|
+
this.retryTimer = null;
|
|
104
|
+
}
|
|
105
|
+
startTimer() {
|
|
106
|
+
this.timer = setInterval(() => {
|
|
107
|
+
void this.flush();
|
|
108
|
+
}, this.config.flushIntervalMs);
|
|
109
|
+
}
|
|
110
|
+
scheduleRetry() {
|
|
111
|
+
const maxRetries = this.config.maxRetries ?? 3;
|
|
112
|
+
if (this.retryCount >= maxRetries) {
|
|
113
|
+
this.retryCount = 0;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.retryCount++;
|
|
117
|
+
const delayMs = Math.min(1e3 * 2 ** (this.retryCount - 1), RETRY_MAX_DELAY_MS);
|
|
118
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
119
|
+
this.retryTimer = setTimeout(() => {
|
|
120
|
+
void this.flush();
|
|
121
|
+
}, delayMs);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/core/session.ts
|
|
126
|
+
var ANONYMOUS_ID_KEY = "anonymous_user_id";
|
|
127
|
+
var END_USER_ID_KEY = "end_user_id";
|
|
128
|
+
var SESSION_ID_KEY = "session_id";
|
|
129
|
+
var SESSION_LAST_ACTIVE_KEY = "session_last_active";
|
|
130
|
+
var SESSION_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
131
|
+
var Session = class {
|
|
132
|
+
constructor(storage) {
|
|
133
|
+
this.storage = storage;
|
|
134
|
+
this._endUserId = null;
|
|
135
|
+
this._anonymousId = this.loadOrCreateAnonymousId();
|
|
136
|
+
this._sessionId = this.loadOrCreateSession();
|
|
137
|
+
this._endUserId = this.storage.get(END_USER_ID_KEY);
|
|
138
|
+
}
|
|
139
|
+
get anonymousId() {
|
|
140
|
+
return this._anonymousId;
|
|
141
|
+
}
|
|
142
|
+
get sessionId() {
|
|
143
|
+
return this._sessionId;
|
|
144
|
+
}
|
|
145
|
+
get endUserId() {
|
|
146
|
+
return this._endUserId;
|
|
147
|
+
}
|
|
148
|
+
/** ID a usar como visitor en eventos (end_user_id si está identificado, si no anonymous). */
|
|
149
|
+
get effectiveUserId() {
|
|
150
|
+
return this._endUserId ?? this._anonymousId;
|
|
151
|
+
}
|
|
152
|
+
setEndUserId(id) {
|
|
153
|
+
this._endUserId = id;
|
|
154
|
+
this.storage.set(END_USER_ID_KEY, id);
|
|
155
|
+
}
|
|
156
|
+
/** Renueva el timestamp de actividad. Si pasaron > 30 min, genera nueva sesión. */
|
|
157
|
+
touch() {
|
|
158
|
+
const lastActive = this.storage.get(SESSION_LAST_ACTIVE_KEY);
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (lastActive && now - Number.parseInt(lastActive, 10) > SESSION_TIMEOUT_MS) {
|
|
161
|
+
this._sessionId = uuidv7();
|
|
162
|
+
this.storage.set(SESSION_ID_KEY, this._sessionId);
|
|
163
|
+
}
|
|
164
|
+
this.storage.set(SESSION_LAST_ACTIVE_KEY, String(now));
|
|
165
|
+
}
|
|
166
|
+
/** Limpia la identidad (reset) pero conserva el anonymous_id. */
|
|
167
|
+
reset() {
|
|
168
|
+
this._endUserId = null;
|
|
169
|
+
this.storage.remove(END_USER_ID_KEY);
|
|
170
|
+
this._sessionId = uuidv7();
|
|
171
|
+
this.storage.set(SESSION_ID_KEY, this._sessionId);
|
|
172
|
+
this.storage.set(SESSION_LAST_ACTIVE_KEY, String(Date.now()));
|
|
173
|
+
}
|
|
174
|
+
loadOrCreateAnonymousId() {
|
|
175
|
+
let id = this.storage.get(ANONYMOUS_ID_KEY);
|
|
176
|
+
if (!id) {
|
|
177
|
+
id = uuidv7();
|
|
178
|
+
this.storage.set(ANONYMOUS_ID_KEY, id);
|
|
179
|
+
}
|
|
180
|
+
return id;
|
|
181
|
+
}
|
|
182
|
+
loadOrCreateSession() {
|
|
183
|
+
const existing = this.storage.get(SESSION_ID_KEY);
|
|
184
|
+
const lastActive = this.storage.get(SESSION_LAST_ACTIVE_KEY);
|
|
185
|
+
if (existing && lastActive) {
|
|
186
|
+
const elapsed = Date.now() - Number.parseInt(lastActive, 10);
|
|
187
|
+
if (elapsed < SESSION_TIMEOUT_MS) {
|
|
188
|
+
return existing;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const newId = uuidv7();
|
|
192
|
+
this.storage.set(SESSION_ID_KEY, newId);
|
|
193
|
+
this.storage.set(SESSION_LAST_ACTIVE_KEY, String(Date.now()));
|
|
194
|
+
return newId;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// src/core/storage.ts
|
|
199
|
+
var STORAGE_PREFIX = "veo:";
|
|
200
|
+
var ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
201
|
+
var LocalStorageAdapter = class {
|
|
202
|
+
get(key) {
|
|
203
|
+
return localStorage.getItem(STORAGE_PREFIX + key);
|
|
204
|
+
}
|
|
205
|
+
set(key, value) {
|
|
206
|
+
try {
|
|
207
|
+
localStorage.setItem(STORAGE_PREFIX + key, value);
|
|
208
|
+
} catch {
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
remove(key) {
|
|
212
|
+
localStorage.removeItem(STORAGE_PREFIX + key);
|
|
213
|
+
}
|
|
214
|
+
clear() {
|
|
215
|
+
const toRemove = [];
|
|
216
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
217
|
+
const key = localStorage.key(i);
|
|
218
|
+
if (key?.startsWith(STORAGE_PREFIX)) toRemove.push(key);
|
|
219
|
+
}
|
|
220
|
+
for (const key of toRemove) localStorage.removeItem(key);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
function readCookies() {
|
|
224
|
+
const raw = document.cookie;
|
|
225
|
+
return raw === "" ? [] : raw.split("; ");
|
|
226
|
+
}
|
|
227
|
+
var CookieAdapter = class {
|
|
228
|
+
get(key) {
|
|
229
|
+
const name = `${STORAGE_PREFIX}${key}=`;
|
|
230
|
+
for (const cookie of readCookies()) {
|
|
231
|
+
if (cookie.startsWith(name)) {
|
|
232
|
+
return decodeURIComponent(cookie.slice(name.length));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
set(key, value, options) {
|
|
238
|
+
const maxAgeSec = Math.floor((options?.ttlMs ?? ONE_YEAR_MS) / 1e3);
|
|
239
|
+
const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
|
|
240
|
+
document.cookie = `${STORAGE_PREFIX}${key}=${encodeURIComponent(
|
|
241
|
+
value
|
|
242
|
+
)}; Path=/; Max-Age=${maxAgeSec}; SameSite=Lax${secure}`;
|
|
243
|
+
}
|
|
244
|
+
remove(key) {
|
|
245
|
+
document.cookie = `${STORAGE_PREFIX}${key}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
246
|
+
}
|
|
247
|
+
clear() {
|
|
248
|
+
for (const cookie of readCookies()) {
|
|
249
|
+
const eq = cookie.indexOf("=");
|
|
250
|
+
const name = eq === -1 ? cookie : cookie.slice(0, eq);
|
|
251
|
+
if (name.startsWith(STORAGE_PREFIX)) {
|
|
252
|
+
this.remove(name.slice(STORAGE_PREFIX.length));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
var MemoryAdapter = class {
|
|
258
|
+
constructor() {
|
|
259
|
+
this.store = /* @__PURE__ */ new Map();
|
|
260
|
+
}
|
|
261
|
+
get(key) {
|
|
262
|
+
return this.store.get(key) ?? null;
|
|
263
|
+
}
|
|
264
|
+
set(key, value) {
|
|
265
|
+
this.store.set(key, value);
|
|
266
|
+
}
|
|
267
|
+
remove(key) {
|
|
268
|
+
this.store.delete(key);
|
|
269
|
+
}
|
|
270
|
+
clear() {
|
|
271
|
+
this.store.clear();
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
function createStorage(preferred = "localStorage") {
|
|
275
|
+
if (preferred === "localStorage" && hasLocalStorage()) return new LocalStorageAdapter();
|
|
276
|
+
if (preferred === "cookie" && hasDocument()) return new CookieAdapter();
|
|
277
|
+
if (preferred === "memory") return new MemoryAdapter();
|
|
278
|
+
if (hasLocalStorage()) return new LocalStorageAdapter();
|
|
279
|
+
if (hasDocument()) return new CookieAdapter();
|
|
280
|
+
return new MemoryAdapter();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/core/transport.ts
|
|
284
|
+
var TransportError = class extends Error {
|
|
285
|
+
constructor(message, statusCode) {
|
|
286
|
+
super(message);
|
|
287
|
+
this.statusCode = statusCode;
|
|
288
|
+
this.name = "TransportError";
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
var Transport = class {
|
|
292
|
+
constructor(config) {
|
|
293
|
+
this.config = config;
|
|
294
|
+
}
|
|
295
|
+
/** Envío normal con `fetch`. Lanza `TransportError` si la respuesta no es OK. */
|
|
296
|
+
async send(path, body) {
|
|
297
|
+
const url = `${this.config.apiUrl}${path}`;
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 1e4);
|
|
300
|
+
try {
|
|
301
|
+
const response = await fetch(url, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: {
|
|
304
|
+
"Content-Type": "application/json",
|
|
305
|
+
"X-Api-Key": this.config.apiKey,
|
|
306
|
+
"X-Sdk-Version": this.config.sdkVersion
|
|
307
|
+
},
|
|
308
|
+
body: JSON.stringify(body),
|
|
309
|
+
keepalive: true,
|
|
310
|
+
signal: controller.signal
|
|
311
|
+
});
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
throw new TransportError(`HTTP ${response.status}`, response.status);
|
|
314
|
+
}
|
|
315
|
+
return await response.json();
|
|
316
|
+
} finally {
|
|
317
|
+
clearTimeout(timeoutId);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Envío con `navigator.sendBeacon`. Fire-and-forget, pensado para `pagehide`.
|
|
322
|
+
* Como sendBeacon no admite headers, la API key viaja por query string.
|
|
323
|
+
*/
|
|
324
|
+
sendBeacon(path, body) {
|
|
325
|
+
if (!hasNavigatorSendBeacon()) return false;
|
|
326
|
+
const url = new URL(`${this.config.apiUrl}${path}`);
|
|
327
|
+
url.searchParams.set("key", this.config.apiKey);
|
|
328
|
+
url.searchParams.set("v", this.config.sdkVersion);
|
|
329
|
+
const blob = new Blob([JSON.stringify(body)], { type: "application/json" });
|
|
330
|
+
return navigator.sendBeacon(url.toString(), blob);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// src/core/client.ts
|
|
335
|
+
var ORGANIZATION_ID_KEY = "organization_id";
|
|
336
|
+
var END_USER_ID_KEY2 = "end_user_id";
|
|
337
|
+
var IDENTIFY_SIG_KEY = "identify_signature";
|
|
338
|
+
function stableStringify(value) {
|
|
339
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
340
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
341
|
+
const entries = Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`);
|
|
342
|
+
return `{${entries.join(",")}}`;
|
|
343
|
+
}
|
|
344
|
+
var Client = class {
|
|
345
|
+
constructor(config) {
|
|
346
|
+
this.config = config;
|
|
347
|
+
this.plugins = [];
|
|
348
|
+
this.currentOrganizationId = null;
|
|
349
|
+
this.listeners = {
|
|
350
|
+
pageview: /* @__PURE__ */ new Set(),
|
|
351
|
+
identify: /* @__PURE__ */ new Set()
|
|
352
|
+
};
|
|
353
|
+
if (!config.apiKey) throw new Error("apiKey is required");
|
|
354
|
+
if (!config.apiUrl) throw new Error("apiUrl is required");
|
|
355
|
+
this.storage = createStorage(config.storage ?? "localStorage");
|
|
356
|
+
this.session = new Session(this.storage);
|
|
357
|
+
this.transport = new Transport({
|
|
358
|
+
apiKey: config.apiKey,
|
|
359
|
+
apiUrl: config.apiUrl.replace(/\/$/, ""),
|
|
360
|
+
sdkVersion: SDK_VERSION
|
|
361
|
+
});
|
|
362
|
+
this.queue = new Queue({
|
|
363
|
+
transport: this.transport,
|
|
364
|
+
flushIntervalMs: config.flushInterval ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
365
|
+
batchSize: config.flushBatchSize ?? DEFAULT_FLUSH_BATCH_SIZE,
|
|
366
|
+
onError: (err) => {
|
|
367
|
+
if (config.debug) console.error("[veo] flush error:", err);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
const persistedUserId = this.storage.get(END_USER_ID_KEY2);
|
|
371
|
+
if (persistedUserId) this.session.setEndUserId(persistedUserId);
|
|
372
|
+
const persistedOrgId = this.storage.get(ORGANIZATION_ID_KEY);
|
|
373
|
+
if (persistedOrgId) this.currentOrganizationId = persistedOrgId;
|
|
374
|
+
this.setupUnloadHandlers();
|
|
375
|
+
if (config.debug) {
|
|
376
|
+
console.log("[veo] initialized", {
|
|
377
|
+
anonymousId: this.session.anonymousId,
|
|
378
|
+
sessionId: this.session.sessionId,
|
|
379
|
+
endUserId: this.session.endUserId
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
identify(payload) {
|
|
384
|
+
const { visitor, organization } = payload;
|
|
385
|
+
const signature = stableStringify({
|
|
386
|
+
id: visitor.id,
|
|
387
|
+
traits: visitor.traits ?? {},
|
|
388
|
+
org: organization ? { id: organization.id, attributes: organization.attributes ?? {} } : null
|
|
389
|
+
});
|
|
390
|
+
if (this.session.endUserId === visitor.id && this.storage.get(IDENTIFY_SIG_KEY) === signature) {
|
|
391
|
+
if (this.config.debug) console.debug("[veo] identify: sin cambios, omitido");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const previousAnonymousId = this.session.endUserId === null ? this.session.anonymousId : void 0;
|
|
395
|
+
this.session.setEndUserId(visitor.id);
|
|
396
|
+
if (organization) {
|
|
397
|
+
this.currentOrganizationId = organization.id;
|
|
398
|
+
this.storage.set(ORGANIZATION_ID_KEY, organization.id);
|
|
399
|
+
}
|
|
400
|
+
const endUser = {
|
|
401
|
+
id: visitor.id,
|
|
402
|
+
traits: visitor.traits ?? {},
|
|
403
|
+
...previousAnonymousId !== void 0 ? { anonymousId: previousAnonymousId } : {}
|
|
404
|
+
};
|
|
405
|
+
const body = {
|
|
406
|
+
endUser,
|
|
407
|
+
...organization ? { organization: { id: organization.id, attributes: organization.attributes ?? {} } } : {}
|
|
408
|
+
};
|
|
409
|
+
void this.transport.send("/v1/identify", body).catch((err) => {
|
|
410
|
+
if (this.config.debug) console.error("[veo] identify failed:", err);
|
|
411
|
+
});
|
|
412
|
+
this.enqueue({
|
|
413
|
+
actionType: "identify",
|
|
414
|
+
actionProperties: {
|
|
415
|
+
traits: visitor.traits,
|
|
416
|
+
organizationAttributes: organization?.attributes
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
this.storage.set(IDENTIFY_SIG_KEY, signature);
|
|
420
|
+
this.emit("identify", { endUserId: visitor.id, sessionId: this.session.sessionId });
|
|
421
|
+
}
|
|
422
|
+
track(name, properties) {
|
|
423
|
+
if (!name || typeof name !== "string") {
|
|
424
|
+
if (this.config.debug) console.error("[veo] track: name is required");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
this.enqueue({
|
|
428
|
+
actionType: "track",
|
|
429
|
+
actionName: name,
|
|
430
|
+
...properties !== void 0 ? { actionProperties: properties } : {}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
pageview(path) {
|
|
434
|
+
if (!hasDocument()) return;
|
|
435
|
+
const url = window.location.href;
|
|
436
|
+
const pagePath = path ?? window.location.pathname;
|
|
437
|
+
this.enqueue({
|
|
438
|
+
actionType: "pageview",
|
|
439
|
+
pageUrl: url,
|
|
440
|
+
pagePath,
|
|
441
|
+
pageTitle: document.title,
|
|
442
|
+
pageReferrer: document.referrer
|
|
443
|
+
});
|
|
444
|
+
this.emit("pageview", {
|
|
445
|
+
url,
|
|
446
|
+
path: pagePath,
|
|
447
|
+
sessionId: this.session.sessionId,
|
|
448
|
+
endUserId: this.session.endUserId
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
reset() {
|
|
452
|
+
this.session.reset();
|
|
453
|
+
this.currentOrganizationId = null;
|
|
454
|
+
this.storage.remove(ORGANIZATION_ID_KEY);
|
|
455
|
+
this.storage.remove(END_USER_ID_KEY2);
|
|
456
|
+
this.storage.remove(IDENTIFY_SIG_KEY);
|
|
457
|
+
}
|
|
458
|
+
flush() {
|
|
459
|
+
return this.queue.flush();
|
|
460
|
+
}
|
|
461
|
+
use(plugin) {
|
|
462
|
+
this.plugins.push(plugin);
|
|
463
|
+
plugin.install(this);
|
|
464
|
+
return this;
|
|
465
|
+
}
|
|
466
|
+
getEndUserId() {
|
|
467
|
+
return this.session.endUserId;
|
|
468
|
+
}
|
|
469
|
+
getSessionId() {
|
|
470
|
+
return this.session.sessionId;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Suscribe `listener` a `event`. Devuelve una función para desuscribir.
|
|
474
|
+
* Los errores en listeners se logean en debug mode pero NO se propagan
|
|
475
|
+
* (un plugin no debe poder romper a otro).
|
|
476
|
+
*/
|
|
477
|
+
on(event, listener) {
|
|
478
|
+
this.listeners[event].add(listener);
|
|
479
|
+
return () => {
|
|
480
|
+
this.listeners[event].delete(listener);
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
emit(event, payload) {
|
|
484
|
+
for (const listener of this.listeners[event]) {
|
|
485
|
+
try {
|
|
486
|
+
listener(payload);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
if (this.config.debug) console.error(`[veo] listener for '${event}' failed:`, err);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Construye el evento completo y lo agrega a la queue. Omite los campos
|
|
494
|
+
* opcionales no definidos (exigido por `exactOptionalPropertyTypes`).
|
|
495
|
+
* @internal
|
|
496
|
+
*/
|
|
497
|
+
enqueue(input) {
|
|
498
|
+
this.session.touch();
|
|
499
|
+
const organizationId = this.currentOrganizationId ?? void 0;
|
|
500
|
+
const pageUrl = input.pageUrl ?? (hasWindow() ? window.location.href : void 0);
|
|
501
|
+
const pagePath = input.pagePath ?? (hasWindow() ? window.location.pathname : void 0);
|
|
502
|
+
const pageTitle = input.pageTitle ?? (hasDocument() ? document.title : void 0);
|
|
503
|
+
const pageReferrer = input.pageReferrer ?? (hasDocument() ? document.referrer : void 0);
|
|
504
|
+
const event = {
|
|
505
|
+
actionId: uuidv7(),
|
|
506
|
+
endUserId: this.session.effectiveUserId,
|
|
507
|
+
sessionId: this.session.sessionId,
|
|
508
|
+
actionType: input.actionType,
|
|
509
|
+
occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
510
|
+
...organizationId !== void 0 ? { organizationId } : {},
|
|
511
|
+
...input.actionName !== void 0 ? { actionName: input.actionName } : {},
|
|
512
|
+
...pageUrl !== void 0 ? { pageUrl } : {},
|
|
513
|
+
...pagePath !== void 0 ? { pagePath } : {},
|
|
514
|
+
...pageTitle !== void 0 ? { pageTitle } : {},
|
|
515
|
+
...pageReferrer !== void 0 ? { pageReferrer } : {},
|
|
516
|
+
...input.actionProperties !== void 0 ? { actionProperties: input.actionProperties } : {}
|
|
517
|
+
};
|
|
518
|
+
this.queue.add(event);
|
|
519
|
+
}
|
|
520
|
+
setupUnloadHandlers() {
|
|
521
|
+
if (!hasWindow()) return;
|
|
522
|
+
window.addEventListener("pagehide", () => this.queue.flushBeacon());
|
|
523
|
+
window.addEventListener("visibilitychange", () => {
|
|
524
|
+
if (document.visibilityState === "hidden") {
|
|
525
|
+
this.queue.flushBeacon();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// src/plugins/plugin.ts
|
|
532
|
+
function definePlugin(plugin) {
|
|
533
|
+
return plugin;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/plugins/autocapture/constants.ts
|
|
537
|
+
var DEFAULT_AUTOCAPTURE_CONFIG = {
|
|
538
|
+
enabled: true,
|
|
539
|
+
captureClicks: true,
|
|
540
|
+
captureSubmits: true,
|
|
541
|
+
captureChanges: true,
|
|
542
|
+
maskAllInputs: true,
|
|
543
|
+
maskAllText: false,
|
|
544
|
+
throttleMs: 100,
|
|
545
|
+
maxTextLength: 200,
|
|
546
|
+
captureElementMetadata: true,
|
|
547
|
+
captureAncestors: 5
|
|
548
|
+
};
|
|
549
|
+
var MAX_ANCESTORS = 10;
|
|
550
|
+
var ALWAYS_BLOCK_SELECTORS = [
|
|
551
|
+
"[data-veo-ignore]",
|
|
552
|
+
".veo-ignore",
|
|
553
|
+
// Inputs sensibles
|
|
554
|
+
'input[type="password"]',
|
|
555
|
+
'input[type="email"]',
|
|
556
|
+
'input[type="tel"]',
|
|
557
|
+
'input[type="hidden"]',
|
|
558
|
+
// Datos de tarjeta
|
|
559
|
+
'[autocomplete="cc-number"]',
|
|
560
|
+
'[autocomplete="cc-csc"]',
|
|
561
|
+
'[autocomplete="cc-exp"]',
|
|
562
|
+
'[autocomplete="cc-name"]',
|
|
563
|
+
// Otros campos sensibles típicos
|
|
564
|
+
'[name*="password" i]',
|
|
565
|
+
'[name*="ssn" i]',
|
|
566
|
+
'[name*="credit" i]',
|
|
567
|
+
'[name*="card" i]',
|
|
568
|
+
'[id*="password" i]',
|
|
569
|
+
'[id*="ssn" i]'
|
|
570
|
+
];
|
|
571
|
+
var SENSITIVE_ATTRIBUTES = ["value", "data-credit-card", "data-ssn", "data-password"];
|
|
572
|
+
var PRIORITY_ATTRIBUTES = [
|
|
573
|
+
"id",
|
|
574
|
+
"name",
|
|
575
|
+
"type",
|
|
576
|
+
"role",
|
|
577
|
+
"href",
|
|
578
|
+
"data-action",
|
|
579
|
+
"data-veo-tag",
|
|
580
|
+
"data-testid",
|
|
581
|
+
"aria-label",
|
|
582
|
+
"aria-labelledby"
|
|
583
|
+
];
|
|
584
|
+
var HASHED_CLASS_PATTERNS = [
|
|
585
|
+
/^css-[a-z0-9]{4,}$/i,
|
|
586
|
+
// emotion, styled-components nuevas
|
|
587
|
+
/^_[a-z0-9]+_[a-z0-9]{4,}_\d+$/,
|
|
588
|
+
// CSS Modules con hash
|
|
589
|
+
/^[a-z]{2,4}-[a-z0-9]{6,}$/i,
|
|
590
|
+
// Tailwind JIT (a veces)
|
|
591
|
+
/^[a-f0-9]{6,}$/i,
|
|
592
|
+
// hashes hex puros
|
|
593
|
+
/^sc-[a-z0-9]+$/i
|
|
594
|
+
// styled-components legacy
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
// src/plugins/autocapture/pii-guard.ts
|
|
598
|
+
function isElementBlocked(element, extraBlockSelectors = []) {
|
|
599
|
+
const allBlockers = [...ALWAYS_BLOCK_SELECTORS, ...extraBlockSelectors];
|
|
600
|
+
let current = element;
|
|
601
|
+
while (current && current !== document.documentElement) {
|
|
602
|
+
for (const selector of allBlockers) {
|
|
603
|
+
try {
|
|
604
|
+
if (current.matches(selector)) return true;
|
|
605
|
+
} catch {
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
current = current.parentElement;
|
|
609
|
+
}
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
function isSensitiveAttribute(attrName) {
|
|
613
|
+
const lower = attrName.toLowerCase();
|
|
614
|
+
return SENSITIVE_ATTRIBUTES.includes(lower) || /password|secret|token|ssn|credit/i.test(attrName);
|
|
615
|
+
}
|
|
616
|
+
function safeGetText(element, maxLength, mask) {
|
|
617
|
+
if (mask) return "";
|
|
618
|
+
const tagName = element.tagName.toLowerCase();
|
|
619
|
+
if (tagName === "input" || tagName === "textarea") return "";
|
|
620
|
+
const raw = (element instanceof HTMLElement ? element.innerText : "") || element.textContent || "";
|
|
621
|
+
const trimmed = raw.trim().replace(/\s+/g, " ");
|
|
622
|
+
return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength)}\u2026` : trimmed;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/plugins/autocapture/selector-builder.ts
|
|
626
|
+
function isHashedClass(className) {
|
|
627
|
+
return HASHED_CLASS_PATTERNS.some((pattern) => pattern.test(className));
|
|
628
|
+
}
|
|
629
|
+
function filterHumanClasses(element) {
|
|
630
|
+
return Array.from(element.classList).filter((c) => !isHashedClass(c) && c.length < 64);
|
|
631
|
+
}
|
|
632
|
+
var STABLE_ATTRIBUTES = [
|
|
633
|
+
"data-testid",
|
|
634
|
+
"data-test",
|
|
635
|
+
"data-cy",
|
|
636
|
+
"name",
|
|
637
|
+
"aria-label",
|
|
638
|
+
"placeholder",
|
|
639
|
+
"alt",
|
|
640
|
+
"title",
|
|
641
|
+
// `href` identifica muy bien a los links (`<a>`), un target frecuente de
|
|
642
|
+
// autocapture; va después de los atributos semánticos pero antes de los
|
|
643
|
+
// genéricos. Valores largos (URLs absolutas con query) se descartan por
|
|
644
|
+
// MAX_ATTR_VALUE_LENGTH.
|
|
645
|
+
"href",
|
|
646
|
+
"role",
|
|
647
|
+
"type"
|
|
648
|
+
];
|
|
649
|
+
var MAX_ATTR_VALUE_LENGTH = 100;
|
|
650
|
+
function cssEscape(value) {
|
|
651
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
652
|
+
return CSS.escape(value);
|
|
653
|
+
}
|
|
654
|
+
return value.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "\\$&");
|
|
655
|
+
}
|
|
656
|
+
function escapeAttrValue(value) {
|
|
657
|
+
return value.replace(/[\\"]/g, "\\$&");
|
|
658
|
+
}
|
|
659
|
+
function stableAttributeSelector(element, tag) {
|
|
660
|
+
for (const attr of STABLE_ATTRIBUTES) {
|
|
661
|
+
const value = element.getAttribute(attr);
|
|
662
|
+
if (value && value.length > 0 && value.length <= MAX_ATTR_VALUE_LENGTH) {
|
|
663
|
+
return `${tag}[${attr}="${escapeAttrValue(value)}"]`;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
function buildElementSelector(element) {
|
|
669
|
+
const tag = element.tagName.toLowerCase();
|
|
670
|
+
const veoTag = element.getAttribute("data-veo-tag");
|
|
671
|
+
if (veoTag) {
|
|
672
|
+
return `${tag}[data-veo-tag="${escapeAttrValue(veoTag)}"]`;
|
|
673
|
+
}
|
|
674
|
+
if (element.id) {
|
|
675
|
+
const classes2 = filterHumanClasses(element);
|
|
676
|
+
const classPart2 = classes2.length > 0 ? `.${classes2.map(cssEscape).join(".")}` : "";
|
|
677
|
+
return `${tag}#${cssEscape(element.id)}${classPart2}`;
|
|
678
|
+
}
|
|
679
|
+
const attrSelector = stableAttributeSelector(element, tag);
|
|
680
|
+
if (attrSelector) return attrSelector;
|
|
681
|
+
const classes = filterHumanClasses(element);
|
|
682
|
+
const classPart = classes.length > 0 ? `.${classes.map(cssEscape).join(".")}` : "";
|
|
683
|
+
return `${tag}${classPart}`;
|
|
684
|
+
}
|
|
685
|
+
function buildSelectorPath(element, maxAncestors = 5) {
|
|
686
|
+
const parts = [];
|
|
687
|
+
let current = element;
|
|
688
|
+
let depth = 0;
|
|
689
|
+
while (current && current !== document.documentElement && depth <= maxAncestors) {
|
|
690
|
+
parts.unshift(buildElementSelector(current));
|
|
691
|
+
current = current.parentElement;
|
|
692
|
+
depth++;
|
|
693
|
+
}
|
|
694
|
+
return parts.join(" > ");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/plugins/autocapture/element-serializer.ts
|
|
698
|
+
function extractPriorityAttributes(element) {
|
|
699
|
+
const result = {};
|
|
700
|
+
for (const attrName of PRIORITY_ATTRIBUTES) {
|
|
701
|
+
const value = element.getAttribute(attrName);
|
|
702
|
+
if (value !== null && !isSensitiveAttribute(attrName) && value.length < 256) {
|
|
703
|
+
result[attrName] = value;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
function serializeElement(element, config) {
|
|
709
|
+
const rect = element.getBoundingClientRect();
|
|
710
|
+
return {
|
|
711
|
+
tag: element.tagName.toLowerCase(),
|
|
712
|
+
id: element.id || null,
|
|
713
|
+
classes: filterHumanClasses(element),
|
|
714
|
+
text: safeGetText(element, config.maxTextLength, config.maskAllText),
|
|
715
|
+
attributes: config.captureElementMetadata ? extractPriorityAttributes(element) : {},
|
|
716
|
+
position: {
|
|
717
|
+
x: Math.round(rect.left + window.scrollX),
|
|
718
|
+
y: Math.round(rect.top + window.scrollY),
|
|
719
|
+
w: Math.round(rect.width),
|
|
720
|
+
h: Math.round(rect.height)
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function serializeAncestors(element, maxDepth) {
|
|
725
|
+
const ancestors = [];
|
|
726
|
+
let current = element.parentElement;
|
|
727
|
+
let depth = 0;
|
|
728
|
+
while (current && current !== document.documentElement && depth < maxDepth) {
|
|
729
|
+
ancestors.push({
|
|
730
|
+
tag: current.tagName.toLowerCase(),
|
|
731
|
+
id: current.id || null,
|
|
732
|
+
classes: filterHumanClasses(current),
|
|
733
|
+
veoTag: current.getAttribute("data-veo-tag") || null
|
|
734
|
+
});
|
|
735
|
+
current = current.parentElement;
|
|
736
|
+
depth++;
|
|
737
|
+
}
|
|
738
|
+
return ancestors;
|
|
739
|
+
}
|
|
740
|
+
function buildAutocapturePayload(eventType, element, config) {
|
|
741
|
+
return {
|
|
742
|
+
$event_type: eventType,
|
|
743
|
+
$element: serializeElement(element, config),
|
|
744
|
+
$ancestors: serializeAncestors(element, config.captureAncestors),
|
|
745
|
+
$selector_path: buildSelectorPath(element, config.captureAncestors),
|
|
746
|
+
$page_url: window.location.href,
|
|
747
|
+
$page_path: window.location.pathname
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/plugins/autocapture/throttle.ts
|
|
752
|
+
var ElementThrottle = class {
|
|
753
|
+
constructor(windowMs) {
|
|
754
|
+
this.windowMs = windowMs;
|
|
755
|
+
this.lastFired = /* @__PURE__ */ new WeakMap();
|
|
756
|
+
}
|
|
757
|
+
/** Devuelve true si el elemento puede emitir ahora (y registra el disparo). */
|
|
758
|
+
shouldFire(element) {
|
|
759
|
+
const now = Date.now();
|
|
760
|
+
const last = this.lastFired.get(element) ?? 0;
|
|
761
|
+
if (now - last < this.windowMs) return false;
|
|
762
|
+
this.lastFired.set(element, now);
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// src/plugins/autocapture/dom-listener.ts
|
|
768
|
+
var DomListener = class {
|
|
769
|
+
constructor(client, config) {
|
|
770
|
+
this.client = client;
|
|
771
|
+
this.cleanups = [];
|
|
772
|
+
const d = DEFAULT_AUTOCAPTURE_CONFIG;
|
|
773
|
+
this.config = {
|
|
774
|
+
enabled: config.enabled ?? d.enabled,
|
|
775
|
+
captureClicks: config.captureClicks ?? d.captureClicks,
|
|
776
|
+
captureSubmits: config.captureSubmits ?? d.captureSubmits,
|
|
777
|
+
captureChanges: config.captureChanges ?? d.captureChanges,
|
|
778
|
+
maskAllInputs: config.maskAllInputs ?? d.maskAllInputs,
|
|
779
|
+
maskAllText: config.maskAllText ?? d.maskAllText,
|
|
780
|
+
blockSelectors: config.blockSelectors ?? [],
|
|
781
|
+
throttleMs: config.throttleMs ?? d.throttleMs,
|
|
782
|
+
maxTextLength: config.maxTextLength ?? d.maxTextLength,
|
|
783
|
+
captureElementMetadata: config.captureElementMetadata ?? d.captureElementMetadata,
|
|
784
|
+
captureAncestors: Math.min(config.captureAncestors ?? d.captureAncestors, MAX_ANCESTORS)
|
|
785
|
+
};
|
|
786
|
+
this.throttle = new ElementThrottle(this.config.throttleMs);
|
|
787
|
+
}
|
|
788
|
+
install() {
|
|
789
|
+
if (!hasDocument() || !this.config.enabled) return;
|
|
790
|
+
if (this.config.captureClicks) this.attach("click", (e) => this.handleClick(e));
|
|
791
|
+
if (this.config.captureSubmits) this.attach("submit", (e) => this.handleSubmit(e), true);
|
|
792
|
+
if (this.config.captureChanges) this.attach("change", (e) => this.handleChange(e), true);
|
|
793
|
+
}
|
|
794
|
+
destroy() {
|
|
795
|
+
for (const cleanup of this.cleanups) cleanup();
|
|
796
|
+
this.cleanups = [];
|
|
797
|
+
}
|
|
798
|
+
attach(eventName, handler, useCapture = false) {
|
|
799
|
+
const listener = (e) => {
|
|
800
|
+
if (!e.isTrusted) return;
|
|
801
|
+
try {
|
|
802
|
+
handler(e);
|
|
803
|
+
} catch {
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
document.addEventListener(eventName, listener, useCapture);
|
|
807
|
+
this.cleanups.push(() => document.removeEventListener(eventName, listener, useCapture));
|
|
808
|
+
}
|
|
809
|
+
handleClick(event) {
|
|
810
|
+
const target = event.target;
|
|
811
|
+
if (!(target instanceof Element)) return;
|
|
812
|
+
const meaningful = this.findMeaningfulAncestor(target);
|
|
813
|
+
if (!meaningful) return;
|
|
814
|
+
if (isElementBlocked(meaningful, this.config.blockSelectors)) return;
|
|
815
|
+
if (!this.throttle.shouldFire(meaningful)) return;
|
|
816
|
+
this.emit("click", meaningful);
|
|
817
|
+
}
|
|
818
|
+
handleSubmit(event) {
|
|
819
|
+
const target = event.target;
|
|
820
|
+
if (!(target instanceof Element) || target.tagName.toLowerCase() !== "form") return;
|
|
821
|
+
if (isElementBlocked(target, this.config.blockSelectors)) return;
|
|
822
|
+
if (!this.throttle.shouldFire(target)) return;
|
|
823
|
+
this.emit("submit", target);
|
|
824
|
+
}
|
|
825
|
+
handleChange(event) {
|
|
826
|
+
const target = event.target;
|
|
827
|
+
if (!(target instanceof Element)) return;
|
|
828
|
+
const tag = target.tagName.toLowerCase();
|
|
829
|
+
if (tag !== "select" && tag !== "input" && tag !== "textarea") return;
|
|
830
|
+
if (target instanceof HTMLInputElement && target.type !== "checkbox" && target.type !== "radio") {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (isElementBlocked(target, this.config.blockSelectors)) return;
|
|
834
|
+
if (!this.throttle.shouldFire(target)) return;
|
|
835
|
+
this.emit("change", target);
|
|
836
|
+
}
|
|
837
|
+
/** Busca hacia arriba un ancestro "interactivo". Devuelve null si no hay ninguno. */
|
|
838
|
+
findMeaningfulAncestor(element) {
|
|
839
|
+
let current = element;
|
|
840
|
+
let depth = 0;
|
|
841
|
+
while (current && current !== document.documentElement && depth < 5) {
|
|
842
|
+
const tag = current.tagName.toLowerCase();
|
|
843
|
+
const isInteractive = tag === "button" || tag === "a" || tag === "input" || tag === "select" || tag === "textarea" || current.hasAttribute("data-veo-tag") || current.hasAttribute("role") || current.hasAttribute("onclick") || current instanceof HTMLElement && current.style.cursor === "pointer";
|
|
844
|
+
if (isInteractive) return current;
|
|
845
|
+
current = current.parentElement;
|
|
846
|
+
depth++;
|
|
847
|
+
}
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
emit(eventType, element) {
|
|
851
|
+
const payload = buildAutocapturePayload(eventType, element, this.config);
|
|
852
|
+
this.client.track("$autocapture", payload);
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// src/plugins/autocapture/index.ts
|
|
857
|
+
function autocapturePlugin(config = {}) {
|
|
858
|
+
let listener = null;
|
|
859
|
+
return definePlugin({
|
|
860
|
+
name: "autocapture",
|
|
861
|
+
install(client) {
|
|
862
|
+
listener = new DomListener(client, config);
|
|
863
|
+
listener.install();
|
|
864
|
+
},
|
|
865
|
+
destroy() {
|
|
866
|
+
listener?.destroy();
|
|
867
|
+
listener = null;
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// src/plugins/guides/constants.ts
|
|
873
|
+
var GUIDE_Z_INDEX = 2147483640;
|
|
874
|
+
var DEFAULT_ANCHOR_WAIT_MS = 5e3;
|
|
875
|
+
var SAFE_URL_PROTOCOLS = ["http:", "https:"];
|
|
876
|
+
var GUIDE_HOST_ATTR = "data-veo-guide";
|
|
877
|
+
var FREQUENCY_CACHE_KEY_PREFIX = "veo:freq:";
|
|
878
|
+
var DEFAULT_TRACKER_FLUSH_INTERVAL_MS = 3e3;
|
|
879
|
+
var DEFAULT_TRACKER_BATCH_SIZE = 5;
|
|
880
|
+
var DEFAULT_TRACKER_MAX_RETRIES = 3;
|
|
881
|
+
var FREQUENCY_CACHE_MAX_ENTRIES = 500;
|
|
882
|
+
var FREQUENCY_CACHE_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
|
|
883
|
+
var WALKTHROUGH_STATE_KEY_PREFIX = "veo:walkthrough_state:";
|
|
884
|
+
var WALKTHROUGH_ABANDONMENT_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
885
|
+
var WALKTHROUGH_STEP_SELECTOR_TIMEOUT_MS = 5e3;
|
|
886
|
+
|
|
887
|
+
// src/plugins/guides/guide-frequency-cache.ts
|
|
888
|
+
var STATUS_PRIORITY = {
|
|
889
|
+
shown: 1,
|
|
890
|
+
dismissed: 2,
|
|
891
|
+
completed: 2
|
|
892
|
+
};
|
|
893
|
+
function namespaceFromApiKey(apiKey) {
|
|
894
|
+
return apiKey.slice(-16) || "default";
|
|
895
|
+
}
|
|
896
|
+
var GuideFrequencyCache = class {
|
|
897
|
+
constructor(namespace) {
|
|
898
|
+
this.storageKey = `${FREQUENCY_CACHE_KEY_PREFIX}${namespace}`;
|
|
899
|
+
this.cache = this.load();
|
|
900
|
+
this.prune();
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Decide si una guía debe filtrarse localmente antes de pintarla.
|
|
904
|
+
*
|
|
905
|
+
* - `every_visit` / `always` → nunca filtra (siempre re-pinta).
|
|
906
|
+
* - `once` → cualquier entry registrada filtra.
|
|
907
|
+
* - `until_dismissed` → solo si `status === 'dismissed'`.
|
|
908
|
+
*/
|
|
909
|
+
shouldFilter(guideId, displayFrequency) {
|
|
910
|
+
if (displayFrequency === "every_visit" || displayFrequency === "always") return false;
|
|
911
|
+
const entry = this.cache.get(guideId);
|
|
912
|
+
if (!entry) return false;
|
|
913
|
+
if (displayFrequency === "once") return true;
|
|
914
|
+
if (displayFrequency === "until_dismissed") return entry.status === "dismissed";
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Registra/actualiza la entry de una guía. Nunca degrada un estado más
|
|
919
|
+
* fuerte (no convierte `dismissed` en `shown` por un re-render). Persiste
|
|
920
|
+
* cada llamada — escribir a localStorage es lo bastante barato.
|
|
921
|
+
*/
|
|
922
|
+
record(guideId, status) {
|
|
923
|
+
const existing = this.cache.get(guideId);
|
|
924
|
+
if (existing && STATUS_PRIORITY[status] < STATUS_PRIORITY[existing.status]) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
this.cache.set(guideId, {
|
|
928
|
+
guideId,
|
|
929
|
+
status,
|
|
930
|
+
lastInteractionAt: Date.now()
|
|
931
|
+
});
|
|
932
|
+
this.persist();
|
|
933
|
+
}
|
|
934
|
+
/** Limpia el cache (botón "reset" del demo, tests). */
|
|
935
|
+
clear() {
|
|
936
|
+
this.cache.clear();
|
|
937
|
+
this.persist();
|
|
938
|
+
}
|
|
939
|
+
/** Útil para debugging — devuelve un snapshot. */
|
|
940
|
+
snapshot() {
|
|
941
|
+
return Array.from(this.cache.values());
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Limpia entries expiradas por TTL y, si aún excede el límite, descarta
|
|
945
|
+
* las más viejas hasta caber. Se ejecuta una vez en el constructor; no
|
|
946
|
+
* vale la pena correrlo a cada lectura.
|
|
947
|
+
*/
|
|
948
|
+
prune() {
|
|
949
|
+
const now = Date.now();
|
|
950
|
+
let mutated = false;
|
|
951
|
+
for (const [id, entry] of this.cache) {
|
|
952
|
+
if (now - entry.lastInteractionAt > FREQUENCY_CACHE_TTL_MS) {
|
|
953
|
+
this.cache.delete(id);
|
|
954
|
+
mutated = true;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (this.cache.size > FREQUENCY_CACHE_MAX_ENTRIES) {
|
|
958
|
+
const sorted = Array.from(this.cache.entries()).sort(
|
|
959
|
+
(a, b) => a[1].lastInteractionAt - b[1].lastInteractionAt
|
|
960
|
+
);
|
|
961
|
+
const excess = this.cache.size - FREQUENCY_CACHE_MAX_ENTRIES;
|
|
962
|
+
for (let i = 0; i < excess; i++) {
|
|
963
|
+
const tuple = sorted[i];
|
|
964
|
+
if (tuple) this.cache.delete(tuple[0]);
|
|
965
|
+
}
|
|
966
|
+
mutated = true;
|
|
967
|
+
}
|
|
968
|
+
if (mutated) this.persist();
|
|
969
|
+
}
|
|
970
|
+
load() {
|
|
971
|
+
if (typeof localStorage === "undefined") return /* @__PURE__ */ new Map();
|
|
972
|
+
try {
|
|
973
|
+
const raw = localStorage.getItem(this.storageKey);
|
|
974
|
+
if (!raw) return /* @__PURE__ */ new Map();
|
|
975
|
+
const parsed = JSON.parse(raw);
|
|
976
|
+
if (!Array.isArray(parsed)) return /* @__PURE__ */ new Map();
|
|
977
|
+
const entries = [];
|
|
978
|
+
for (const item of parsed) {
|
|
979
|
+
if (item && typeof item === "object" && typeof item.guideId === "string" && typeof item.lastInteractionAt === "number" && ["shown", "dismissed", "completed"].includes(item.status)) {
|
|
980
|
+
const e = item;
|
|
981
|
+
entries.push([e.guideId, e]);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return new Map(entries);
|
|
985
|
+
} catch {
|
|
986
|
+
return /* @__PURE__ */ new Map();
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
persist() {
|
|
990
|
+
if (typeof localStorage === "undefined") return;
|
|
991
|
+
try {
|
|
992
|
+
const serialized = JSON.stringify(Array.from(this.cache.values()));
|
|
993
|
+
localStorage.setItem(this.storageKey, serialized);
|
|
994
|
+
} catch {
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
// src/plugins/guides/guide-resolver-client.ts
|
|
1000
|
+
var GuideResolverClient = class {
|
|
1001
|
+
constructor(apiUrl, apiKey, debug = false) {
|
|
1002
|
+
this.apiUrl = apiUrl;
|
|
1003
|
+
this.apiKey = apiKey;
|
|
1004
|
+
this.debug = debug;
|
|
1005
|
+
}
|
|
1006
|
+
async resolve(endUserId, pageUrl) {
|
|
1007
|
+
const params = new URLSearchParams({ endUserId, pageUrl });
|
|
1008
|
+
let response;
|
|
1009
|
+
try {
|
|
1010
|
+
response = await fetch(`${this.apiUrl}/v1/guides/resolve?${params.toString()}`, {
|
|
1011
|
+
method: "GET",
|
|
1012
|
+
headers: { "X-Api-Key": this.apiKey },
|
|
1013
|
+
credentials: "omit"
|
|
1014
|
+
});
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
if (this.debug) console.error("[veo] guides resolve fetch failed:", err);
|
|
1017
|
+
return [];
|
|
1018
|
+
}
|
|
1019
|
+
if (!response.ok) {
|
|
1020
|
+
if (this.debug) console.warn("[veo] guides resolve HTTP", response.status);
|
|
1021
|
+
return [];
|
|
1022
|
+
}
|
|
1023
|
+
try {
|
|
1024
|
+
const data = await response.json();
|
|
1025
|
+
return Array.isArray(data.guides) ? data.guides : [];
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
if (this.debug) console.error("[veo] guides resolve parse failed:", err);
|
|
1028
|
+
return [];
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Trae una guía específica por ID, sin pasar por la lógica de
|
|
1033
|
+
* activación. La usa el controller para resumir un walkthrough entre
|
|
1034
|
+
* páginas: el `resolve` no devuelve la guía si la URL actual no
|
|
1035
|
+
* matchea, pero el walkthrough activo debe poder consultarla igual.
|
|
1036
|
+
*
|
|
1037
|
+
* El endpoint backend (`GET /v1/guides/:guideId`) devuelve `GuideResponse`,
|
|
1038
|
+
* un superset de `ResolvedGuide` con campos de audit. Acá descartamos los
|
|
1039
|
+
* extra y nos quedamos con el shape que el resto del SDK ya consume.
|
|
1040
|
+
*
|
|
1041
|
+
* Devuelve `null` en cualquier error (404, red, parse) — el caller debe
|
|
1042
|
+
* tratarlo como "guía ya no existe" y cancelar el walkthrough limpiamente.
|
|
1043
|
+
*/
|
|
1044
|
+
async fetchById(guideId) {
|
|
1045
|
+
let response;
|
|
1046
|
+
try {
|
|
1047
|
+
response = await fetch(`${this.apiUrl}/v1/guides/${encodeURIComponent(guideId)}`, {
|
|
1048
|
+
method: "GET",
|
|
1049
|
+
headers: { "X-Api-Key": this.apiKey },
|
|
1050
|
+
credentials: "omit"
|
|
1051
|
+
});
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
if (this.debug) console.error("[veo] guides fetchById network failed:", err);
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
if (!response.ok) {
|
|
1057
|
+
if (this.debug) console.warn("[veo] guides fetchById HTTP", response.status);
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
const data = await response.json();
|
|
1062
|
+
if (typeof data.guideId !== "string" || typeof data.guideName !== "string" || typeof data.guideType !== "string" || !Array.isArray(data.guideSteps) || !data.activationRules || typeof data.displayFrequency !== "string" || typeof data.displayPriority !== "number") {
|
|
1063
|
+
if (this.debug) console.warn("[veo] guides fetchById: unexpected shape");
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
return {
|
|
1067
|
+
guideId: data.guideId,
|
|
1068
|
+
guideName: data.guideName,
|
|
1069
|
+
guideType: data.guideType,
|
|
1070
|
+
guideSteps: data.guideSteps,
|
|
1071
|
+
activationRules: data.activationRules,
|
|
1072
|
+
displayFrequency: data.displayFrequency,
|
|
1073
|
+
displayPriority: data.displayPriority
|
|
1074
|
+
};
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
if (this.debug) console.error("[veo] guides fetchById parse failed:", err);
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
// src/plugins/guides/guide-tracker-client.ts
|
|
1083
|
+
function toWire(payload) {
|
|
1084
|
+
const wire = {
|
|
1085
|
+
endUserId: payload.endUserId,
|
|
1086
|
+
interactionType: payload.action
|
|
1087
|
+
};
|
|
1088
|
+
if (payload.sessionId) wire.sessionId = payload.sessionId;
|
|
1089
|
+
if (typeof payload.stepIndex === "number") wire.stepPosition = payload.stepIndex;
|
|
1090
|
+
if (payload.pageUrl) wire.pageUrl = payload.pageUrl;
|
|
1091
|
+
if (payload.pagePath) wire.pagePath = payload.pagePath;
|
|
1092
|
+
return wire;
|
|
1093
|
+
}
|
|
1094
|
+
var GuideTrackerClient = class {
|
|
1095
|
+
constructor(apiUrl, apiKey) {
|
|
1096
|
+
this.apiUrl = apiUrl;
|
|
1097
|
+
this.apiKey = apiKey;
|
|
1098
|
+
}
|
|
1099
|
+
async send(request) {
|
|
1100
|
+
const url = this.buildUrl(request.guideId);
|
|
1101
|
+
const response = await fetch(url, {
|
|
1102
|
+
method: "POST",
|
|
1103
|
+
headers: {
|
|
1104
|
+
"X-Api-Key": this.apiKey,
|
|
1105
|
+
"Content-Type": "application/json"
|
|
1106
|
+
},
|
|
1107
|
+
body: JSON.stringify(toWire(request.payload)),
|
|
1108
|
+
credentials: "omit"
|
|
1109
|
+
});
|
|
1110
|
+
if (!response.ok) {
|
|
1111
|
+
throw new Error(`guide interaction send failed: HTTP ${response.status}`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Envío "fire-and-forget" en pagehide. Devuelve `false` si el navegador
|
|
1116
|
+
* no acepta el beacon (cola llena, no soportado) — en ese caso la queue
|
|
1117
|
+
* sabrá que perdió el envío.
|
|
1118
|
+
*/
|
|
1119
|
+
sendBeacon(request) {
|
|
1120
|
+
if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function") {
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
const url = `${this.buildUrl(request.guideId)}?key=${encodeURIComponent(this.apiKey)}`;
|
|
1124
|
+
const blob = new Blob([JSON.stringify(toWire(request.payload))], {
|
|
1125
|
+
type: "application/json"
|
|
1126
|
+
});
|
|
1127
|
+
try {
|
|
1128
|
+
return navigator.sendBeacon(url, blob);
|
|
1129
|
+
} catch {
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
buildUrl(guideId) {
|
|
1134
|
+
return `${this.apiUrl}/v1/guides/${encodeURIComponent(guideId)}/interactions`;
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
// src/plugins/guides/guide-tracker-queue.ts
|
|
1139
|
+
var GuideTrackerQueue = class {
|
|
1140
|
+
constructor(config) {
|
|
1141
|
+
this.config = config;
|
|
1142
|
+
this.buffer = [];
|
|
1143
|
+
this.timer = null;
|
|
1144
|
+
this.flushing = false;
|
|
1145
|
+
this.unloadCleanup = null;
|
|
1146
|
+
this.startTimer();
|
|
1147
|
+
this.installUnloadHandler();
|
|
1148
|
+
}
|
|
1149
|
+
/** Tamaño actual del buffer (útil para debug/stats en el demo). */
|
|
1150
|
+
get size() {
|
|
1151
|
+
return this.buffer.length;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Encola una interaction. Si el buffer alcanza `batchSize`, dispara
|
|
1155
|
+
* `flush()` inmediato.
|
|
1156
|
+
*/
|
|
1157
|
+
enqueue(request) {
|
|
1158
|
+
this.buffer.push({
|
|
1159
|
+
guideId: request.guideId,
|
|
1160
|
+
payload: request.payload,
|
|
1161
|
+
attempts: 0,
|
|
1162
|
+
enqueuedAt: Date.now()
|
|
1163
|
+
});
|
|
1164
|
+
if (this.buffer.length >= this.config.batchSize) {
|
|
1165
|
+
void this.flush();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Envía todo el buffer en paralelo. Items que fallan vuelven al buffer
|
|
1170
|
+
* si todavía tienen reintentos disponibles. Idempotente con respecto a
|
|
1171
|
+
* flushes concurrentes (el segundo es un no-op).
|
|
1172
|
+
*/
|
|
1173
|
+
async flush() {
|
|
1174
|
+
if (this.flushing || this.buffer.length === 0) return;
|
|
1175
|
+
this.flushing = true;
|
|
1176
|
+
const batch = this.buffer.splice(0, this.buffer.length);
|
|
1177
|
+
const results = await Promise.allSettled(
|
|
1178
|
+
batch.map(
|
|
1179
|
+
(item) => this.config.client.send({ guideId: item.guideId, payload: item.payload })
|
|
1180
|
+
)
|
|
1181
|
+
);
|
|
1182
|
+
for (let i = 0; i < results.length; i++) {
|
|
1183
|
+
const result = results[i];
|
|
1184
|
+
const item = batch[i];
|
|
1185
|
+
if (!result || !item) continue;
|
|
1186
|
+
if (result.status === "fulfilled") continue;
|
|
1187
|
+
item.attempts += 1;
|
|
1188
|
+
if (item.attempts < this.config.maxRetries) {
|
|
1189
|
+
this.buffer.push(item);
|
|
1190
|
+
} else {
|
|
1191
|
+
this.safeOnError(result.reason, item);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
this.flushing = false;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Drena el buffer vía `sendBeacon`. Síncrono, sin retry. Para pagehide.
|
|
1198
|
+
*/
|
|
1199
|
+
flushBeacon() {
|
|
1200
|
+
if (this.buffer.length === 0) return;
|
|
1201
|
+
const batch = this.buffer.splice(0, this.buffer.length);
|
|
1202
|
+
for (const item of batch) {
|
|
1203
|
+
this.config.client.sendBeacon({ guideId: item.guideId, payload: item.payload });
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
destroy() {
|
|
1207
|
+
if (this.timer) clearInterval(this.timer);
|
|
1208
|
+
this.timer = null;
|
|
1209
|
+
this.unloadCleanup?.();
|
|
1210
|
+
this.unloadCleanup = null;
|
|
1211
|
+
}
|
|
1212
|
+
startTimer() {
|
|
1213
|
+
if (typeof window === "undefined") return;
|
|
1214
|
+
this.timer = setInterval(() => {
|
|
1215
|
+
void this.flush();
|
|
1216
|
+
}, this.config.flushIntervalMs);
|
|
1217
|
+
}
|
|
1218
|
+
installUnloadHandler() {
|
|
1219
|
+
if (typeof window === "undefined") return;
|
|
1220
|
+
const onPagehide = () => this.flushBeacon();
|
|
1221
|
+
const onVisibility = () => {
|
|
1222
|
+
if (document.visibilityState === "hidden") this.flushBeacon();
|
|
1223
|
+
};
|
|
1224
|
+
window.addEventListener("pagehide", onPagehide);
|
|
1225
|
+
window.addEventListener("visibilitychange", onVisibility);
|
|
1226
|
+
this.unloadCleanup = () => {
|
|
1227
|
+
window.removeEventListener("pagehide", onPagehide);
|
|
1228
|
+
window.removeEventListener("visibilitychange", onVisibility);
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
safeOnError(reason, item) {
|
|
1232
|
+
if (!this.config.onError) return;
|
|
1233
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
1234
|
+
try {
|
|
1235
|
+
this.config.onError(error, item);
|
|
1236
|
+
} catch {
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
// src/plugins/guides/block-builder.ts
|
|
1242
|
+
function buildStepContent(step, doc, callbacks) {
|
|
1243
|
+
const container = doc.createElement("div");
|
|
1244
|
+
container.className = "veo-guide-content";
|
|
1245
|
+
if (typeof step.imageUrl === "string" && step.imageUrl && isSafeUrl(step.imageUrl)) {
|
|
1246
|
+
const img = doc.createElement("img");
|
|
1247
|
+
img.className = "veo-guide-image";
|
|
1248
|
+
img.src = step.imageUrl;
|
|
1249
|
+
img.alt = typeof step.title === "string" ? step.title : "";
|
|
1250
|
+
container.appendChild(img);
|
|
1251
|
+
}
|
|
1252
|
+
if (typeof step.title === "string" && step.title) {
|
|
1253
|
+
const heading = doc.createElement("h2");
|
|
1254
|
+
heading.className = "veo-guide-title";
|
|
1255
|
+
heading.textContent = step.title;
|
|
1256
|
+
container.appendChild(heading);
|
|
1257
|
+
}
|
|
1258
|
+
if (typeof step.content === "string" && step.content) {
|
|
1259
|
+
const paragraph = doc.createElement("p");
|
|
1260
|
+
paragraph.className = "veo-guide-text";
|
|
1261
|
+
paragraph.textContent = step.content;
|
|
1262
|
+
container.appendChild(paragraph);
|
|
1263
|
+
}
|
|
1264
|
+
const actions = doc.createElement("div");
|
|
1265
|
+
actions.className = "veo-guide-actions";
|
|
1266
|
+
if (typeof step.ctaText === "string" && step.ctaText) {
|
|
1267
|
+
const cta = doc.createElement("button");
|
|
1268
|
+
cta.type = "button";
|
|
1269
|
+
cta.className = "veo-guide-cta";
|
|
1270
|
+
cta.textContent = step.ctaText;
|
|
1271
|
+
cta.addEventListener("click", () => {
|
|
1272
|
+
const action = step.ctaAction ?? "dismiss";
|
|
1273
|
+
const ctaUrl = readCtaUrl(step);
|
|
1274
|
+
callbacks.onCtaClick(action, ctaUrl);
|
|
1275
|
+
});
|
|
1276
|
+
actions.appendChild(cta);
|
|
1277
|
+
}
|
|
1278
|
+
container.appendChild(actions);
|
|
1279
|
+
const closeBtn = doc.createElement("button");
|
|
1280
|
+
closeBtn.type = "button";
|
|
1281
|
+
closeBtn.className = "veo-guide-close";
|
|
1282
|
+
closeBtn.setAttribute("aria-label", "Cerrar");
|
|
1283
|
+
closeBtn.textContent = "\xD7";
|
|
1284
|
+
closeBtn.addEventListener("click", () => callbacks.onDismiss());
|
|
1285
|
+
container.appendChild(closeBtn);
|
|
1286
|
+
return container;
|
|
1287
|
+
}
|
|
1288
|
+
function isSafeUrl(url) {
|
|
1289
|
+
if (typeof url !== "string" || url.length === 0) return false;
|
|
1290
|
+
try {
|
|
1291
|
+
const base = typeof window !== "undefined" ? window.location.href : "http://localhost/";
|
|
1292
|
+
const parsed = new URL(url, base);
|
|
1293
|
+
return SAFE_URL_PROTOCOLS.includes(parsed.protocol);
|
|
1294
|
+
} catch {
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
function readCtaUrl(step) {
|
|
1299
|
+
const candidate = step.style?.ctaUrl;
|
|
1300
|
+
if (typeof candidate !== "string") return void 0;
|
|
1301
|
+
return isSafeUrl(candidate) ? candidate : void 0;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// src/plugins/guides/styles.ts
|
|
1305
|
+
var GUIDE_STYLES = `
|
|
1306
|
+
:host {
|
|
1307
|
+
--veo-primary: #4f46e5;
|
|
1308
|
+
--veo-text: #1f2937;
|
|
1309
|
+
--veo-text-secondary: #4b5563;
|
|
1310
|
+
--veo-bg: #ffffff;
|
|
1311
|
+
--veo-radius: 12px;
|
|
1312
|
+
--veo-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
|
1313
|
+
all: initial;
|
|
1314
|
+
}
|
|
1315
|
+
* { box-sizing: border-box; font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif; }
|
|
1316
|
+
|
|
1317
|
+
.veo-modal-overlay {
|
|
1318
|
+
position: fixed; inset: 0;
|
|
1319
|
+
background: rgba(15, 15, 30, 0.45);
|
|
1320
|
+
display: flex; align-items: center; justify-content: center;
|
|
1321
|
+
z-index: ${GUIDE_Z_INDEX};
|
|
1322
|
+
animation: veo-fade-in 180ms ease-out;
|
|
1323
|
+
}
|
|
1324
|
+
.veo-modal-card {
|
|
1325
|
+
background: var(--veo-bg);
|
|
1326
|
+
border-radius: var(--veo-radius);
|
|
1327
|
+
padding: 24px 28px;
|
|
1328
|
+
max-width: 420px; width: 90%;
|
|
1329
|
+
position: relative;
|
|
1330
|
+
box-shadow: var(--veo-shadow);
|
|
1331
|
+
animation: veo-slide-up 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
.veo-banner {
|
|
1335
|
+
position: fixed; left: 0; right: 0;
|
|
1336
|
+
background: var(--veo-bg);
|
|
1337
|
+
padding: 14px 20px;
|
|
1338
|
+
z-index: ${GUIDE_Z_INDEX};
|
|
1339
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
|
1340
|
+
animation: veo-slide-down 200ms ease-out;
|
|
1341
|
+
}
|
|
1342
|
+
.veo-banner-top { top: 0; }
|
|
1343
|
+
.veo-banner-bottom { bottom: 0; }
|
|
1344
|
+
|
|
1345
|
+
.veo-tooltip {
|
|
1346
|
+
position: absolute;
|
|
1347
|
+
background: var(--veo-bg);
|
|
1348
|
+
border-radius: var(--veo-radius);
|
|
1349
|
+
padding: 14px 16px;
|
|
1350
|
+
max-width: 300px;
|
|
1351
|
+
z-index: ${GUIDE_Z_INDEX};
|
|
1352
|
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
|
1353
|
+
animation: veo-fade-in 150ms ease-out;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
.veo-guide-content {
|
|
1357
|
+
position: relative;
|
|
1358
|
+
display: flex; flex-direction: column;
|
|
1359
|
+
}
|
|
1360
|
+
.veo-guide-image {
|
|
1361
|
+
display: block; width: 100%; max-height: 180px;
|
|
1362
|
+
object-fit: cover; border-radius: 8px;
|
|
1363
|
+
margin-bottom: 12px;
|
|
1364
|
+
}
|
|
1365
|
+
.veo-guide-title {
|
|
1366
|
+
font-size: 18px; font-weight: 600; line-height: 1.3;
|
|
1367
|
+
margin: 0 0 6px; color: var(--veo-text);
|
|
1368
|
+
}
|
|
1369
|
+
.veo-guide-text {
|
|
1370
|
+
font-size: 14px; line-height: 1.5;
|
|
1371
|
+
margin: 0 0 16px; color: var(--veo-text-secondary);
|
|
1372
|
+
}
|
|
1373
|
+
.veo-guide-actions {
|
|
1374
|
+
display: flex; gap: 8px; justify-content: flex-end;
|
|
1375
|
+
}
|
|
1376
|
+
.veo-guide-cta {
|
|
1377
|
+
background: var(--veo-primary); color: #fff; border: none;
|
|
1378
|
+
padding: 9px 18px; border-radius: 8px;
|
|
1379
|
+
font-size: 14px; font-weight: 500; cursor: pointer;
|
|
1380
|
+
transition: opacity 120ms ease;
|
|
1381
|
+
}
|
|
1382
|
+
.veo-guide-cta:hover { opacity: 0.9; }
|
|
1383
|
+
.veo-guide-cta:active { transform: translateY(1px); }
|
|
1384
|
+
.veo-guide-close {
|
|
1385
|
+
position: absolute; top: -6px; right: -6px;
|
|
1386
|
+
width: 24px; height: 24px;
|
|
1387
|
+
background: none; border: none;
|
|
1388
|
+
font-size: 22px; line-height: 1;
|
|
1389
|
+
cursor: pointer; color: #9ca3af; padding: 0;
|
|
1390
|
+
}
|
|
1391
|
+
.veo-guide-close:hover { color: var(--veo-text); }
|
|
1392
|
+
|
|
1393
|
+
.veo-walkthrough {
|
|
1394
|
+
position: absolute;
|
|
1395
|
+
background: var(--veo-bg);
|
|
1396
|
+
border-radius: var(--veo-radius);
|
|
1397
|
+
padding: 16px;
|
|
1398
|
+
max-width: 340px;
|
|
1399
|
+
z-index: ${GUIDE_Z_INDEX};
|
|
1400
|
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
|
|
1401
|
+
animation: veo-fade-in 150ms ease-out;
|
|
1402
|
+
}
|
|
1403
|
+
.veo-walkthrough-counter {
|
|
1404
|
+
font-size: 11px; color: #9ca3af;
|
|
1405
|
+
margin-bottom: 8px; letter-spacing: 0.02em;
|
|
1406
|
+
}
|
|
1407
|
+
.veo-walkthrough-progress {
|
|
1408
|
+
display: flex; gap: 4px;
|
|
1409
|
+
margin-bottom: 12px;
|
|
1410
|
+
}
|
|
1411
|
+
.veo-walkthrough-progress-dot {
|
|
1412
|
+
width: 8px; height: 8px;
|
|
1413
|
+
border-radius: 50%;
|
|
1414
|
+
background: #e5e7eb;
|
|
1415
|
+
transition: background 200ms ease;
|
|
1416
|
+
}
|
|
1417
|
+
.veo-walkthrough-progress-dot.active { background: var(--veo-primary); }
|
|
1418
|
+
.veo-walkthrough-progress-dot.completed { background: var(--veo-primary); opacity: 0.5; }
|
|
1419
|
+
.veo-walkthrough-actions {
|
|
1420
|
+
display: flex; align-items: center;
|
|
1421
|
+
margin-top: 16px; gap: 8px;
|
|
1422
|
+
}
|
|
1423
|
+
.veo-walkthrough-actions-right {
|
|
1424
|
+
display: flex; gap: 8px;
|
|
1425
|
+
margin-left: auto;
|
|
1426
|
+
}
|
|
1427
|
+
.veo-walkthrough-btn-secondary {
|
|
1428
|
+
background: transparent; color: #6b7280;
|
|
1429
|
+
border: 1px solid #e5e7eb;
|
|
1430
|
+
padding: 8px 16px; border-radius: 8px;
|
|
1431
|
+
font-size: 13px; cursor: pointer;
|
|
1432
|
+
transition: background 120ms ease;
|
|
1433
|
+
}
|
|
1434
|
+
.veo-walkthrough-btn-secondary:hover { background: #f9fafb; }
|
|
1435
|
+
.veo-walkthrough-skip {
|
|
1436
|
+
background: transparent; color: #9ca3af;
|
|
1437
|
+
border: none; padding: 4px 8px;
|
|
1438
|
+
font-size: 12px; cursor: pointer;
|
|
1439
|
+
}
|
|
1440
|
+
.veo-walkthrough-skip:hover { color: var(--veo-text); }
|
|
1441
|
+
|
|
1442
|
+
.veo-custom-floating {
|
|
1443
|
+
position: fixed;
|
|
1444
|
+
z-index: ${GUIDE_Z_INDEX};
|
|
1445
|
+
max-width: calc(100vw - 16px);
|
|
1446
|
+
max-height: calc(100vh - 16px);
|
|
1447
|
+
animation: veo-fade-in 160ms ease;
|
|
1448
|
+
}
|
|
1449
|
+
.veo-custom-anchored {
|
|
1450
|
+
position: fixed;
|
|
1451
|
+
top: 0; left: 0;
|
|
1452
|
+
z-index: ${GUIDE_Z_INDEX};
|
|
1453
|
+
max-width: calc(100vw - 16px);
|
|
1454
|
+
animation: veo-fade-in 160ms ease;
|
|
1455
|
+
}
|
|
1456
|
+
.veo-custom-frame {
|
|
1457
|
+
display: block;
|
|
1458
|
+
border: 0;
|
|
1459
|
+
width: 360px;
|
|
1460
|
+
background: transparent;
|
|
1461
|
+
color-scheme: normal;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
@keyframes veo-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
1465
|
+
@keyframes veo-slide-up {
|
|
1466
|
+
from { transform: translateY(10px); opacity: 0; }
|
|
1467
|
+
to { transform: translateY(0); opacity: 1; }
|
|
1468
|
+
}
|
|
1469
|
+
@keyframes veo-slide-down {
|
|
1470
|
+
from { transform: translateY(-10px); opacity: 0; }
|
|
1471
|
+
to { transform: translateY(0); opacity: 1; }
|
|
1472
|
+
}
|
|
1473
|
+
`;
|
|
1474
|
+
|
|
1475
|
+
// src/plugins/guides/renderers/base-renderer.ts
|
|
1476
|
+
var BaseRenderer = class {
|
|
1477
|
+
constructor() {
|
|
1478
|
+
this.host = null;
|
|
1479
|
+
this.shadow = null;
|
|
1480
|
+
this.cleanups = [];
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Crea un `<div data-veo-guide>` adjunto a `document.body`, le adjunta
|
|
1484
|
+
* un ShadowRoot abierto e inyecta los estilos. Retorna el nodo raíz
|
|
1485
|
+
* donde la subclase puede insertar el contenido específico.
|
|
1486
|
+
*/
|
|
1487
|
+
createHost() {
|
|
1488
|
+
const host = document.createElement("div");
|
|
1489
|
+
host.setAttribute(GUIDE_HOST_ATTR, "");
|
|
1490
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
1491
|
+
const style = document.createElement("style");
|
|
1492
|
+
style.textContent = GUIDE_STYLES;
|
|
1493
|
+
shadow.appendChild(style);
|
|
1494
|
+
const root = document.createElement("div");
|
|
1495
|
+
shadow.appendChild(root);
|
|
1496
|
+
document.body.appendChild(host);
|
|
1497
|
+
this.host = host;
|
|
1498
|
+
this.shadow = shadow;
|
|
1499
|
+
return { host, shadow, root };
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Registra una función a ejecutar en `destroy()`. Útil para limpiar
|
|
1503
|
+
* listeners globales (scroll, resize) o intervalos.
|
|
1504
|
+
*/
|
|
1505
|
+
registerCleanup(fn) {
|
|
1506
|
+
this.cleanups.push(fn);
|
|
1507
|
+
}
|
|
1508
|
+
/** Remueve el host del DOM y corre todas las funciones de cleanup. */
|
|
1509
|
+
destroy() {
|
|
1510
|
+
for (const fn of this.cleanups) {
|
|
1511
|
+
try {
|
|
1512
|
+
fn();
|
|
1513
|
+
} catch {
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
this.cleanups = [];
|
|
1517
|
+
if (this.host?.parentNode) {
|
|
1518
|
+
this.host.parentNode.removeChild(this.host);
|
|
1519
|
+
}
|
|
1520
|
+
this.host = null;
|
|
1521
|
+
this.shadow = null;
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
|
|
1525
|
+
// src/plugins/guides/renderers/banner-renderer.ts
|
|
1526
|
+
var BannerRenderer = class extends BaseRenderer {
|
|
1527
|
+
render(context) {
|
|
1528
|
+
const step = context.guide.guideSteps[0];
|
|
1529
|
+
if (!step) return;
|
|
1530
|
+
const { root } = this.createHost();
|
|
1531
|
+
const ownerDocument = root.ownerDocument ?? document;
|
|
1532
|
+
const position = step.style?.position === "bottom" ? "bottom" : "top";
|
|
1533
|
+
const banner = ownerDocument.createElement("div");
|
|
1534
|
+
banner.className = `veo-banner veo-banner-${position}`;
|
|
1535
|
+
const dismiss = (action, ctaUrl) => {
|
|
1536
|
+
context.onInteraction({
|
|
1537
|
+
guideId: context.guide.guideId,
|
|
1538
|
+
stepIndex: 0,
|
|
1539
|
+
action
|
|
1540
|
+
});
|
|
1541
|
+
if (ctaUrl) window.open(ctaUrl, "_blank", "noopener,noreferrer");
|
|
1542
|
+
context.onClose();
|
|
1543
|
+
};
|
|
1544
|
+
const content = buildStepContent(step, ownerDocument, {
|
|
1545
|
+
onCtaClick: (action, url) => {
|
|
1546
|
+
dismiss("cta_clicked", action === "url" ? url : void 0);
|
|
1547
|
+
},
|
|
1548
|
+
onDismiss: () => dismiss("dismissed")
|
|
1549
|
+
});
|
|
1550
|
+
banner.appendChild(content);
|
|
1551
|
+
root.appendChild(banner);
|
|
1552
|
+
context.onInteraction({
|
|
1553
|
+
guideId: context.guide.guideId,
|
|
1554
|
+
stepIndex: 0,
|
|
1555
|
+
action: "shown"
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
// src/plugins/guides/wait-for-element.ts
|
|
1561
|
+
function waitForElement(selector, timeoutMs = DEFAULT_ANCHOR_WAIT_MS) {
|
|
1562
|
+
return new Promise((resolve) => {
|
|
1563
|
+
const safeQuery = () => {
|
|
1564
|
+
try {
|
|
1565
|
+
return document.querySelector(selector);
|
|
1566
|
+
} catch {
|
|
1567
|
+
return null;
|
|
1568
|
+
}
|
|
1569
|
+
};
|
|
1570
|
+
const existing = safeQuery();
|
|
1571
|
+
if (existing) {
|
|
1572
|
+
resolve(existing);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
let resolved = false;
|
|
1576
|
+
const finish = (el) => {
|
|
1577
|
+
if (resolved) return;
|
|
1578
|
+
resolved = true;
|
|
1579
|
+
observer.disconnect();
|
|
1580
|
+
clearTimeout(timer);
|
|
1581
|
+
resolve(el);
|
|
1582
|
+
};
|
|
1583
|
+
const observer = new MutationObserver(() => {
|
|
1584
|
+
const el = safeQuery();
|
|
1585
|
+
if (el) finish(el);
|
|
1586
|
+
});
|
|
1587
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
1588
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// src/plugins/guides/renderers/custom-renderer.ts
|
|
1593
|
+
var DEFAULT_PLACEMENT = { mode: "floating", position: "bottom-right" };
|
|
1594
|
+
var DEFAULT_OFFSET = 16;
|
|
1595
|
+
var DEFAULT_WIDTH = 360;
|
|
1596
|
+
var CustomRenderer = class extends BaseRenderer {
|
|
1597
|
+
async render(context) {
|
|
1598
|
+
const step = context.guide.guideSteps[0];
|
|
1599
|
+
if (!step || typeof step.html !== "string" || step.html.length === 0) return;
|
|
1600
|
+
const placement = step.placement ?? DEFAULT_PLACEMENT;
|
|
1601
|
+
let anchor = null;
|
|
1602
|
+
if (placement.mode === "anchored") {
|
|
1603
|
+
const selector = placement.selector ?? step.selector ?? context.guide.activationRules.selector;
|
|
1604
|
+
if (typeof selector !== "string" || selector.length === 0) return;
|
|
1605
|
+
anchor = await waitForElement(selector);
|
|
1606
|
+
if (!anchor) return;
|
|
1607
|
+
}
|
|
1608
|
+
const { root } = this.createHost();
|
|
1609
|
+
const doc = root.ownerDocument ?? document;
|
|
1610
|
+
const wrapper = doc.createElement("div");
|
|
1611
|
+
wrapper.className = placement.mode === "anchored" ? "veo-custom-anchored" : "veo-custom-floating";
|
|
1612
|
+
const iframe = doc.createElement("iframe");
|
|
1613
|
+
iframe.className = "veo-custom-frame";
|
|
1614
|
+
iframe.setAttribute("sandbox", "allow-scripts");
|
|
1615
|
+
iframe.setAttribute("title", context.guide.guideName || "Veo");
|
|
1616
|
+
iframe.style.width = toCssSize(placement.width ?? DEFAULT_WIDTH);
|
|
1617
|
+
const hasFixedHeight = placement.height !== void 0 && placement.height !== null;
|
|
1618
|
+
if (hasFixedHeight) {
|
|
1619
|
+
iframe.style.height = toCssSize(placement.height);
|
|
1620
|
+
}
|
|
1621
|
+
const token = uuidv7();
|
|
1622
|
+
iframe.srcdoc = buildSrcdoc(step.html, step.css ?? "", step.js ?? "", token);
|
|
1623
|
+
wrapper.appendChild(iframe);
|
|
1624
|
+
root.appendChild(wrapper);
|
|
1625
|
+
if (placement.mode === "floating") {
|
|
1626
|
+
applyFloatingPosition(wrapper, placement);
|
|
1627
|
+
} else if (anchor) {
|
|
1628
|
+
this.registerCleanup(await this.setupAnchoredPosition(anchor, wrapper, placement));
|
|
1629
|
+
}
|
|
1630
|
+
const close = () => context.onClose();
|
|
1631
|
+
const onMessage = (event) => {
|
|
1632
|
+
if (event.source !== iframe.contentWindow) return;
|
|
1633
|
+
const data = event.data;
|
|
1634
|
+
if (!data || typeof data !== "object" || data.__veo !== token) return;
|
|
1635
|
+
switch (data.type) {
|
|
1636
|
+
case "dismiss":
|
|
1637
|
+
context.onInteraction({
|
|
1638
|
+
guideId: context.guide.guideId,
|
|
1639
|
+
stepIndex: 0,
|
|
1640
|
+
action: "dismissed"
|
|
1641
|
+
});
|
|
1642
|
+
close();
|
|
1643
|
+
break;
|
|
1644
|
+
case "cta":
|
|
1645
|
+
context.onInteraction({
|
|
1646
|
+
guideId: context.guide.guideId,
|
|
1647
|
+
stepIndex: 0,
|
|
1648
|
+
action: "cta_clicked"
|
|
1649
|
+
});
|
|
1650
|
+
if (typeof data.url === "string" && isSafeUrl(data.url)) {
|
|
1651
|
+
window.open(data.url, "_blank", "noopener,noreferrer");
|
|
1652
|
+
}
|
|
1653
|
+
if (data.close !== false) close();
|
|
1654
|
+
break;
|
|
1655
|
+
case "navigate":
|
|
1656
|
+
if (typeof data.url === "string" && isSafeUrl(data.url)) {
|
|
1657
|
+
window.location.assign(data.url);
|
|
1658
|
+
}
|
|
1659
|
+
break;
|
|
1660
|
+
case "track":
|
|
1661
|
+
if (typeof data.name === "string" && context.onTrack) {
|
|
1662
|
+
context.onTrack(data.name, isRecord(data.props) ? data.props : void 0);
|
|
1663
|
+
}
|
|
1664
|
+
break;
|
|
1665
|
+
case "resize":
|
|
1666
|
+
if (!hasFixedHeight && typeof data.height === "number" && data.height > 0) {
|
|
1667
|
+
iframe.style.height = `${Math.ceil(data.height)}px`;
|
|
1668
|
+
}
|
|
1669
|
+
break;
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
window.addEventListener("message", onMessage);
|
|
1673
|
+
this.registerCleanup(() => window.removeEventListener("message", onMessage));
|
|
1674
|
+
context.onInteraction({ guideId: context.guide.guideId, stepIndex: 0, action: "shown" });
|
|
1675
|
+
}
|
|
1676
|
+
async setupAnchoredPosition(anchor, wrapper, placement) {
|
|
1677
|
+
const side = placement.side ?? "bottom";
|
|
1678
|
+
const update = async () => {
|
|
1679
|
+
const { x, y } = await computePosition(anchor, wrapper, {
|
|
1680
|
+
placement: side,
|
|
1681
|
+
strategy: "fixed",
|
|
1682
|
+
middleware: [offset(placement.offsetY ?? 8), flip(), shift({ padding: 8 })]
|
|
1683
|
+
});
|
|
1684
|
+
wrapper.style.left = `${x}px`;
|
|
1685
|
+
wrapper.style.top = `${y}px`;
|
|
1686
|
+
};
|
|
1687
|
+
await update();
|
|
1688
|
+
const reposition = () => {
|
|
1689
|
+
void update();
|
|
1690
|
+
};
|
|
1691
|
+
window.addEventListener("scroll", reposition, true);
|
|
1692
|
+
window.addEventListener("resize", reposition);
|
|
1693
|
+
return () => {
|
|
1694
|
+
window.removeEventListener("scroll", reposition, true);
|
|
1695
|
+
window.removeEventListener("resize", reposition);
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
function toCssSize(value) {
|
|
1700
|
+
return typeof value === "number" ? `${value}px` : value;
|
|
1701
|
+
}
|
|
1702
|
+
function isRecord(value) {
|
|
1703
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1704
|
+
}
|
|
1705
|
+
function applyFloatingPosition(wrapper, placement) {
|
|
1706
|
+
const position = placement.position ?? "bottom-right";
|
|
1707
|
+
const ox = `${placement.offsetX ?? DEFAULT_OFFSET}px`;
|
|
1708
|
+
const oy = `${placement.offsetY ?? DEFAULT_OFFSET}px`;
|
|
1709
|
+
const s = wrapper.style;
|
|
1710
|
+
if (position === "center") {
|
|
1711
|
+
s.top = "50%";
|
|
1712
|
+
s.left = "50%";
|
|
1713
|
+
s.transform = "translate(-50%, -50%)";
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const [vertical, horizontal] = position.split("-");
|
|
1717
|
+
if (vertical === "top") s.top = oy;
|
|
1718
|
+
else s.bottom = oy;
|
|
1719
|
+
if (horizontal === "left") {
|
|
1720
|
+
s.left = ox;
|
|
1721
|
+
} else if (horizontal === "right") {
|
|
1722
|
+
s.right = ox;
|
|
1723
|
+
} else {
|
|
1724
|
+
s.left = "50%";
|
|
1725
|
+
s.transform = "translateX(-50%)";
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
function buildSrcdoc(html, css, js, token) {
|
|
1729
|
+
const bridge = `(function(){var T=${JSON.stringify(token)};function P(m){m.__veo=T;parent.postMessage(m,'*');}window.veo={dismiss:function(){P({type:'dismiss'});},cta:function(u){P({type:'cta',url:u});},track:function(n,p){P({type:'track',name:n,props:p||{}});},navigate:function(u){P({type:'navigate',url:u});}};function H(){P({type:'resize',height:Math.ceil(document.documentElement.scrollHeight)});}window.addEventListener('load',H);try{if(typeof ResizeObserver!=='undefined'){new ResizeObserver(H).observe(document.documentElement);}}catch(e){}})();`;
|
|
1730
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:transparent;}</style><style>${escapeClosing(css, "style")}</style></head><body>${html}<script>${bridge}</script>` + (js ? `<script>${escapeClosing(js, "script")}</script>` : "") + "</body></html>";
|
|
1731
|
+
}
|
|
1732
|
+
function escapeClosing(code, tag) {
|
|
1733
|
+
const rx = tag === "script" ? /<\/script/gi : /<\/style/gi;
|
|
1734
|
+
return code.replace(rx, `<\\/${tag}`);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// src/plugins/guides/renderers/modal-renderer.ts
|
|
1738
|
+
var ModalRenderer = class extends BaseRenderer {
|
|
1739
|
+
render(context) {
|
|
1740
|
+
const step = context.guide.guideSteps[0];
|
|
1741
|
+
if (!step) return;
|
|
1742
|
+
const { root } = this.createHost();
|
|
1743
|
+
const ownerDocument = root.ownerDocument ?? document;
|
|
1744
|
+
const overlay = ownerDocument.createElement("div");
|
|
1745
|
+
overlay.className = "veo-modal-overlay";
|
|
1746
|
+
const card = ownerDocument.createElement("div");
|
|
1747
|
+
card.className = "veo-modal-card";
|
|
1748
|
+
const dismiss = (action, ctaUrl) => {
|
|
1749
|
+
context.onInteraction({
|
|
1750
|
+
guideId: context.guide.guideId,
|
|
1751
|
+
stepIndex: 0,
|
|
1752
|
+
action
|
|
1753
|
+
});
|
|
1754
|
+
if (ctaUrl) window.open(ctaUrl, "_blank", "noopener,noreferrer");
|
|
1755
|
+
context.onClose();
|
|
1756
|
+
};
|
|
1757
|
+
const content = buildStepContent(step, ownerDocument, {
|
|
1758
|
+
onCtaClick: (action, url) => {
|
|
1759
|
+
dismiss("cta_clicked", action === "url" ? url : void 0);
|
|
1760
|
+
},
|
|
1761
|
+
onDismiss: () => dismiss("dismissed")
|
|
1762
|
+
});
|
|
1763
|
+
card.appendChild(content);
|
|
1764
|
+
overlay.appendChild(card);
|
|
1765
|
+
root.appendChild(overlay);
|
|
1766
|
+
overlay.addEventListener("click", (e) => {
|
|
1767
|
+
if (e.target === overlay) dismiss("dismissed");
|
|
1768
|
+
});
|
|
1769
|
+
const onKey = (e) => {
|
|
1770
|
+
if (e.key === "Escape") dismiss("dismissed");
|
|
1771
|
+
};
|
|
1772
|
+
document.addEventListener("keydown", onKey);
|
|
1773
|
+
this.registerCleanup(() => document.removeEventListener("keydown", onKey));
|
|
1774
|
+
context.onInteraction({
|
|
1775
|
+
guideId: context.guide.guideId,
|
|
1776
|
+
stepIndex: 0,
|
|
1777
|
+
action: "shown"
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
var TooltipRenderer = class extends BaseRenderer {
|
|
1782
|
+
async render(context) {
|
|
1783
|
+
const step = context.guide.guideSteps[0];
|
|
1784
|
+
if (!step) return;
|
|
1785
|
+
const selector = step.selector ?? context.guide.activationRules.selector;
|
|
1786
|
+
if (typeof selector !== "string" || selector.length === 0) return;
|
|
1787
|
+
const anchor = await waitForElement(selector);
|
|
1788
|
+
if (!anchor) return;
|
|
1789
|
+
const { root } = this.createHost();
|
|
1790
|
+
const ownerDocument = root.ownerDocument ?? document;
|
|
1791
|
+
const tooltip = ownerDocument.createElement("div");
|
|
1792
|
+
tooltip.className = "veo-tooltip";
|
|
1793
|
+
const dismiss = (action, ctaUrl) => {
|
|
1794
|
+
context.onInteraction({
|
|
1795
|
+
guideId: context.guide.guideId,
|
|
1796
|
+
stepIndex: 0,
|
|
1797
|
+
action
|
|
1798
|
+
});
|
|
1799
|
+
if (ctaUrl) window.open(ctaUrl, "_blank", "noopener,noreferrer");
|
|
1800
|
+
context.onClose();
|
|
1801
|
+
};
|
|
1802
|
+
const content = buildStepContent(step, ownerDocument, {
|
|
1803
|
+
onCtaClick: (action, url) => {
|
|
1804
|
+
dismiss("cta_clicked", action === "url" ? url : void 0);
|
|
1805
|
+
},
|
|
1806
|
+
onDismiss: () => dismiss("dismissed")
|
|
1807
|
+
});
|
|
1808
|
+
tooltip.appendChild(content);
|
|
1809
|
+
root.appendChild(tooltip);
|
|
1810
|
+
const updatePosition = async () => {
|
|
1811
|
+
const { x, y } = await computePosition(anchor, tooltip, {
|
|
1812
|
+
placement: "bottom",
|
|
1813
|
+
middleware: [offset(8), flip(), shift({ padding: 8 })]
|
|
1814
|
+
});
|
|
1815
|
+
tooltip.style.left = `${x}px`;
|
|
1816
|
+
tooltip.style.top = `${y}px`;
|
|
1817
|
+
};
|
|
1818
|
+
await updatePosition();
|
|
1819
|
+
const reposition = () => {
|
|
1820
|
+
void updatePosition();
|
|
1821
|
+
};
|
|
1822
|
+
window.addEventListener("scroll", reposition, true);
|
|
1823
|
+
window.addEventListener("resize", reposition);
|
|
1824
|
+
this.registerCleanup(() => {
|
|
1825
|
+
window.removeEventListener("scroll", reposition, true);
|
|
1826
|
+
window.removeEventListener("resize", reposition);
|
|
1827
|
+
});
|
|
1828
|
+
context.onInteraction({
|
|
1829
|
+
guideId: context.guide.guideId,
|
|
1830
|
+
stepIndex: 0,
|
|
1831
|
+
action: "shown"
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
|
|
1836
|
+
// src/plugins/guides/walkthrough-block-builder.ts
|
|
1837
|
+
function buildWalkthroughStepContent(step, stepIndex, totalSteps, doc, callbacks) {
|
|
1838
|
+
const container = doc.createElement("div");
|
|
1839
|
+
container.className = "veo-guide-content";
|
|
1840
|
+
const counter = doc.createElement("div");
|
|
1841
|
+
counter.className = "veo-walkthrough-counter";
|
|
1842
|
+
counter.textContent = `Paso ${stepIndex + 1} de ${totalSteps}`;
|
|
1843
|
+
container.appendChild(counter);
|
|
1844
|
+
const progress = doc.createElement("div");
|
|
1845
|
+
progress.className = "veo-walkthrough-progress";
|
|
1846
|
+
for (let i = 0; i < totalSteps; i++) {
|
|
1847
|
+
const dot = doc.createElement("span");
|
|
1848
|
+
dot.className = "veo-walkthrough-progress-dot";
|
|
1849
|
+
if (i < stepIndex) dot.classList.add("completed");
|
|
1850
|
+
if (i === stepIndex) dot.classList.add("active");
|
|
1851
|
+
progress.appendChild(dot);
|
|
1852
|
+
}
|
|
1853
|
+
container.appendChild(progress);
|
|
1854
|
+
if (typeof step.imageUrl === "string" && step.imageUrl && isSafeUrl(step.imageUrl)) {
|
|
1855
|
+
const img = doc.createElement("img");
|
|
1856
|
+
img.className = "veo-guide-image";
|
|
1857
|
+
img.src = step.imageUrl;
|
|
1858
|
+
img.alt = typeof step.title === "string" ? step.title : "";
|
|
1859
|
+
container.appendChild(img);
|
|
1860
|
+
}
|
|
1861
|
+
if (typeof step.title === "string" && step.title) {
|
|
1862
|
+
const heading = doc.createElement("h2");
|
|
1863
|
+
heading.className = "veo-guide-title";
|
|
1864
|
+
heading.textContent = step.title;
|
|
1865
|
+
container.appendChild(heading);
|
|
1866
|
+
}
|
|
1867
|
+
if (typeof step.content === "string" && step.content) {
|
|
1868
|
+
const paragraph = doc.createElement("p");
|
|
1869
|
+
paragraph.className = "veo-guide-text";
|
|
1870
|
+
paragraph.textContent = step.content;
|
|
1871
|
+
container.appendChild(paragraph);
|
|
1872
|
+
}
|
|
1873
|
+
const actions = doc.createElement("div");
|
|
1874
|
+
actions.className = "veo-walkthrough-actions";
|
|
1875
|
+
const skipBtn = doc.createElement("button");
|
|
1876
|
+
skipBtn.type = "button";
|
|
1877
|
+
skipBtn.className = "veo-walkthrough-skip";
|
|
1878
|
+
skipBtn.textContent = "Omitir";
|
|
1879
|
+
skipBtn.addEventListener("click", () => callbacks.onSkip());
|
|
1880
|
+
actions.appendChild(skipBtn);
|
|
1881
|
+
const rightGroup = doc.createElement("div");
|
|
1882
|
+
rightGroup.className = "veo-walkthrough-actions-right";
|
|
1883
|
+
if (stepIndex > 0) {
|
|
1884
|
+
const backBtn = doc.createElement("button");
|
|
1885
|
+
backBtn.type = "button";
|
|
1886
|
+
backBtn.className = "veo-walkthrough-btn-secondary";
|
|
1887
|
+
backBtn.textContent = "Atr\xE1s";
|
|
1888
|
+
backBtn.addEventListener("click", () => callbacks.onBack());
|
|
1889
|
+
rightGroup.appendChild(backBtn);
|
|
1890
|
+
}
|
|
1891
|
+
const isLastStep = stepIndex === totalSteps - 1;
|
|
1892
|
+
const primaryBtn = doc.createElement("button");
|
|
1893
|
+
primaryBtn.type = "button";
|
|
1894
|
+
primaryBtn.className = "veo-guide-cta";
|
|
1895
|
+
const defaultLabel = isLastStep ? "Finalizar" : "Siguiente";
|
|
1896
|
+
primaryBtn.textContent = typeof step.ctaText === "string" && step.ctaText ? step.ctaText : defaultLabel;
|
|
1897
|
+
primaryBtn.addEventListener("click", () => {
|
|
1898
|
+
if (isLastStep) callbacks.onComplete();
|
|
1899
|
+
else callbacks.onNext();
|
|
1900
|
+
});
|
|
1901
|
+
rightGroup.appendChild(primaryBtn);
|
|
1902
|
+
actions.appendChild(rightGroup);
|
|
1903
|
+
container.appendChild(actions);
|
|
1904
|
+
return container;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/plugins/guides/renderers/walkthrough-renderer.ts
|
|
1908
|
+
var WalkthroughRenderer = class extends BaseRenderer {
|
|
1909
|
+
async render(context) {
|
|
1910
|
+
const step = context.guide.guideSteps[context.currentStepIndex];
|
|
1911
|
+
if (!step) return false;
|
|
1912
|
+
const selector = step.selector ?? context.guide.activationRules.selector;
|
|
1913
|
+
if (typeof selector !== "string" || selector.length === 0) return false;
|
|
1914
|
+
const anchor = await waitForElement(selector, WALKTHROUGH_STEP_SELECTOR_TIMEOUT_MS);
|
|
1915
|
+
if (!anchor) return false;
|
|
1916
|
+
const { root } = this.createHost();
|
|
1917
|
+
const ownerDocument = root.ownerDocument ?? document;
|
|
1918
|
+
const tooltip = ownerDocument.createElement("div");
|
|
1919
|
+
tooltip.className = "veo-walkthrough";
|
|
1920
|
+
const content = buildWalkthroughStepContent(
|
|
1921
|
+
step,
|
|
1922
|
+
context.currentStepIndex,
|
|
1923
|
+
context.guide.guideSteps.length,
|
|
1924
|
+
ownerDocument,
|
|
1925
|
+
context.callbacks
|
|
1926
|
+
);
|
|
1927
|
+
tooltip.appendChild(content);
|
|
1928
|
+
root.appendChild(tooltip);
|
|
1929
|
+
const updatePosition = async () => {
|
|
1930
|
+
const { x, y } = await computePosition(anchor, tooltip, {
|
|
1931
|
+
placement: "bottom",
|
|
1932
|
+
middleware: [offset(8), flip(), shift({ padding: 8 })]
|
|
1933
|
+
});
|
|
1934
|
+
tooltip.style.left = `${x}px`;
|
|
1935
|
+
tooltip.style.top = `${y}px`;
|
|
1936
|
+
};
|
|
1937
|
+
await updatePosition();
|
|
1938
|
+
const reposition = () => {
|
|
1939
|
+
void updatePosition();
|
|
1940
|
+
};
|
|
1941
|
+
window.addEventListener("scroll", reposition, true);
|
|
1942
|
+
window.addEventListener("resize", reposition);
|
|
1943
|
+
this.registerCleanup(() => {
|
|
1944
|
+
window.removeEventListener("scroll", reposition, true);
|
|
1945
|
+
window.removeEventListener("resize", reposition);
|
|
1946
|
+
});
|
|
1947
|
+
return true;
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
// src/plugins/guides/walkthrough-state.ts
|
|
1952
|
+
var WalkthroughStateManager = class {
|
|
1953
|
+
constructor(namespace) {
|
|
1954
|
+
this.state = null;
|
|
1955
|
+
this.storageKey = `${WALKTHROUGH_STATE_KEY_PREFIX}${namespace}`;
|
|
1956
|
+
this.state = this.load();
|
|
1957
|
+
if (this.state && this.isAbandoned(this.state)) {
|
|
1958
|
+
this.clear();
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Estado del walkthrough activo. Devuelve `null` si no hay ninguno o si
|
|
1963
|
+
* el último progreso fue hace más del timeout (en ese caso también
|
|
1964
|
+
* limpia el storage como side-effect).
|
|
1965
|
+
*/
|
|
1966
|
+
current() {
|
|
1967
|
+
if (!this.state) return null;
|
|
1968
|
+
if (this.isAbandoned(this.state)) {
|
|
1969
|
+
this.clear();
|
|
1970
|
+
return null;
|
|
1971
|
+
}
|
|
1972
|
+
return this.state;
|
|
1973
|
+
}
|
|
1974
|
+
/** Atajo legible para el controller. */
|
|
1975
|
+
hasActive() {
|
|
1976
|
+
return this.current() !== null;
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Inicia un nuevo walkthrough en step 0. Sobreescribe cualquier estado
|
|
1980
|
+
* previo — los callers (controller) son responsables de verificar
|
|
1981
|
+
* `hasActive()` antes si necesitan exclusividad.
|
|
1982
|
+
*/
|
|
1983
|
+
start(guideId, totalSteps) {
|
|
1984
|
+
const now = Date.now();
|
|
1985
|
+
this.state = {
|
|
1986
|
+
guideId,
|
|
1987
|
+
totalSteps,
|
|
1988
|
+
currentStepIndex: 0,
|
|
1989
|
+
startedAt: now,
|
|
1990
|
+
lastProgressAt: now
|
|
1991
|
+
};
|
|
1992
|
+
this.persist();
|
|
1993
|
+
return this.state;
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Avanza al siguiente step. Si el step resultante excede `totalSteps`,
|
|
1997
|
+
* devuelve `null` y NO toca el estado — el controller debe interpretar
|
|
1998
|
+
* eso como "ya estamos en el último, no hay próximo" y disparar
|
|
1999
|
+
* `complete` vía el handler del botón "Finalizar".
|
|
2000
|
+
*/
|
|
2001
|
+
advance() {
|
|
2002
|
+
if (!this.state) return null;
|
|
2003
|
+
const next = this.state.currentStepIndex + 1;
|
|
2004
|
+
if (next >= this.state.totalSteps) return null;
|
|
2005
|
+
this.state = {
|
|
2006
|
+
...this.state,
|
|
2007
|
+
currentStepIndex: next,
|
|
2008
|
+
lastProgressAt: Date.now()
|
|
2009
|
+
};
|
|
2010
|
+
this.persist();
|
|
2011
|
+
return this.state;
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Retrocede al step anterior. En step 0 no hace nada y devuelve el
|
|
2015
|
+
* estado tal cual (el botón "Atrás" no debería estar visible en el
|
|
2016
|
+
* primer step, pero por defensa el manager también lo guard-ea).
|
|
2017
|
+
*/
|
|
2018
|
+
back() {
|
|
2019
|
+
if (!this.state) return null;
|
|
2020
|
+
if (this.state.currentStepIndex === 0) return this.state;
|
|
2021
|
+
this.state = {
|
|
2022
|
+
...this.state,
|
|
2023
|
+
currentStepIndex: this.state.currentStepIndex - 1,
|
|
2024
|
+
lastProgressAt: Date.now()
|
|
2025
|
+
};
|
|
2026
|
+
this.persist();
|
|
2027
|
+
return this.state;
|
|
2028
|
+
}
|
|
2029
|
+
/** Limpia el estado activo (skip, complete, abandono, guía borrada). */
|
|
2030
|
+
clear() {
|
|
2031
|
+
this.state = null;
|
|
2032
|
+
if (typeof localStorage === "undefined") return;
|
|
2033
|
+
try {
|
|
2034
|
+
localStorage.removeItem(this.storageKey);
|
|
2035
|
+
} catch {
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
isAbandoned(state) {
|
|
2039
|
+
return Date.now() - state.lastProgressAt > WALKTHROUGH_ABANDONMENT_TIMEOUT_MS;
|
|
2040
|
+
}
|
|
2041
|
+
load() {
|
|
2042
|
+
if (typeof localStorage === "undefined") return null;
|
|
2043
|
+
try {
|
|
2044
|
+
const raw = localStorage.getItem(this.storageKey);
|
|
2045
|
+
if (!raw) return null;
|
|
2046
|
+
const parsed = JSON.parse(raw);
|
|
2047
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
2048
|
+
const candidate = parsed;
|
|
2049
|
+
if (typeof candidate.guideId !== "string" || typeof candidate.totalSteps !== "number" || typeof candidate.currentStepIndex !== "number" || typeof candidate.startedAt !== "number" || typeof candidate.lastProgressAt !== "number") {
|
|
2050
|
+
return null;
|
|
2051
|
+
}
|
|
2052
|
+
return candidate;
|
|
2053
|
+
} catch {
|
|
2054
|
+
return null;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
persist() {
|
|
2058
|
+
if (typeof localStorage === "undefined" || !this.state) return;
|
|
2059
|
+
try {
|
|
2060
|
+
localStorage.setItem(this.storageKey, JSON.stringify(this.state));
|
|
2061
|
+
} catch {
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
};
|
|
2065
|
+
|
|
2066
|
+
// src/plugins/guides/guides-controller.ts
|
|
2067
|
+
var STATUS_BY_ACTION = {
|
|
2068
|
+
shown: "shown",
|
|
2069
|
+
dismissed: "dismissed",
|
|
2070
|
+
cta_clicked: null,
|
|
2071
|
+
step_advanced: null,
|
|
2072
|
+
step_back: null,
|
|
2073
|
+
completed: "completed"
|
|
2074
|
+
};
|
|
2075
|
+
var GuidesController = class {
|
|
2076
|
+
constructor(client, config) {
|
|
2077
|
+
this.client = client;
|
|
2078
|
+
this.activeRenderers = /* @__PURE__ */ new Set();
|
|
2079
|
+
this.dispatched = /* @__PURE__ */ new Set();
|
|
2080
|
+
this.activeWalkthroughRenderer = null;
|
|
2081
|
+
this.activeWalkthroughGuide = null;
|
|
2082
|
+
this.debug = config.debug === true;
|
|
2083
|
+
this.resolver = config.resolver ?? new GuideResolverClient(config.apiUrl, config.apiKey, this.debug);
|
|
2084
|
+
if (config.trackerQueue) {
|
|
2085
|
+
this.trackerQueue = config.trackerQueue;
|
|
2086
|
+
} else {
|
|
2087
|
+
const httpClient = new GuideTrackerClient(config.apiUrl, config.apiKey);
|
|
2088
|
+
this.trackerQueue = new GuideTrackerQueue({
|
|
2089
|
+
client: httpClient,
|
|
2090
|
+
flushIntervalMs: DEFAULT_TRACKER_FLUSH_INTERVAL_MS,
|
|
2091
|
+
batchSize: DEFAULT_TRACKER_BATCH_SIZE,
|
|
2092
|
+
maxRetries: DEFAULT_TRACKER_MAX_RETRIES,
|
|
2093
|
+
onError: (err, item) => {
|
|
2094
|
+
if (this.debug) {
|
|
2095
|
+
console.warn(
|
|
2096
|
+
`[veo] dropped guide interaction after retries: guideId=${item.guideId} action=${item.payload.action}`,
|
|
2097
|
+
err
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
const namespace = namespaceFromApiKey(config.apiKey);
|
|
2104
|
+
this.frequencyCache = config.frequencyCache ?? new GuideFrequencyCache(namespace);
|
|
2105
|
+
this.walkthroughState = config.walkthroughState ?? new WalkthroughStateManager(namespace);
|
|
2106
|
+
}
|
|
2107
|
+
/** Cuántas interactions hay pendientes de envío. Útil para debug en demo. */
|
|
2108
|
+
get pendingInteractions() {
|
|
2109
|
+
return this.trackerQueue.size;
|
|
2110
|
+
}
|
|
2111
|
+
/** Limpia el cache local (botón "reset" del demo, tests). */
|
|
2112
|
+
clearFrequencyCache() {
|
|
2113
|
+
this.frequencyCache.clear();
|
|
2114
|
+
}
|
|
2115
|
+
/** Limpia el walkthrough activo (debug del demo). */
|
|
2116
|
+
clearWalkthroughState() {
|
|
2117
|
+
this.tearDownWalkthrough();
|
|
2118
|
+
}
|
|
2119
|
+
/** Punto de entrada principal. Llamado en cada pageview/identify. */
|
|
2120
|
+
async checkGuides(pageUrl, sessionId) {
|
|
2121
|
+
const endUserId = this.client.getEndUserId();
|
|
2122
|
+
if (!endUserId) {
|
|
2123
|
+
if (this.debug) console.log("[veo] guides: skipped, no endUserId yet");
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
if (await this.tryResumeWalkthrough(endUserId, sessionId, pageUrl)) {
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
let guides;
|
|
2130
|
+
try {
|
|
2131
|
+
guides = await this.resolver.resolve(endUserId, pageUrl);
|
|
2132
|
+
} catch (err) {
|
|
2133
|
+
if (this.debug) console.error("[veo] guides resolver threw:", err);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (this.debug) console.log(`[veo] guides: ${guides.length} eligible at ${pageUrl}`);
|
|
2137
|
+
for (const guide of guides) {
|
|
2138
|
+
if (this.frequencyCache.shouldFilter(guide.guideId, guide.displayFrequency)) {
|
|
2139
|
+
if (this.debug) console.log(`[veo] guides: filtered locally guideId=${guide.guideId}`);
|
|
2140
|
+
continue;
|
|
2141
|
+
}
|
|
2142
|
+
if (guide.guideType === "walkthrough" && guide.guideSteps.length > 1) {
|
|
2143
|
+
if (this.walkthroughState.hasActive()) {
|
|
2144
|
+
if (this.debug)
|
|
2145
|
+
console.log(`[veo] guides: walkthrough ${guide.guideId} skipped (another active)`);
|
|
2146
|
+
continue;
|
|
2147
|
+
}
|
|
2148
|
+
await this.startWalkthrough(guide, endUserId, sessionId, pageUrl);
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
this.runSingleStepRender(guide, sessionId, pageUrl);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
/** Cierra todas las guías activas, limpia cache de dispatch y para timers. */
|
|
2155
|
+
destroy() {
|
|
2156
|
+
for (const renderer of this.activeRenderers) {
|
|
2157
|
+
try {
|
|
2158
|
+
renderer.destroy();
|
|
2159
|
+
} catch (err) {
|
|
2160
|
+
if (this.debug) console.error("[veo] renderer destroy failed:", err);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
this.activeRenderers.clear();
|
|
2164
|
+
if (this.activeWalkthroughRenderer) {
|
|
2165
|
+
try {
|
|
2166
|
+
this.activeWalkthroughRenderer.destroy();
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
this.activeWalkthroughRenderer = null;
|
|
2170
|
+
this.activeWalkthroughGuide = null;
|
|
2171
|
+
}
|
|
2172
|
+
this.dispatched.clear();
|
|
2173
|
+
this.trackerQueue.destroy();
|
|
2174
|
+
}
|
|
2175
|
+
runSingleStepRender(guide, sessionId, pageUrl) {
|
|
2176
|
+
const renderer = this.createSingleStepRenderer(guide.guideType);
|
|
2177
|
+
if (!renderer) return;
|
|
2178
|
+
const delayMs = guide.activationRules.delayMs ?? 0;
|
|
2179
|
+
window.setTimeout(
|
|
2180
|
+
() => this.mountSingleStepRenderer(guide, renderer, sessionId, pageUrl),
|
|
2181
|
+
delayMs
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
2184
|
+
mountSingleStepRenderer(guide, renderer, sessionId, pageUrl) {
|
|
2185
|
+
this.activeRenderers.add(renderer);
|
|
2186
|
+
const onClose = () => {
|
|
2187
|
+
renderer.destroy();
|
|
2188
|
+
this.activeRenderers.delete(renderer);
|
|
2189
|
+
};
|
|
2190
|
+
const onInteraction = (event) => {
|
|
2191
|
+
this.handleInteraction(guide, sessionId, pageUrl, event);
|
|
2192
|
+
};
|
|
2193
|
+
const onTrack = (eventName, properties) => {
|
|
2194
|
+
this.client.track(eventName, properties);
|
|
2195
|
+
};
|
|
2196
|
+
try {
|
|
2197
|
+
void renderer.render({ guide, onInteraction, onClose, onTrack });
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
if (this.debug) console.error("[veo] renderer.render threw:", err);
|
|
2200
|
+
this.activeRenderers.delete(renderer);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Si hay un walkthrough persistido, intenta pintar el step actual en
|
|
2205
|
+
* la página actual. Retorna `true` si lo intentó (sea éxito o cancel),
|
|
2206
|
+
* `false` si no había walkthrough activo.
|
|
2207
|
+
*
|
|
2208
|
+
* Cancela limpiamente (sin enviar `dismissed` falso) cuando:
|
|
2209
|
+
* - La guía fue eliminada en el backend (404 → `fetchById` devuelve null).
|
|
2210
|
+
* - La guía cambió de tipo y ya no es walkthrough.
|
|
2211
|
+
* - El selector del step actual no aparece dentro del timeout.
|
|
2212
|
+
*/
|
|
2213
|
+
async tryResumeWalkthrough(endUserId, sessionId, pageUrl) {
|
|
2214
|
+
const state = this.walkthroughState.current();
|
|
2215
|
+
if (!state) return false;
|
|
2216
|
+
const guide = await this.resolver.fetchById(state.guideId);
|
|
2217
|
+
if (!guide || guide.guideType !== "walkthrough") {
|
|
2218
|
+
if (this.debug)
|
|
2219
|
+
console.log(`[veo] guides: walkthrough ${state.guideId} no longer available, cleaning up`);
|
|
2220
|
+
this.tearDownWalkthrough();
|
|
2221
|
+
return true;
|
|
2222
|
+
}
|
|
2223
|
+
const stepIndex = Math.min(state.currentStepIndex, guide.guideSteps.length - 1);
|
|
2224
|
+
await this.renderWalkthroughStep(guide, stepIndex, endUserId, sessionId, pageUrl);
|
|
2225
|
+
return true;
|
|
2226
|
+
}
|
|
2227
|
+
async startWalkthrough(guide, endUserId, sessionId, pageUrl) {
|
|
2228
|
+
this.walkthroughState.start(guide.guideId, guide.guideSteps.length);
|
|
2229
|
+
const rendered = await this.renderWalkthroughStep(guide, 0, endUserId, sessionId, pageUrl);
|
|
2230
|
+
if (!rendered) return;
|
|
2231
|
+
this.enqueueInteractionOnce(guide.guideId, endUserId, sessionId, pageUrl, "shown", 0);
|
|
2232
|
+
this.frequencyCache.record(guide.guideId, "shown");
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* Pinta el step indicado. Reusa la guía (no fetcha): el caller debe
|
|
2236
|
+
* traerla. Retorna `true` si el render fue exitoso. En caso contrario
|
|
2237
|
+
* ya disparó el teardown — el caller no debe seguir reportando.
|
|
2238
|
+
*/
|
|
2239
|
+
async renderWalkthroughStep(guide, stepIndex, endUserId, sessionId, pageUrl) {
|
|
2240
|
+
if (this.activeWalkthroughRenderer) {
|
|
2241
|
+
this.activeWalkthroughRenderer.destroy();
|
|
2242
|
+
this.activeWalkthroughRenderer = null;
|
|
2243
|
+
}
|
|
2244
|
+
const renderer = new WalkthroughRenderer();
|
|
2245
|
+
this.activeWalkthroughGuide = guide;
|
|
2246
|
+
const callbacks = {
|
|
2247
|
+
onNext: () => this.handleWalkthroughNext(endUserId, sessionId, pageUrl),
|
|
2248
|
+
onBack: () => this.handleWalkthroughBack(endUserId, sessionId, pageUrl),
|
|
2249
|
+
onSkip: () => this.handleWalkthroughSkip(endUserId, sessionId, pageUrl),
|
|
2250
|
+
onComplete: () => this.handleWalkthroughComplete(endUserId, sessionId, pageUrl)
|
|
2251
|
+
};
|
|
2252
|
+
let success = false;
|
|
2253
|
+
try {
|
|
2254
|
+
success = await renderer.render({ guide, currentStepIndex: stepIndex, callbacks });
|
|
2255
|
+
} catch (err) {
|
|
2256
|
+
if (this.debug) console.error("[veo] walkthrough renderer threw:", err);
|
|
2257
|
+
}
|
|
2258
|
+
if (!success) {
|
|
2259
|
+
if (this.debug)
|
|
2260
|
+
console.warn(
|
|
2261
|
+
`[veo] walkthrough ${guide.guideId} step ${stepIndex} could not render, cancelling`
|
|
2262
|
+
);
|
|
2263
|
+
this.tearDownWalkthrough();
|
|
2264
|
+
return false;
|
|
2265
|
+
}
|
|
2266
|
+
this.activeWalkthroughRenderer = renderer;
|
|
2267
|
+
return true;
|
|
2268
|
+
}
|
|
2269
|
+
handleWalkthroughNext(endUserId, sessionId, pageUrl) {
|
|
2270
|
+
const guide = this.activeWalkthroughGuide;
|
|
2271
|
+
if (!guide) return;
|
|
2272
|
+
const newState = this.walkthroughState.advance();
|
|
2273
|
+
if (!newState) {
|
|
2274
|
+
this.handleWalkthroughComplete(endUserId, sessionId, pageUrl);
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
this.enqueueInteraction(
|
|
2278
|
+
guide.guideId,
|
|
2279
|
+
endUserId,
|
|
2280
|
+
sessionId,
|
|
2281
|
+
pageUrl,
|
|
2282
|
+
"step_advanced",
|
|
2283
|
+
newState.currentStepIndex
|
|
2284
|
+
);
|
|
2285
|
+
void this.renderWalkthroughStep(
|
|
2286
|
+
guide,
|
|
2287
|
+
newState.currentStepIndex,
|
|
2288
|
+
endUserId,
|
|
2289
|
+
sessionId,
|
|
2290
|
+
pageUrl
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
handleWalkthroughBack(endUserId, sessionId, pageUrl) {
|
|
2294
|
+
const guide = this.activeWalkthroughGuide;
|
|
2295
|
+
if (!guide) return;
|
|
2296
|
+
const state = this.walkthroughState.current();
|
|
2297
|
+
if (!state || state.currentStepIndex === 0) return;
|
|
2298
|
+
const newState = this.walkthroughState.back();
|
|
2299
|
+
if (!newState) return;
|
|
2300
|
+
this.enqueueInteraction(
|
|
2301
|
+
guide.guideId,
|
|
2302
|
+
endUserId,
|
|
2303
|
+
sessionId,
|
|
2304
|
+
pageUrl,
|
|
2305
|
+
"step_back",
|
|
2306
|
+
newState.currentStepIndex
|
|
2307
|
+
);
|
|
2308
|
+
void this.renderWalkthroughStep(
|
|
2309
|
+
guide,
|
|
2310
|
+
newState.currentStepIndex,
|
|
2311
|
+
endUserId,
|
|
2312
|
+
sessionId,
|
|
2313
|
+
pageUrl
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
handleWalkthroughSkip(endUserId, sessionId, pageUrl) {
|
|
2317
|
+
const guide = this.activeWalkthroughGuide;
|
|
2318
|
+
const state = this.walkthroughState.current();
|
|
2319
|
+
if (!guide || !state) return;
|
|
2320
|
+
this.enqueueInteraction(
|
|
2321
|
+
guide.guideId,
|
|
2322
|
+
endUserId,
|
|
2323
|
+
sessionId,
|
|
2324
|
+
pageUrl,
|
|
2325
|
+
"dismissed",
|
|
2326
|
+
state.currentStepIndex
|
|
2327
|
+
);
|
|
2328
|
+
this.frequencyCache.record(guide.guideId, "dismissed");
|
|
2329
|
+
this.tearDownWalkthrough();
|
|
2330
|
+
}
|
|
2331
|
+
handleWalkthroughComplete(endUserId, sessionId, pageUrl) {
|
|
2332
|
+
const guide = this.activeWalkthroughGuide;
|
|
2333
|
+
const state = this.walkthroughState.current();
|
|
2334
|
+
if (!guide || !state) return;
|
|
2335
|
+
this.enqueueInteraction(
|
|
2336
|
+
guide.guideId,
|
|
2337
|
+
endUserId,
|
|
2338
|
+
sessionId,
|
|
2339
|
+
pageUrl,
|
|
2340
|
+
"completed",
|
|
2341
|
+
state.currentStepIndex
|
|
2342
|
+
);
|
|
2343
|
+
this.frequencyCache.record(guide.guideId, "completed");
|
|
2344
|
+
this.tearDownWalkthrough();
|
|
2345
|
+
}
|
|
2346
|
+
tearDownWalkthrough() {
|
|
2347
|
+
if (this.activeWalkthroughRenderer) {
|
|
2348
|
+
try {
|
|
2349
|
+
this.activeWalkthroughRenderer.destroy();
|
|
2350
|
+
} catch {
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
this.activeWalkthroughRenderer = null;
|
|
2354
|
+
this.activeWalkthroughGuide = null;
|
|
2355
|
+
this.walkthroughState.clear();
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Tras cada interaction single-step:
|
|
2359
|
+
* 1. dedupe (no enviar 2× la misma `(guideId, action)` por sesión)
|
|
2360
|
+
* 2. actualizar el cache local si corresponde
|
|
2361
|
+
* 3. encolar el POST
|
|
2362
|
+
*
|
|
2363
|
+
* El endUserId se lee del client EN ESTE MOMENTO (no en el render),
|
|
2364
|
+
* por si el usuario se identificó después de que la guía apareció.
|
|
2365
|
+
*/
|
|
2366
|
+
handleInteraction(guide, sessionId, pageUrl, event) {
|
|
2367
|
+
const dedupKey = `${guide.guideId}:${event.action}`;
|
|
2368
|
+
if (this.dispatched.has(dedupKey)) {
|
|
2369
|
+
if (this.debug) console.log(`[veo] guides: dedup hit ${dedupKey}`);
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
this.dispatched.add(dedupKey);
|
|
2373
|
+
const cacheStatus = STATUS_BY_ACTION[event.action];
|
|
2374
|
+
if (cacheStatus) this.frequencyCache.record(guide.guideId, cacheStatus);
|
|
2375
|
+
const endUserId = this.client.getEndUserId();
|
|
2376
|
+
if (!endUserId) {
|
|
2377
|
+
if (this.debug)
|
|
2378
|
+
console.warn("[veo] guides: interaction without endUserId \u2014 skipping enqueue");
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
this.trackerQueue.enqueue({
|
|
2382
|
+
guideId: guide.guideId,
|
|
2383
|
+
payload: {
|
|
2384
|
+
endUserId,
|
|
2385
|
+
sessionId,
|
|
2386
|
+
action: event.action,
|
|
2387
|
+
stepIndex: event.stepIndex,
|
|
2388
|
+
pageUrl,
|
|
2389
|
+
pagePath: extractPath(pageUrl)
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Encola una interacción de walkthrough SIN pasar por el dedup set.
|
|
2395
|
+
* Acciones como `step_advanced` se repiten naturalmente (cada next es
|
|
2396
|
+
* una navegación distinta) y deduplicarlas perdería datos.
|
|
2397
|
+
*/
|
|
2398
|
+
enqueueInteraction(guideId, endUserId, sessionId, pageUrl, action, stepIndex) {
|
|
2399
|
+
this.trackerQueue.enqueue({
|
|
2400
|
+
guideId,
|
|
2401
|
+
payload: {
|
|
2402
|
+
endUserId,
|
|
2403
|
+
sessionId,
|
|
2404
|
+
action,
|
|
2405
|
+
stepIndex,
|
|
2406
|
+
pageUrl,
|
|
2407
|
+
pagePath: extractPath(pageUrl)
|
|
2408
|
+
}
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Encola con dedup por sesión del SDK. Para `shown` del primer step:
|
|
2413
|
+
* no queremos re-emitirlo si el usuario navega entre dos páginas que
|
|
2414
|
+
* matchean el mismo step.
|
|
2415
|
+
*/
|
|
2416
|
+
enqueueInteractionOnce(guideId, endUserId, sessionId, pageUrl, action, stepIndex) {
|
|
2417
|
+
const dedupKey = `${guideId}:${action}:${stepIndex}`;
|
|
2418
|
+
if (this.dispatched.has(dedupKey)) return;
|
|
2419
|
+
this.dispatched.add(dedupKey);
|
|
2420
|
+
this.enqueueInteraction(guideId, endUserId, sessionId, pageUrl, action, stepIndex);
|
|
2421
|
+
}
|
|
2422
|
+
createSingleStepRenderer(type) {
|
|
2423
|
+
switch (type) {
|
|
2424
|
+
case "modal":
|
|
2425
|
+
return new ModalRenderer();
|
|
2426
|
+
case "banner":
|
|
2427
|
+
return new BannerRenderer();
|
|
2428
|
+
case "tooltip":
|
|
2429
|
+
return new TooltipRenderer();
|
|
2430
|
+
case "custom":
|
|
2431
|
+
return new CustomRenderer();
|
|
2432
|
+
case "walkthrough":
|
|
2433
|
+
return null;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
};
|
|
2437
|
+
function extractPath(url) {
|
|
2438
|
+
try {
|
|
2439
|
+
return new URL(url).pathname;
|
|
2440
|
+
} catch {
|
|
2441
|
+
return "/";
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// src/plugins/guides/index.ts
|
|
2446
|
+
function guidesPlugin(config) {
|
|
2447
|
+
return definePlugin({
|
|
2448
|
+
name: "guides",
|
|
2449
|
+
install(client) {
|
|
2450
|
+
if (!hasWindow()) return;
|
|
2451
|
+
const controller = new GuidesController(client, config);
|
|
2452
|
+
window.__veoGuides = { controller };
|
|
2453
|
+
client.on("identify", ({ sessionId }) => {
|
|
2454
|
+
void controller.checkGuides(window.location.href, sessionId);
|
|
2455
|
+
});
|
|
2456
|
+
client.on("pageview", ({ url, sessionId }) => {
|
|
2457
|
+
void controller.checkGuides(url, sessionId);
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/plugins/spa-router.ts
|
|
2464
|
+
function spaRouterPlugin() {
|
|
2465
|
+
return definePlugin({
|
|
2466
|
+
name: "spa-router",
|
|
2467
|
+
install(client) {
|
|
2468
|
+
if (!hasWindow()) return;
|
|
2469
|
+
client.pageview();
|
|
2470
|
+
const fire = () => {
|
|
2471
|
+
queueMicrotask(() => client.pageview());
|
|
2472
|
+
};
|
|
2473
|
+
const origPushState = window.history.pushState;
|
|
2474
|
+
const origReplaceState = window.history.replaceState;
|
|
2475
|
+
window.history.pushState = function(...args) {
|
|
2476
|
+
origPushState.apply(this, args);
|
|
2477
|
+
fire();
|
|
2478
|
+
};
|
|
2479
|
+
window.history.replaceState = function(...args) {
|
|
2480
|
+
origReplaceState.apply(this, args);
|
|
2481
|
+
fire();
|
|
2482
|
+
};
|
|
2483
|
+
window.addEventListener("popstate", fire);
|
|
2484
|
+
window.addEventListener("hashchange", fire);
|
|
2485
|
+
}
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// src/index.ts
|
|
2490
|
+
function init(config) {
|
|
2491
|
+
const client = new Client(config);
|
|
2492
|
+
client.use(spaRouterPlugin());
|
|
2493
|
+
if (config.autocapture !== false) {
|
|
2494
|
+
const autocaptureConfig = typeof config.autocapture === "object" ? config.autocapture : {};
|
|
2495
|
+
client.use(autocapturePlugin(autocaptureConfig));
|
|
2496
|
+
}
|
|
2497
|
+
if (config.guides === true) {
|
|
2498
|
+
client.use(
|
|
2499
|
+
guidesPlugin({
|
|
2500
|
+
apiUrl: config.apiUrl,
|
|
2501
|
+
apiKey: config.apiKey,
|
|
2502
|
+
...config.debug !== void 0 ? { debug: config.debug } : {}
|
|
2503
|
+
})
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
if (typeof window !== "undefined") {
|
|
2507
|
+
const stub = window.veo;
|
|
2508
|
+
const pending = stub?._q ?? [];
|
|
2509
|
+
window.veo = client;
|
|
2510
|
+
for (const entry of pending) {
|
|
2511
|
+
const [method, args] = entry;
|
|
2512
|
+
if (method === "init") continue;
|
|
2513
|
+
const fn = client[method];
|
|
2514
|
+
if (typeof fn === "function") {
|
|
2515
|
+
try {
|
|
2516
|
+
fn.apply(client, Array.from(args));
|
|
2517
|
+
} catch (err) {
|
|
2518
|
+
if (config.debug) console.error("[veo] drain error:", err);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
return client;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
export { Client, SDK_VERSION, autocapturePlugin, definePlugin, guidesPlugin, init, spaRouterPlugin };
|
|
2527
|
+
//# sourceMappingURL=index.mjs.map
|
|
2528
|
+
//# sourceMappingURL=index.mjs.map
|