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