@magicfeedback/popup-sdk 1.8.1 → 1.8.3

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.
@@ -968,10 +968,13 @@ function createDefaultRenderer() {
968
968
 
969
969
  // src/analytics/analytics-manager.ts
970
970
  function createDryRunSink(log = console.log) {
971
- return (payload) => log(
972
- "[DeepdotsAnalytics] (dry-run \xB7 NOT sent \xB7 no init.analytics) POST /sdk/feedback \u2192",
973
- JSON.stringify(payload, null, 2)
974
- );
971
+ return (payload, meta) => {
972
+ if (meta?.resetSession) return;
973
+ log(
974
+ "[DeepdotsAnalytics] (dry-run \xB7 NOT sent \xB7 no init.analytics) POST /sdk/feedback \u2192",
975
+ JSON.stringify(payload, null, 2)
976
+ );
977
+ };
975
978
  }
976
979
  var dryRunSink = createDryRunSink();
977
980
  var AnalyticsManager = class {
@@ -1038,6 +1041,19 @@ var AnalyticsManager = class {
1038
1041
  this.exitMiniService(name);
1039
1042
  }
1040
1043
  }
1044
+ /**
1045
+ * Olvida la sesión en curso SIN emitir nada: vacía el buffer y los mini-services activos
1046
+ * (sin `mini_service_exit`). Para una sesión que el backend ya cerró por inactividad: lo
1047
+ * que quedara pendiente pertenecía a ella y no debe llegar con la sesión nueva.
1048
+ */
1049
+ discardSession(identity) {
1050
+ this.events = [];
1051
+ this.activeMiniServices.clear();
1052
+ try {
1053
+ void this.sink({ ...this.buildPayload(identity), events: [] }, { resetSession: true });
1054
+ } catch {
1055
+ }
1056
+ }
1041
1057
  /**
1042
1058
  * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
1043
1059
  * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
@@ -1208,14 +1224,7 @@ function createFeedbackSink(options) {
1208
1224
  const post = async (body, final, sessionEnd = false) => {
1209
1225
  const json = JSON.stringify(body);
1210
1226
  const small = json.length <= KEEPALIVE_MAX_BYTES;
1211
- if (final && options.sendBeaconImpl && small) {
1212
- const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
1213
- if (options.sendBeaconImpl(url, payload)) {
1214
- options.log?.("[DeepdotsAnalytics] final flush via sendBeacon");
1215
- return;
1216
- }
1217
- options.log?.("[DeepdotsAnalytics] sendBeacon rejected the batch; falling back to fetch");
1218
- }
1227
+ if (final) options.log?.("[DeepdotsAnalytics] final flush via fetch keepalive");
1219
1228
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
1220
1229
  if (!f) {
1221
1230
  options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
@@ -1225,6 +1234,9 @@ function createFeedbackSink(options) {
1225
1234
  method: "POST",
1226
1235
  headers: { "Content-Type": "application/json" },
1227
1236
  body: json,
1237
+ // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
1238
+ // petición con credenciales fallaría el CORS. Explícito para no depender del default.
1239
+ credentials: "omit",
1228
1240
  // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
1229
1241
  ...small ? { keepalive: true } : {}
1230
1242
  });
@@ -1251,6 +1263,12 @@ function createFeedbackSink(options) {
1251
1263
  }
1252
1264
  };
1253
1265
  return async (envelope, meta) => {
1266
+ if (meta?.resetSession) {
1267
+ feedbackSessionId = void 0;
1268
+ firstPostInFlight = null;
1269
+ options.onSessionReset?.();
1270
+ return;
1271
+ }
1254
1272
  const final = meta?.final === true;
1255
1273
  const sessionEnd = meta?.sessionEnd === true;
1256
1274
  if (!feedbackSessionId && firstPostInFlight && !final) {
@@ -1317,6 +1335,23 @@ var NavigationObserver = class {
1317
1335
  this.closeCurrent();
1318
1336
  this.currentScreen = null;
1319
1337
  }
1338
+ /**
1339
+ * Olvida la pantalla actual SIN emitir su visita. Para una sesión que el backend ya dio
1340
+ * por cerrada: su visita no tiene dónde ir, y mandarla con la sesión nueva la contaría
1341
+ * donde no ocurrió. Después, `restart()` vuelve a empezar en la pantalla actual.
1342
+ */
1343
+ discard() {
1344
+ this.currentScreen = null;
1345
+ }
1346
+ /**
1347
+ * Retoma el seguimiento en la pantalla actual tras un `stop()`, cuando se abre una sesión
1348
+ * nueva sin navegar (cambio de usuario, vuelta de la bfcache). No-op si nunca se instaló
1349
+ * (navegación manual por `setScreen`, RN) o si ya hay pantalla en curso.
1350
+ */
1351
+ restart() {
1352
+ if (!this.installed || this.currentScreen != null || typeof window === "undefined") return;
1353
+ this.begin(window.location.href);
1354
+ }
1320
1355
  closeCurrent() {
1321
1356
  if (this.currentScreen == null) return;
1322
1357
  const exit = this.now();
@@ -1837,6 +1872,10 @@ var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
1837
1872
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
1838
1873
  var ANALYTICS_MAX_BATCH_SIZE = 20;
1839
1874
  var ANALYTICS_FLUSH_INTERVAL_MS = 3e4;
1875
+ var ANALYTICS_HEARTBEAT_MS = 5 * 6e4;
1876
+ var ANALYTICS_SESSION_TIMEOUT_MS = 30 * 6e4;
1877
+ var ANALYTICS_SERVER_IDLE_MS = 60 * 6e4;
1878
+ var ANALYTICS_SESSION_DISCARD_MS = ANALYTICS_SERVER_IDLE_MS - 10 * 6e4;
1840
1879
  var RESERVED_EVENT_PREFIX = "deepdots_";
1841
1880
  var CUSTOM_EVENT_PREFIX = "deepdots_event_";
1842
1881
  var DeepdotsPopups = class {
@@ -1900,11 +1939,21 @@ var DeepdotsPopups = class {
1900
1939
  * nueva al volver a foreground o al conceder el consentimiento más tarde.
1901
1940
  */
1902
1941
  this.sessionOpen = false;
1942
+ /** Último `user_engagement` emitido (ms epoch): marca el latido. */
1943
+ this.lastEngagementAt = 0;
1944
+ /** Cuándo se ocultó la pestaña por última vez (ms epoch); null si está visible. */
1945
+ this.hiddenAt = null;
1903
1946
  /**
1904
1947
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
1905
1948
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
1906
1949
  */
1907
1950
  this.language = void 0;
1951
+ /** `geolocation` del init: si es false no se usa ni la caché ni el lookup. */
1952
+ this.geoEnabled = true;
1953
+ /** Solo se consulta a terceros si el país/ciudad se va a enviar (analytics configurado). */
1954
+ this.geoWanted = false;
1955
+ /** Evita más de un lookup por instancia (el consentimiento puede ir y venir). */
1956
+ this.geoLookupStarted = false;
1908
1957
  }
1909
1958
  /** Initialize the SDK with configuration */
1910
1959
  init(config) {
@@ -1956,8 +2005,6 @@ var DeepdotsPopups = class {
1956
2005
  baseUrl: this.baseUrl,
1957
2006
  keys: config.analytics,
1958
2007
  log: (...a) => this.log(...a),
1959
- // sendBeacon: único transporte que sobrevive al cierre de la página.
1960
- sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
1961
2008
  onSessionId: (id) => {
1962
2009
  this.analyticsFeedbackSessionId = id;
1963
2010
  },
@@ -1992,20 +2039,13 @@ var DeepdotsPopups = class {
1992
2039
  if (this.tracking?.isTrackingEnabled()) {
1993
2040
  this.crashReporter.install();
1994
2041
  }
1995
- const cachedGeo = readCachedGeo(storage, Date.now());
1996
- if (cachedGeo) this.analytics.updateDevice(cachedGeo);
1997
- fetchGeo().then((geo) => {
1998
- if (geo) {
1999
- this.analytics?.updateDevice(geo);
2000
- writeCachedGeo(storage, geo, Date.now());
2001
- }
2002
- }).catch(() => {
2003
- });
2042
+ this.geoEnabled = config.geolocation ?? true;
2043
+ this.geoWanted = this.geoEnabled && !!config.analytics;
2044
+ this.resolveGeo();
2004
2045
  this.navObserver = new NavigationObserver();
2005
2046
  this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
2006
2047
  this.navObserver.install();
2007
2048
  this.engagement = new EngagementTracker();
2008
- this.engagement.resume();
2009
2049
  this.setupAnalyticsFlush();
2010
2050
  this.openSession();
2011
2051
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
@@ -2043,6 +2083,7 @@ var DeepdotsPopups = class {
2043
2083
  if (!enabled) this.closeSession("tracking_disabled");
2044
2084
  this.tracking?.setTrackingEnabled(enabled);
2045
2085
  if (enabled) this.openSession();
2086
+ if (enabled) this.resolveGeo();
2046
2087
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
2047
2088
  }
2048
2089
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -2224,6 +2265,7 @@ var DeepdotsPopups = class {
2224
2265
  this.navStarted = false;
2225
2266
  this.analytics?.exitAllMiniServices();
2226
2267
  this.flushEngagement();
2268
+ this.engagement?.pause();
2227
2269
  this.track("deepdots_session_end", { reason });
2228
2270
  this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
2229
2271
  this.analyticsFeedbackSessionId = void 0;
@@ -2238,7 +2280,14 @@ var DeepdotsPopups = class {
2238
2280
  if (this.sessionOpen) return;
2239
2281
  if (!this.tracking?.isTrackingEnabled()) return;
2240
2282
  this.sessionOpen = true;
2283
+ this.lastEngagementAt = Date.now();
2241
2284
  this.track("deepdots_session_start", {});
2285
+ this.navObserver?.restart();
2286
+ if (this.isDocumentVisible()) this.engagement?.resume();
2287
+ }
2288
+ /** Web: si la pestaña se ve. Sin `document` (RN) manda AppState vía onForeground/onBackground. */
2289
+ isDocumentVisible() {
2290
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
2242
2291
  }
2243
2292
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
2244
2293
  previewAnalytics() {
@@ -2251,8 +2300,8 @@ var DeepdotsPopups = class {
2251
2300
  }
2252
2301
  /**
2253
2302
  * Envía el lote acumulado de analytics y vacía el buffer.
2254
- * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
2255
- * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
2303
+ * `final: true` (cierre de página/app): el lote no espera al primer POST en vuelo, porque
2304
+ * el documento se muere; un lote que falle por red o 5xx se re-encola para el siguiente flush.
2256
2305
  */
2257
2306
  flushAnalytics(options) {
2258
2307
  if (!this.tracking?.isTrackingEnabled()) return;
@@ -2262,7 +2311,76 @@ var DeepdotsPopups = class {
2262
2311
  flushEngagement() {
2263
2312
  if (!this.tracking?.isTrackingEnabled()) return;
2264
2313
  const ms = this.engagement?.consume() ?? 0;
2265
- if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
2314
+ if (ms > 0) {
2315
+ this.track("deepdots_user_engagement", { engagement_time_msec: ms });
2316
+ this.lastEngagementAt = Date.now();
2317
+ }
2318
+ }
2319
+ /** Tick del flush periódico: latido de engagement si toca, y envío de lo acumulado. */
2320
+ onAnalyticsFlushTick() {
2321
+ if (this.sessionOpen && this.isDocumentVisible() && Date.now() - this.lastEngagementAt >= ANALYTICS_HEARTBEAT_MS) {
2322
+ this.flushEngagement();
2323
+ }
2324
+ this.flushAnalytics();
2325
+ }
2326
+ /**
2327
+ * La pestaña vuelve a verse. Tras más de ANALYTICS_SESSION_TIMEOUT_MS oculta, la sesión
2328
+ * anterior termina y empieza otra: seguir con el mismo sessionId mezclaba dos visitas y,
2329
+ * si el backend ya la había cerrado por inactividad, la partía en dos feedbacks.
2330
+ * - Oculta menos de ANALYTICS_SESSION_DISCARD_MS: el backend aún la tiene abierta → se
2331
+ * cierra con normalidad (page_view de la pantalla, session_end `idle_timeout`, completed).
2332
+ * - Más: ya la cerró el backend → se descarta sin enviar nada.
2333
+ */
2334
+ onDocumentVisible() {
2335
+ const hiddenFor = this.hiddenAt === null ? 0 : Date.now() - this.hiddenAt;
2336
+ this.hiddenAt = null;
2337
+ if (!this.sessionOpen) return;
2338
+ if (hiddenFor < ANALYTICS_SESSION_TIMEOUT_MS) {
2339
+ this.engagement?.resume();
2340
+ return;
2341
+ }
2342
+ if (hiddenFor < ANALYTICS_SESSION_DISCARD_MS) {
2343
+ this.closeSession("idle_timeout");
2344
+ } else {
2345
+ this.discardSession();
2346
+ }
2347
+ this.openSession();
2348
+ }
2349
+ /** Olvida la sesión abierta sin enviar nada (ver onDocumentVisible). */
2350
+ discardSession() {
2351
+ this.sessionOpen = false;
2352
+ this.navObserver?.discard();
2353
+ this.analytics?.discardSession(this.analyticsIdentity());
2354
+ this.engagement?.consume();
2355
+ this.engagement?.pause();
2356
+ this.analyticsFeedbackSessionId = void 0;
2357
+ this.tracking?.setSessionId(null);
2358
+ this.log("tracking \xB7 session discarded after", ANALYTICS_SESSION_DISCARD_MS / 6e4, "min hidden");
2359
+ }
2360
+ /**
2361
+ * Geolocalización por IP (país/ciudad para analytics). La caché persistente es local y se
2362
+ * aplica siempre que `geolocation` no esté desactivado. El lookup a terceros (ipapi.co →
2363
+ * ipwho.is → ipinfo.io, fallback con timeout) solo se hace si el dato se va a ENVIAR:
2364
+ * analytics configurado + tracking activo + caché ausente o caducada. Una vez por instancia.
2365
+ */
2366
+ resolveGeo() {
2367
+ const storage = this.storage;
2368
+ if (!this.geoEnabled || !storage || !this.analytics) return;
2369
+ const cachedGeo = readCachedGeo(storage, Date.now());
2370
+ if (cachedGeo) {
2371
+ this.analytics.updateDevice(cachedGeo);
2372
+ return;
2373
+ }
2374
+ if (!this.geoWanted || this.geoLookupStarted) return;
2375
+ if (!this.tracking?.isTrackingEnabled()) return;
2376
+ this.geoLookupStarted = true;
2377
+ fetchGeo().then((geo) => {
2378
+ if (geo) {
2379
+ this.analytics?.updateDevice(geo);
2380
+ writeCachedGeo(storage, geo, Date.now());
2381
+ }
2382
+ }).catch(() => {
2383
+ });
2266
2384
  }
2267
2385
  analyticsIdentity() {
2268
2386
  return {
@@ -2272,22 +2390,32 @@ var DeepdotsPopups = class {
2272
2390
  }
2273
2391
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
2274
2392
  setupAnalyticsFlush() {
2393
+ this.startAnalyticsFlushTimer();
2275
2394
  if (typeof document === "undefined" || typeof window === "undefined") return;
2276
- this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
2277
2395
  window.addEventListener("pagehide", () => {
2278
2396
  clearInterval(this.analyticsFlushTimer);
2279
2397
  this.closeSession("page_hide");
2280
2398
  });
2399
+ window.addEventListener("pageshow", (event) => {
2400
+ if (!event.persisted) return;
2401
+ this.startAnalyticsFlushTimer();
2402
+ this.openSession();
2403
+ });
2281
2404
  document.addEventListener("visibilitychange", () => {
2282
2405
  if (document.visibilityState === "hidden") {
2283
2406
  this.flushEngagement();
2284
2407
  this.engagement?.pause();
2285
2408
  this.flushAnalytics();
2409
+ this.hiddenAt = Date.now();
2286
2410
  } else if (document.visibilityState === "visible") {
2287
- this.engagement?.resume();
2411
+ this.onDocumentVisible();
2288
2412
  }
2289
2413
  });
2290
2414
  }
2415
+ startAnalyticsFlushTimer() {
2416
+ clearInterval(this.analyticsFlushTimer);
2417
+ this.analyticsFlushTimer = setInterval(() => this.onAnalyticsFlushTick(), ANALYTICS_FLUSH_INTERVAL_MS);
2418
+ }
2291
2419
  /** Enable auto-launch functionality with configured triggers */
2292
2420
  autoLaunch() {
2293
2421
  if (!this.initialized) {
@@ -2381,6 +2509,7 @@ var DeepdotsPopups = class {
2381
2509
  this.emitEvent("popup_shown", def.surveyId, { popupId: def.id });
2382
2510
  }
2383
2511
  shouldShow(def, pathUrl, skipPathCheck = false) {
2512
+ if (this.storage) this.loadPopupState(this.storage);
2384
2513
  if (this.isPathExcluded(def, pathUrl)) {
2385
2514
  return false;
2386
2515
  }
@@ -2907,22 +3036,32 @@ var DeepdotsPopups = class {
2907
3036
  this.persistPopupState();
2908
3037
  }
2909
3038
  /**
2910
- * Rehidrata el estado de redisplay desde el storage. `answeredSurveys` se reconstruye
2911
- * desde las entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
3039
+ * Fusiona el estado de redisplay del storage con el de memoria: por cada popup/survey gana
3040
+ * la entrada MÁS RECIENTE. Fusionar y no sustituir es lo que hace convivir a varias
3041
+ * pestañas sobre el mismo `localStorage`: se llama en el `init()`, antes de cada decisión
3042
+ * de mostrar y antes de cada escritura. `answeredSurveys` se reconstruye desde las
3043
+ * entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
2912
3044
  */
2913
3045
  loadPopupState(storage) {
2914
3046
  const state = readPopupState(storage);
2915
3047
  Object.entries(state.lastShown).forEach(([popupId, timestamp]) => {
2916
- this.lastShown.set(popupId, timestamp);
3048
+ if (timestamp > (this.lastShown.get(popupId) ?? -Infinity)) this.lastShown.set(popupId, timestamp);
2917
3049
  });
2918
3050
  Object.entries(state.progress).forEach(([surveyId, entry]) => {
3051
+ const current = this.surveyProgress.get(surveyId);
3052
+ if (current && current.timestamp >= entry.timestamp) return;
2919
3053
  this.surveyProgress.set(surveyId, { status: entry.status, timestamp: entry.timestamp });
2920
3054
  if (entry.status === "COMPLETED") this.answeredSurveys.add(surveyId);
2921
3055
  });
2922
3056
  }
2923
- /** Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria. */
3057
+ /**
3058
+ * Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria.
3059
+ * Fusiona antes de escribir: escribir solo la foto en memoria borraba lo que otra pestaña
3060
+ * hubiese guardado desde nuestro `init()`.
3061
+ */
2924
3062
  persistPopupState() {
2925
3063
  if (!this.storage) return;
3064
+ this.loadPopupState(this.storage);
2926
3065
  writePopupState(this.storage, {
2927
3066
  lastShown: Object.fromEntries(this.lastShown),
2928
3067
  progress: Object.fromEntries(this.surveyProgress)