@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.js CHANGED
@@ -6564,10 +6564,13 @@ init_tracking_manager();
6564
6564
 
6565
6565
  // src/analytics/analytics-manager.ts
6566
6566
  function createDryRunSink(log = console.log) {
6567
- return (payload) => log(
6568
- "[DeepdotsAnalytics] (dry-run \xB7 NOT sent \xB7 no init.analytics) POST /sdk/feedback \u2192",
6569
- JSON.stringify(payload, null, 2)
6570
- );
6567
+ return (payload, meta) => {
6568
+ if (meta?.resetSession) return;
6569
+ log(
6570
+ "[DeepdotsAnalytics] (dry-run \xB7 NOT sent \xB7 no init.analytics) POST /sdk/feedback \u2192",
6571
+ JSON.stringify(payload, null, 2)
6572
+ );
6573
+ };
6571
6574
  }
6572
6575
  var dryRunSink = createDryRunSink();
6573
6576
  var AnalyticsManager = class {
@@ -6634,6 +6637,19 @@ var AnalyticsManager = class {
6634
6637
  this.exitMiniService(name);
6635
6638
  }
6636
6639
  }
6640
+ /**
6641
+ * Olvida la sesión en curso SIN emitir nada: vacía el buffer y los mini-services activos
6642
+ * (sin `mini_service_exit`). Para una sesión que el backend ya cerró por inactividad: lo
6643
+ * que quedara pendiente pertenecía a ella y no debe llegar con la sesión nueva.
6644
+ */
6645
+ discardSession(identity) {
6646
+ this.events = [];
6647
+ this.activeMiniServices.clear();
6648
+ try {
6649
+ void this.sink({ ...this.buildPayload(identity), events: [] }, { resetSession: true });
6650
+ } catch {
6651
+ }
6652
+ }
6637
6653
  /**
6638
6654
  * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
6639
6655
  * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
@@ -6804,14 +6820,7 @@ function createFeedbackSink(options) {
6804
6820
  const post = async (body, final, sessionEnd = false) => {
6805
6821
  const json = JSON.stringify(body);
6806
6822
  const small = json.length <= KEEPALIVE_MAX_BYTES;
6807
- if (final && options.sendBeaconImpl && small) {
6808
- const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
6809
- if (options.sendBeaconImpl(url, payload)) {
6810
- options.log?.("[DeepdotsAnalytics] final flush via sendBeacon");
6811
- return;
6812
- }
6813
- options.log?.("[DeepdotsAnalytics] sendBeacon rejected the batch; falling back to fetch");
6814
- }
6823
+ if (final) options.log?.("[DeepdotsAnalytics] final flush via fetch keepalive");
6815
6824
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
6816
6825
  if (!f) {
6817
6826
  options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
@@ -6821,6 +6830,9 @@ function createFeedbackSink(options) {
6821
6830
  method: "POST",
6822
6831
  headers: { "Content-Type": "application/json" },
6823
6832
  body: json,
6833
+ // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
6834
+ // petición con credenciales fallaría el CORS. Explícito para no depender del default.
6835
+ credentials: "omit",
6824
6836
  // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
6825
6837
  ...small ? { keepalive: true } : {}
6826
6838
  });
@@ -6847,6 +6859,12 @@ function createFeedbackSink(options) {
6847
6859
  }
6848
6860
  };
6849
6861
  return async (envelope, meta) => {
6862
+ if (meta?.resetSession) {
6863
+ feedbackSessionId = void 0;
6864
+ firstPostInFlight = null;
6865
+ options.onSessionReset?.();
6866
+ return;
6867
+ }
6850
6868
  const final = meta?.final === true;
6851
6869
  const sessionEnd = meta?.sessionEnd === true;
6852
6870
  if (!feedbackSessionId && firstPostInFlight && !final) {
@@ -6913,6 +6931,23 @@ var NavigationObserver = class {
6913
6931
  this.closeCurrent();
6914
6932
  this.currentScreen = null;
6915
6933
  }
6934
+ /**
6935
+ * Olvida la pantalla actual SIN emitir su visita. Para una sesión que el backend ya dio
6936
+ * por cerrada: su visita no tiene dónde ir, y mandarla con la sesión nueva la contaría
6937
+ * donde no ocurrió. Después, `restart()` vuelve a empezar en la pantalla actual.
6938
+ */
6939
+ discard() {
6940
+ this.currentScreen = null;
6941
+ }
6942
+ /**
6943
+ * Retoma el seguimiento en la pantalla actual tras un `stop()`, cuando se abre una sesión
6944
+ * nueva sin navegar (cambio de usuario, vuelta de la bfcache). No-op si nunca se instaló
6945
+ * (navegación manual por `setScreen`, RN) o si ya hay pantalla en curso.
6946
+ */
6947
+ restart() {
6948
+ if (!this.installed || this.currentScreen != null || typeof window === "undefined") return;
6949
+ this.begin(window.location.href);
6950
+ }
6916
6951
  closeCurrent() {
6917
6952
  if (this.currentScreen == null) return;
6918
6953
  const exit = this.now();
@@ -7437,6 +7472,10 @@ var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
7437
7472
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
7438
7473
  var ANALYTICS_MAX_BATCH_SIZE = 20;
7439
7474
  var ANALYTICS_FLUSH_INTERVAL_MS = 3e4;
7475
+ var ANALYTICS_HEARTBEAT_MS = 5 * 6e4;
7476
+ var ANALYTICS_SESSION_TIMEOUT_MS = 30 * 6e4;
7477
+ var ANALYTICS_SERVER_IDLE_MS = 60 * 6e4;
7478
+ var ANALYTICS_SESSION_DISCARD_MS = ANALYTICS_SERVER_IDLE_MS - 10 * 6e4;
7440
7479
  var RESERVED_EVENT_PREFIX = "deepdots_";
7441
7480
  var CUSTOM_EVENT_PREFIX = "deepdots_event_";
7442
7481
  var DeepdotsPopups = class {
@@ -7500,11 +7539,21 @@ var DeepdotsPopups = class {
7500
7539
  * nueva al volver a foreground o al conceder el consentimiento más tarde.
7501
7540
  */
7502
7541
  this.sessionOpen = false;
7542
+ /** Último `user_engagement` emitido (ms epoch): marca el latido. */
7543
+ this.lastEngagementAt = 0;
7544
+ /** Cuándo se ocultó la pestaña por última vez (ms epoch); null si está visible. */
7545
+ this.hiddenAt = null;
7503
7546
  /**
7504
7547
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
7505
7548
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
7506
7549
  */
7507
7550
  this.language = void 0;
7551
+ /** `geolocation` del init: si es false no se usa ni la caché ni el lookup. */
7552
+ this.geoEnabled = true;
7553
+ /** Solo se consulta a terceros si el país/ciudad se va a enviar (analytics configurado). */
7554
+ this.geoWanted = false;
7555
+ /** Evita más de un lookup por instancia (el consentimiento puede ir y venir). */
7556
+ this.geoLookupStarted = false;
7508
7557
  }
7509
7558
  /** Initialize the SDK with configuration */
7510
7559
  init(config) {
@@ -7556,8 +7605,6 @@ var DeepdotsPopups = class {
7556
7605
  baseUrl: this.baseUrl,
7557
7606
  keys: config.analytics,
7558
7607
  log: (...a) => this.log(...a),
7559
- // sendBeacon: único transporte que sobrevive al cierre de la página.
7560
- sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
7561
7608
  onSessionId: (id) => {
7562
7609
  this.analyticsFeedbackSessionId = id;
7563
7610
  },
@@ -7592,20 +7639,13 @@ var DeepdotsPopups = class {
7592
7639
  if (this.tracking?.isTrackingEnabled()) {
7593
7640
  this.crashReporter.install();
7594
7641
  }
7595
- const cachedGeo = readCachedGeo(storage, Date.now());
7596
- if (cachedGeo) this.analytics.updateDevice(cachedGeo);
7597
- fetchGeo().then((geo) => {
7598
- if (geo) {
7599
- this.analytics?.updateDevice(geo);
7600
- writeCachedGeo(storage, geo, Date.now());
7601
- }
7602
- }).catch(() => {
7603
- });
7642
+ this.geoEnabled = config.geolocation ?? true;
7643
+ this.geoWanted = this.geoEnabled && !!config.analytics;
7644
+ this.resolveGeo();
7604
7645
  this.navObserver = new NavigationObserver();
7605
7646
  this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
7606
7647
  this.navObserver.install();
7607
7648
  this.engagement = new EngagementTracker();
7608
- this.engagement.resume();
7609
7649
  this.setupAnalyticsFlush();
7610
7650
  this.openSession();
7611
7651
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
@@ -7643,6 +7683,7 @@ var DeepdotsPopups = class {
7643
7683
  if (!enabled) this.closeSession("tracking_disabled");
7644
7684
  this.tracking?.setTrackingEnabled(enabled);
7645
7685
  if (enabled) this.openSession();
7686
+ if (enabled) this.resolveGeo();
7646
7687
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
7647
7688
  }
7648
7689
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -7824,6 +7865,7 @@ var DeepdotsPopups = class {
7824
7865
  this.navStarted = false;
7825
7866
  this.analytics?.exitAllMiniServices();
7826
7867
  this.flushEngagement();
7868
+ this.engagement?.pause();
7827
7869
  this.track("deepdots_session_end", { reason });
7828
7870
  this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
7829
7871
  this.analyticsFeedbackSessionId = void 0;
@@ -7838,7 +7880,14 @@ var DeepdotsPopups = class {
7838
7880
  if (this.sessionOpen) return;
7839
7881
  if (!this.tracking?.isTrackingEnabled()) return;
7840
7882
  this.sessionOpen = true;
7883
+ this.lastEngagementAt = Date.now();
7841
7884
  this.track("deepdots_session_start", {});
7885
+ this.navObserver?.restart();
7886
+ if (this.isDocumentVisible()) this.engagement?.resume();
7887
+ }
7888
+ /** Web: si la pestaña se ve. Sin `document` (RN) manda AppState vía onForeground/onBackground. */
7889
+ isDocumentVisible() {
7890
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
7842
7891
  }
7843
7892
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
7844
7893
  previewAnalytics() {
@@ -7851,8 +7900,8 @@ var DeepdotsPopups = class {
7851
7900
  }
7852
7901
  /**
7853
7902
  * Envía el lote acumulado de analytics y vacía el buffer.
7854
- * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
7855
- * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
7903
+ * `final: true` (cierre de página/app): el lote no espera al primer POST en vuelo, porque
7904
+ * el documento se muere; un lote que falle por red o 5xx se re-encola para el siguiente flush.
7856
7905
  */
7857
7906
  flushAnalytics(options) {
7858
7907
  if (!this.tracking?.isTrackingEnabled()) return;
@@ -7862,7 +7911,76 @@ var DeepdotsPopups = class {
7862
7911
  flushEngagement() {
7863
7912
  if (!this.tracking?.isTrackingEnabled()) return;
7864
7913
  const ms = this.engagement?.consume() ?? 0;
7865
- if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
7914
+ if (ms > 0) {
7915
+ this.track("deepdots_user_engagement", { engagement_time_msec: ms });
7916
+ this.lastEngagementAt = Date.now();
7917
+ }
7918
+ }
7919
+ /** Tick del flush periódico: latido de engagement si toca, y envío de lo acumulado. */
7920
+ onAnalyticsFlushTick() {
7921
+ if (this.sessionOpen && this.isDocumentVisible() && Date.now() - this.lastEngagementAt >= ANALYTICS_HEARTBEAT_MS) {
7922
+ this.flushEngagement();
7923
+ }
7924
+ this.flushAnalytics();
7925
+ }
7926
+ /**
7927
+ * La pestaña vuelve a verse. Tras más de ANALYTICS_SESSION_TIMEOUT_MS oculta, la sesión
7928
+ * anterior termina y empieza otra: seguir con el mismo sessionId mezclaba dos visitas y,
7929
+ * si el backend ya la había cerrado por inactividad, la partía en dos feedbacks.
7930
+ * - Oculta menos de ANALYTICS_SESSION_DISCARD_MS: el backend aún la tiene abierta → se
7931
+ * cierra con normalidad (page_view de la pantalla, session_end `idle_timeout`, completed).
7932
+ * - Más: ya la cerró el backend → se descarta sin enviar nada.
7933
+ */
7934
+ onDocumentVisible() {
7935
+ const hiddenFor = this.hiddenAt === null ? 0 : Date.now() - this.hiddenAt;
7936
+ this.hiddenAt = null;
7937
+ if (!this.sessionOpen) return;
7938
+ if (hiddenFor < ANALYTICS_SESSION_TIMEOUT_MS) {
7939
+ this.engagement?.resume();
7940
+ return;
7941
+ }
7942
+ if (hiddenFor < ANALYTICS_SESSION_DISCARD_MS) {
7943
+ this.closeSession("idle_timeout");
7944
+ } else {
7945
+ this.discardSession();
7946
+ }
7947
+ this.openSession();
7948
+ }
7949
+ /** Olvida la sesión abierta sin enviar nada (ver onDocumentVisible). */
7950
+ discardSession() {
7951
+ this.sessionOpen = false;
7952
+ this.navObserver?.discard();
7953
+ this.analytics?.discardSession(this.analyticsIdentity());
7954
+ this.engagement?.consume();
7955
+ this.engagement?.pause();
7956
+ this.analyticsFeedbackSessionId = void 0;
7957
+ this.tracking?.setSessionId(null);
7958
+ this.log("tracking \xB7 session discarded after", ANALYTICS_SESSION_DISCARD_MS / 6e4, "min hidden");
7959
+ }
7960
+ /**
7961
+ * Geolocalización por IP (país/ciudad para analytics). La caché persistente es local y se
7962
+ * aplica siempre que `geolocation` no esté desactivado. El lookup a terceros (ipapi.co →
7963
+ * ipwho.is → ipinfo.io, fallback con timeout) solo se hace si el dato se va a ENVIAR:
7964
+ * analytics configurado + tracking activo + caché ausente o caducada. Una vez por instancia.
7965
+ */
7966
+ resolveGeo() {
7967
+ const storage = this.storage;
7968
+ if (!this.geoEnabled || !storage || !this.analytics) return;
7969
+ const cachedGeo = readCachedGeo(storage, Date.now());
7970
+ if (cachedGeo) {
7971
+ this.analytics.updateDevice(cachedGeo);
7972
+ return;
7973
+ }
7974
+ if (!this.geoWanted || this.geoLookupStarted) return;
7975
+ if (!this.tracking?.isTrackingEnabled()) return;
7976
+ this.geoLookupStarted = true;
7977
+ fetchGeo().then((geo) => {
7978
+ if (geo) {
7979
+ this.analytics?.updateDevice(geo);
7980
+ writeCachedGeo(storage, geo, Date.now());
7981
+ }
7982
+ }).catch(() => {
7983
+ });
7866
7984
  }
7867
7985
  analyticsIdentity() {
7868
7986
  return {
@@ -7872,22 +7990,32 @@ var DeepdotsPopups = class {
7872
7990
  }
7873
7991
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
7874
7992
  setupAnalyticsFlush() {
7993
+ this.startAnalyticsFlushTimer();
7875
7994
  if (typeof document === "undefined" || typeof window === "undefined") return;
7876
- this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
7877
7995
  window.addEventListener("pagehide", () => {
7878
7996
  clearInterval(this.analyticsFlushTimer);
7879
7997
  this.closeSession("page_hide");
7880
7998
  });
7999
+ window.addEventListener("pageshow", (event) => {
8000
+ if (!event.persisted) return;
8001
+ this.startAnalyticsFlushTimer();
8002
+ this.openSession();
8003
+ });
7881
8004
  document.addEventListener("visibilitychange", () => {
7882
8005
  if (document.visibilityState === "hidden") {
7883
8006
  this.flushEngagement();
7884
8007
  this.engagement?.pause();
7885
8008
  this.flushAnalytics();
8009
+ this.hiddenAt = Date.now();
7886
8010
  } else if (document.visibilityState === "visible") {
7887
- this.engagement?.resume();
8011
+ this.onDocumentVisible();
7888
8012
  }
7889
8013
  });
7890
8014
  }
8015
+ startAnalyticsFlushTimer() {
8016
+ clearInterval(this.analyticsFlushTimer);
8017
+ this.analyticsFlushTimer = setInterval(() => this.onAnalyticsFlushTick(), ANALYTICS_FLUSH_INTERVAL_MS);
8018
+ }
7891
8019
  /** Enable auto-launch functionality with configured triggers */
7892
8020
  autoLaunch() {
7893
8021
  if (!this.initialized) {
@@ -7981,6 +8109,7 @@ var DeepdotsPopups = class {
7981
8109
  this.emitEvent("popup_shown", def.surveyId, { popupId: def.id });
7982
8110
  }
7983
8111
  shouldShow(def, pathUrl, skipPathCheck = false) {
8112
+ if (this.storage) this.loadPopupState(this.storage);
7984
8113
  if (this.isPathExcluded(def, pathUrl)) {
7985
8114
  return false;
7986
8115
  }
@@ -8507,22 +8636,32 @@ var DeepdotsPopups = class {
8507
8636
  this.persistPopupState();
8508
8637
  }
8509
8638
  /**
8510
- * Rehidrata el estado de redisplay desde el storage. `answeredSurveys` se reconstruye
8511
- * desde las entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
8639
+ * Fusiona el estado de redisplay del storage con el de memoria: por cada popup/survey gana
8640
+ * la entrada MÁS RECIENTE. Fusionar y no sustituir es lo que hace convivir a varias
8641
+ * pestañas sobre el mismo `localStorage`: se llama en el `init()`, antes de cada decisión
8642
+ * de mostrar y antes de cada escritura. `answeredSurveys` se reconstruye desde las
8643
+ * entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
8512
8644
  */
8513
8645
  loadPopupState(storage) {
8514
8646
  const state = readPopupState(storage);
8515
8647
  Object.entries(state.lastShown).forEach(([popupId, timestamp]) => {
8516
- this.lastShown.set(popupId, timestamp);
8648
+ if (timestamp > (this.lastShown.get(popupId) ?? -Infinity)) this.lastShown.set(popupId, timestamp);
8517
8649
  });
8518
8650
  Object.entries(state.progress).forEach(([surveyId, entry]) => {
8651
+ const current = this.surveyProgress.get(surveyId);
8652
+ if (current && current.timestamp >= entry.timestamp) return;
8519
8653
  this.surveyProgress.set(surveyId, { status: entry.status, timestamp: entry.timestamp });
8520
8654
  if (entry.status === "COMPLETED") this.answeredSurveys.add(surveyId);
8521
8655
  });
8522
8656
  }
8523
- /** Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria. */
8657
+ /**
8658
+ * Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria.
8659
+ * Fusiona antes de escribir: escribir solo la foto en memoria borraba lo que otra pestaña
8660
+ * hubiese guardado desde nuestro `init()`.
8661
+ */
8524
8662
  persistPopupState() {
8525
8663
  if (!this.storage) return;
8664
+ this.loadPopupState(this.storage);
8526
8665
  writePopupState(this.storage, {
8527
8666
  lastShown: Object.fromEntries(this.lastShown),
8528
8667
  progress: Object.fromEntries(this.surveyProgress)