@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.
@@ -156,6 +156,13 @@ interface DeepdotsInitParams {
156
156
  * to the configured integration; if missing, it stays in dry-run mode (console.log only).
157
157
  */
158
158
  analytics?: AnalyticsKeys;
159
+ /**
160
+ * IP geolocation (country/city) added to analytics. Default `true`. The lookup calls
161
+ * third-party services (ipapi.co, then ipwho.is, then ipinfo.io as fallbacks, 3 s timeout
162
+ * each) and only runs when `analytics` is configured, tracking is enabled and the 30-day
163
+ * cache is missing or expired. Set `false` to never call them nor attach country/city.
164
+ */
165
+ geolocation?: boolean;
159
166
  /**
160
167
  * Internal user attributes known only by the host (language, age, plan, ...), sent
161
168
  * to the backend Contact for segmentation/targeting. Requires `userId`. Equivalent to calling
@@ -406,11 +413,21 @@ declare class DeepdotsPopups {
406
413
  * nueva al volver a foreground o al conceder el consentimiento más tarde.
407
414
  */
408
415
  private sessionOpen;
416
+ /** Último `user_engagement` emitido (ms epoch): marca el latido. */
417
+ private lastEngagementAt;
418
+ /** Cuándo se ocultó la pestaña por última vez (ms epoch); null si está visible. */
419
+ private hiddenAt;
409
420
  /**
410
421
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
411
422
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
412
423
  */
413
424
  private language;
425
+ /** `geolocation` del init: si es false no se usa ni la caché ni el lookup. */
426
+ private geoEnabled;
427
+ /** Solo se consulta a terceros si el país/ciudad se va a enviar (analytics configurado). */
428
+ private geoWanted;
429
+ /** Evita más de un lookup por instancia (el consentimiento puede ir y venir). */
430
+ private geoLookupStarted;
414
431
  /** Initialize the SDK with configuration */
415
432
  init(config: DeepdotsInitParams): void;
416
433
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -507,12 +524,14 @@ declare class DeepdotsPopups {
507
524
  * se llama en init(), al volver a foreground y al conceder el consentimiento.
508
525
  */
509
526
  private openSession;
527
+ /** Web: si la pestaña se ve. Sin `document` (RN) manda AppState vía onForeground/onBackground. */
528
+ private isDocumentVisible;
510
529
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
511
530
  previewAnalytics(): AnalyticsEnvelope;
512
531
  /**
513
532
  * Envía el lote acumulado de analytics y vacía el buffer.
514
- * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
515
- * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
533
+ * `final: true` (cierre de página/app): el lote no espera al primer POST en vuelo, porque
534
+ * el documento se muere; un lote que falle por red o 5xx se re-encola para el siguiente flush.
516
535
  */
517
536
  flushAnalytics(options?: {
518
537
  final?: boolean;
@@ -520,9 +539,30 @@ declare class DeepdotsPopups {
520
539
  }): void;
521
540
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
522
541
  private flushEngagement;
542
+ /** Tick del flush periódico: latido de engagement si toca, y envío de lo acumulado. */
543
+ private onAnalyticsFlushTick;
544
+ /**
545
+ * La pestaña vuelve a verse. Tras más de ANALYTICS_SESSION_TIMEOUT_MS oculta, la sesión
546
+ * anterior termina y empieza otra: seguir con el mismo sessionId mezclaba dos visitas y,
547
+ * si el backend ya la había cerrado por inactividad, la partía en dos feedbacks.
548
+ * - Oculta menos de ANALYTICS_SESSION_DISCARD_MS: el backend aún la tiene abierta → se
549
+ * cierra con normalidad (page_view de la pantalla, session_end `idle_timeout`, completed).
550
+ * - Más: ya la cerró el backend → se descarta sin enviar nada.
551
+ */
552
+ private onDocumentVisible;
553
+ /** Olvida la sesión abierta sin enviar nada (ver onDocumentVisible). */
554
+ private discardSession;
555
+ /**
556
+ * Geolocalización por IP (país/ciudad para analytics). La caché persistente es local y se
557
+ * aplica siempre que `geolocation` no esté desactivado. El lookup a terceros (ipapi.co →
558
+ * ipwho.is → ipinfo.io, fallback con timeout) solo se hace si el dato se va a ENVIAR:
559
+ * analytics configurado + tracking activo + caché ausente o caducada. Una vez por instancia.
560
+ */
561
+ private resolveGeo;
523
562
  private analyticsIdentity;
524
563
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
525
564
  private setupAnalyticsFlush;
565
+ private startAnalyticsFlushTimer;
526
566
  /** Enable auto-launch functionality with configured triggers */
527
567
  autoLaunch(): void;
528
568
  /** Inicia los triggers configurados */
@@ -602,11 +642,18 @@ declare class DeepdotsPopups {
602
642
  private mapPopupTriggerType;
603
643
  private markSurveyProgress;
604
644
  /**
605
- * Rehidrata el estado de redisplay desde el storage. `answeredSurveys` se reconstruye
606
- * desde las entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
645
+ * Fusiona el estado de redisplay del storage con el de memoria: por cada popup/survey gana
646
+ * la entrada MÁS RECIENTE. Fusionar y no sustituir es lo que hace convivir a varias
647
+ * pestañas sobre el mismo `localStorage`: se llama en el `init()`, antes de cada decisión
648
+ * de mostrar y antes de cada escritura. `answeredSurveys` se reconstruye desde las
649
+ * entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
607
650
  */
608
651
  private loadPopupState;
609
- /** Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria. */
652
+ /**
653
+ * Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria.
654
+ * Fusiona antes de escribir: escribir solo la foto en memoria borraba lo que otra pestaña
655
+ * hubiese guardado desde nuestro `init()`.
656
+ */
610
657
  private persistPopupState;
611
658
  private isPopupDefinition;
612
659
  private isPopupTrigger;
@@ -156,6 +156,13 @@ interface DeepdotsInitParams {
156
156
  * to the configured integration; if missing, it stays in dry-run mode (console.log only).
157
157
  */
158
158
  analytics?: AnalyticsKeys;
159
+ /**
160
+ * IP geolocation (country/city) added to analytics. Default `true`. The lookup calls
161
+ * third-party services (ipapi.co, then ipwho.is, then ipinfo.io as fallbacks, 3 s timeout
162
+ * each) and only runs when `analytics` is configured, tracking is enabled and the 30-day
163
+ * cache is missing or expired. Set `false` to never call them nor attach country/city.
164
+ */
165
+ geolocation?: boolean;
159
166
  /**
160
167
  * Internal user attributes known only by the host (language, age, plan, ...), sent
161
168
  * to the backend Contact for segmentation/targeting. Requires `userId`. Equivalent to calling
@@ -406,11 +413,21 @@ declare class DeepdotsPopups {
406
413
  * nueva al volver a foreground o al conceder el consentimiento más tarde.
407
414
  */
408
415
  private sessionOpen;
416
+ /** Último `user_engagement` emitido (ms epoch): marca el latido. */
417
+ private lastEngagementAt;
418
+ /** Cuándo se ocultó la pestaña por última vez (ms epoch); null si está visible. */
419
+ private hiddenAt;
409
420
  /**
410
421
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
411
422
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
412
423
  */
413
424
  private language;
425
+ /** `geolocation` del init: si es false no se usa ni la caché ni el lookup. */
426
+ private geoEnabled;
427
+ /** Solo se consulta a terceros si el país/ciudad se va a enviar (analytics configurado). */
428
+ private geoWanted;
429
+ /** Evita más de un lookup por instancia (el consentimiento puede ir y venir). */
430
+ private geoLookupStarted;
414
431
  /** Initialize the SDK with configuration */
415
432
  init(config: DeepdotsInitParams): void;
416
433
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -507,12 +524,14 @@ declare class DeepdotsPopups {
507
524
  * se llama en init(), al volver a foreground y al conceder el consentimiento.
508
525
  */
509
526
  private openSession;
527
+ /** Web: si la pestaña se ve. Sin `document` (RN) manda AppState vía onForeground/onBackground. */
528
+ private isDocumentVisible;
510
529
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
511
530
  previewAnalytics(): AnalyticsEnvelope;
512
531
  /**
513
532
  * Envía el lote acumulado de analytics y vacía el buffer.
514
- * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
515
- * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
533
+ * `final: true` (cierre de página/app): el lote no espera al primer POST en vuelo, porque
534
+ * el documento se muere; un lote que falle por red o 5xx se re-encola para el siguiente flush.
516
535
  */
517
536
  flushAnalytics(options?: {
518
537
  final?: boolean;
@@ -520,9 +539,30 @@ declare class DeepdotsPopups {
520
539
  }): void;
521
540
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
522
541
  private flushEngagement;
542
+ /** Tick del flush periódico: latido de engagement si toca, y envío de lo acumulado. */
543
+ private onAnalyticsFlushTick;
544
+ /**
545
+ * La pestaña vuelve a verse. Tras más de ANALYTICS_SESSION_TIMEOUT_MS oculta, la sesión
546
+ * anterior termina y empieza otra: seguir con el mismo sessionId mezclaba dos visitas y,
547
+ * si el backend ya la había cerrado por inactividad, la partía en dos feedbacks.
548
+ * - Oculta menos de ANALYTICS_SESSION_DISCARD_MS: el backend aún la tiene abierta → se
549
+ * cierra con normalidad (page_view de la pantalla, session_end `idle_timeout`, completed).
550
+ * - Más: ya la cerró el backend → se descarta sin enviar nada.
551
+ */
552
+ private onDocumentVisible;
553
+ /** Olvida la sesión abierta sin enviar nada (ver onDocumentVisible). */
554
+ private discardSession;
555
+ /**
556
+ * Geolocalización por IP (país/ciudad para analytics). La caché persistente es local y se
557
+ * aplica siempre que `geolocation` no esté desactivado. El lookup a terceros (ipapi.co →
558
+ * ipwho.is → ipinfo.io, fallback con timeout) solo se hace si el dato se va a ENVIAR:
559
+ * analytics configurado + tracking activo + caché ausente o caducada. Una vez por instancia.
560
+ */
561
+ private resolveGeo;
523
562
  private analyticsIdentity;
524
563
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
525
564
  private setupAnalyticsFlush;
565
+ private startAnalyticsFlushTimer;
526
566
  /** Enable auto-launch functionality with configured triggers */
527
567
  autoLaunch(): void;
528
568
  /** Inicia los triggers configurados */
@@ -602,11 +642,18 @@ declare class DeepdotsPopups {
602
642
  private mapPopupTriggerType;
603
643
  private markSurveyProgress;
604
644
  /**
605
- * Rehidrata el estado de redisplay desde el storage. `answeredSurveys` se reconstruye
606
- * desde las entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
645
+ * Fusiona el estado de redisplay del storage con el de memoria: por cada popup/survey gana
646
+ * la entrada MÁS RECIENTE. Fusionar y no sustituir es lo que hace convivir a varias
647
+ * pestañas sobre el mismo `localStorage`: se llama en el `init()`, antes de cada decisión
648
+ * de mostrar y antes de cada escritura. `answeredSurveys` se reconstruye desde las
649
+ * entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
607
650
  */
608
651
  private loadPopupState;
609
- /** Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria. */
652
+ /**
653
+ * Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria.
654
+ * Fusiona antes de escribir: escribir solo la foto en memoria borraba lo que otra pestaña
655
+ * hubiese guardado desde nuestro `init()`.
656
+ */
610
657
  private persistPopupState;
611
658
  private isPopupDefinition;
612
659
  private isPopupTrigger;
@@ -4124,10 +4124,13 @@ init_tracking_manager();
4124
4124
 
4125
4125
  // src/analytics/analytics-manager.ts
4126
4126
  function createDryRunSink(log = console.log) {
4127
- return (payload) => log(
4128
- "[DeepdotsAnalytics] (dry-run \xB7 NOT sent \xB7 no init.analytics) POST /sdk/feedback \u2192",
4129
- JSON.stringify(payload, null, 2)
4130
- );
4127
+ return (payload, meta) => {
4128
+ if (meta?.resetSession) return;
4129
+ log(
4130
+ "[DeepdotsAnalytics] (dry-run \xB7 NOT sent \xB7 no init.analytics) POST /sdk/feedback \u2192",
4131
+ JSON.stringify(payload, null, 2)
4132
+ );
4133
+ };
4131
4134
  }
4132
4135
  var dryRunSink = createDryRunSink();
4133
4136
  var AnalyticsManager = class {
@@ -4194,6 +4197,19 @@ var AnalyticsManager = class {
4194
4197
  this.exitMiniService(name);
4195
4198
  }
4196
4199
  }
4200
+ /**
4201
+ * Olvida la sesión en curso SIN emitir nada: vacía el buffer y los mini-services activos
4202
+ * (sin `mini_service_exit`). Para una sesión que el backend ya cerró por inactividad: lo
4203
+ * que quedara pendiente pertenecía a ella y no debe llegar con la sesión nueva.
4204
+ */
4205
+ discardSession(identity) {
4206
+ this.events = [];
4207
+ this.activeMiniServices.clear();
4208
+ try {
4209
+ void this.sink({ ...this.buildPayload(identity), events: [] }, { resetSession: true });
4210
+ } catch {
4211
+ }
4212
+ }
4197
4213
  /**
4198
4214
  * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
4199
4215
  * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
@@ -4364,14 +4380,7 @@ function createFeedbackSink(options) {
4364
4380
  const post = async (body, final, sessionEnd = false) => {
4365
4381
  const json = JSON.stringify(body);
4366
4382
  const small = json.length <= KEEPALIVE_MAX_BYTES;
4367
- if (final && options.sendBeaconImpl && small) {
4368
- const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
4369
- if (options.sendBeaconImpl(url, payload)) {
4370
- options.log?.("[DeepdotsAnalytics] final flush via sendBeacon");
4371
- return;
4372
- }
4373
- options.log?.("[DeepdotsAnalytics] sendBeacon rejected the batch; falling back to fetch");
4374
- }
4383
+ if (final) options.log?.("[DeepdotsAnalytics] final flush via fetch keepalive");
4375
4384
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
4376
4385
  if (!f) {
4377
4386
  options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
@@ -4381,6 +4390,9 @@ function createFeedbackSink(options) {
4381
4390
  method: "POST",
4382
4391
  headers: { "Content-Type": "application/json" },
4383
4392
  body: json,
4393
+ // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
4394
+ // petición con credenciales fallaría el CORS. Explícito para no depender del default.
4395
+ credentials: "omit",
4384
4396
  // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
4385
4397
  ...small ? { keepalive: true } : {}
4386
4398
  });
@@ -4407,6 +4419,12 @@ function createFeedbackSink(options) {
4407
4419
  }
4408
4420
  };
4409
4421
  return async (envelope, meta) => {
4422
+ if (meta?.resetSession) {
4423
+ feedbackSessionId = void 0;
4424
+ firstPostInFlight = null;
4425
+ options.onSessionReset?.();
4426
+ return;
4427
+ }
4410
4428
  const final = meta?.final === true;
4411
4429
  const sessionEnd = meta?.sessionEnd === true;
4412
4430
  if (!feedbackSessionId && firstPostInFlight && !final) {
@@ -4473,6 +4491,23 @@ var NavigationObserver = class {
4473
4491
  this.closeCurrent();
4474
4492
  this.currentScreen = null;
4475
4493
  }
4494
+ /**
4495
+ * Olvida la pantalla actual SIN emitir su visita. Para una sesión que el backend ya dio
4496
+ * por cerrada: su visita no tiene dónde ir, y mandarla con la sesión nueva la contaría
4497
+ * donde no ocurrió. Después, `restart()` vuelve a empezar en la pantalla actual.
4498
+ */
4499
+ discard() {
4500
+ this.currentScreen = null;
4501
+ }
4502
+ /**
4503
+ * Retoma el seguimiento en la pantalla actual tras un `stop()`, cuando se abre una sesión
4504
+ * nueva sin navegar (cambio de usuario, vuelta de la bfcache). No-op si nunca se instaló
4505
+ * (navegación manual por `setScreen`, RN) o si ya hay pantalla en curso.
4506
+ */
4507
+ restart() {
4508
+ if (!this.installed || this.currentScreen != null || typeof window === "undefined") return;
4509
+ this.begin(window.location.href);
4510
+ }
4476
4511
  closeCurrent() {
4477
4512
  if (this.currentScreen == null) return;
4478
4513
  const exit = this.now();
@@ -4997,6 +5032,10 @@ var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
4997
5032
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
4998
5033
  var ANALYTICS_MAX_BATCH_SIZE = 20;
4999
5034
  var ANALYTICS_FLUSH_INTERVAL_MS = 3e4;
5035
+ var ANALYTICS_HEARTBEAT_MS = 5 * 6e4;
5036
+ var ANALYTICS_SESSION_TIMEOUT_MS = 30 * 6e4;
5037
+ var ANALYTICS_SERVER_IDLE_MS = 60 * 6e4;
5038
+ var ANALYTICS_SESSION_DISCARD_MS = ANALYTICS_SERVER_IDLE_MS - 10 * 6e4;
5000
5039
  var RESERVED_EVENT_PREFIX = "deepdots_";
5001
5040
  var CUSTOM_EVENT_PREFIX = "deepdots_event_";
5002
5041
  var DeepdotsPopups = class {
@@ -5060,11 +5099,21 @@ var DeepdotsPopups = class {
5060
5099
  * nueva al volver a foreground o al conceder el consentimiento más tarde.
5061
5100
  */
5062
5101
  this.sessionOpen = false;
5102
+ /** Último `user_engagement` emitido (ms epoch): marca el latido. */
5103
+ this.lastEngagementAt = 0;
5104
+ /** Cuándo se ocultó la pestaña por última vez (ms epoch); null si está visible. */
5105
+ this.hiddenAt = null;
5063
5106
  /**
5064
5107
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
5065
5108
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
5066
5109
  */
5067
5110
  this.language = void 0;
5111
+ /** `geolocation` del init: si es false no se usa ni la caché ni el lookup. */
5112
+ this.geoEnabled = true;
5113
+ /** Solo se consulta a terceros si el país/ciudad se va a enviar (analytics configurado). */
5114
+ this.geoWanted = false;
5115
+ /** Evita más de un lookup por instancia (el consentimiento puede ir y venir). */
5116
+ this.geoLookupStarted = false;
5068
5117
  }
5069
5118
  /** Initialize the SDK with configuration */
5070
5119
  init(config) {
@@ -5116,8 +5165,6 @@ var DeepdotsPopups = class {
5116
5165
  baseUrl: this.baseUrl,
5117
5166
  keys: config.analytics,
5118
5167
  log: (...a) => this.log(...a),
5119
- // sendBeacon: único transporte que sobrevive al cierre de la página.
5120
- sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
5121
5168
  onSessionId: (id) => {
5122
5169
  this.analyticsFeedbackSessionId = id;
5123
5170
  },
@@ -5152,20 +5199,13 @@ var DeepdotsPopups = class {
5152
5199
  if (this.tracking?.isTrackingEnabled()) {
5153
5200
  this.crashReporter.install();
5154
5201
  }
5155
- const cachedGeo = readCachedGeo(storage, Date.now());
5156
- if (cachedGeo) this.analytics.updateDevice(cachedGeo);
5157
- fetchGeo().then((geo) => {
5158
- if (geo) {
5159
- this.analytics?.updateDevice(geo);
5160
- writeCachedGeo(storage, geo, Date.now());
5161
- }
5162
- }).catch(() => {
5163
- });
5202
+ this.geoEnabled = config.geolocation ?? true;
5203
+ this.geoWanted = this.geoEnabled && !!config.analytics;
5204
+ this.resolveGeo();
5164
5205
  this.navObserver = new NavigationObserver();
5165
5206
  this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
5166
5207
  this.navObserver.install();
5167
5208
  this.engagement = new EngagementTracker();
5168
- this.engagement.resume();
5169
5209
  this.setupAnalyticsFlush();
5170
5210
  this.openSession();
5171
5211
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
@@ -5203,6 +5243,7 @@ var DeepdotsPopups = class {
5203
5243
  if (!enabled) this.closeSession("tracking_disabled");
5204
5244
  this.tracking?.setTrackingEnabled(enabled);
5205
5245
  if (enabled) this.openSession();
5246
+ if (enabled) this.resolveGeo();
5206
5247
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
5207
5248
  }
5208
5249
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -5384,6 +5425,7 @@ var DeepdotsPopups = class {
5384
5425
  this.navStarted = false;
5385
5426
  this.analytics?.exitAllMiniServices();
5386
5427
  this.flushEngagement();
5428
+ this.engagement?.pause();
5387
5429
  this.track("deepdots_session_end", { reason });
5388
5430
  this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
5389
5431
  this.analyticsFeedbackSessionId = void 0;
@@ -5398,7 +5440,14 @@ var DeepdotsPopups = class {
5398
5440
  if (this.sessionOpen) return;
5399
5441
  if (!this.tracking?.isTrackingEnabled()) return;
5400
5442
  this.sessionOpen = true;
5443
+ this.lastEngagementAt = Date.now();
5401
5444
  this.track("deepdots_session_start", {});
5445
+ this.navObserver?.restart();
5446
+ if (this.isDocumentVisible()) this.engagement?.resume();
5447
+ }
5448
+ /** Web: si la pestaña se ve. Sin `document` (RN) manda AppState vía onForeground/onBackground. */
5449
+ isDocumentVisible() {
5450
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
5402
5451
  }
5403
5452
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
5404
5453
  previewAnalytics() {
@@ -5411,8 +5460,8 @@ var DeepdotsPopups = class {
5411
5460
  }
5412
5461
  /**
5413
5462
  * Envía el lote acumulado de analytics y vacía el buffer.
5414
- * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
5415
- * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
5463
+ * `final: true` (cierre de página/app): el lote no espera al primer POST en vuelo, porque
5464
+ * el documento se muere; un lote que falle por red o 5xx se re-encola para el siguiente flush.
5416
5465
  */
5417
5466
  flushAnalytics(options) {
5418
5467
  if (!this.tracking?.isTrackingEnabled()) return;
@@ -5422,7 +5471,76 @@ var DeepdotsPopups = class {
5422
5471
  flushEngagement() {
5423
5472
  if (!this.tracking?.isTrackingEnabled()) return;
5424
5473
  const ms = this.engagement?.consume() ?? 0;
5425
- if (ms > 0) this.track("deepdots_user_engagement", { engagement_time_msec: ms });
5474
+ if (ms > 0) {
5475
+ this.track("deepdots_user_engagement", { engagement_time_msec: ms });
5476
+ this.lastEngagementAt = Date.now();
5477
+ }
5478
+ }
5479
+ /** Tick del flush periódico: latido de engagement si toca, y envío de lo acumulado. */
5480
+ onAnalyticsFlushTick() {
5481
+ if (this.sessionOpen && this.isDocumentVisible() && Date.now() - this.lastEngagementAt >= ANALYTICS_HEARTBEAT_MS) {
5482
+ this.flushEngagement();
5483
+ }
5484
+ this.flushAnalytics();
5485
+ }
5486
+ /**
5487
+ * La pestaña vuelve a verse. Tras más de ANALYTICS_SESSION_TIMEOUT_MS oculta, la sesión
5488
+ * anterior termina y empieza otra: seguir con el mismo sessionId mezclaba dos visitas y,
5489
+ * si el backend ya la había cerrado por inactividad, la partía en dos feedbacks.
5490
+ * - Oculta menos de ANALYTICS_SESSION_DISCARD_MS: el backend aún la tiene abierta → se
5491
+ * cierra con normalidad (page_view de la pantalla, session_end `idle_timeout`, completed).
5492
+ * - Más: ya la cerró el backend → se descarta sin enviar nada.
5493
+ */
5494
+ onDocumentVisible() {
5495
+ const hiddenFor = this.hiddenAt === null ? 0 : Date.now() - this.hiddenAt;
5496
+ this.hiddenAt = null;
5497
+ if (!this.sessionOpen) return;
5498
+ if (hiddenFor < ANALYTICS_SESSION_TIMEOUT_MS) {
5499
+ this.engagement?.resume();
5500
+ return;
5501
+ }
5502
+ if (hiddenFor < ANALYTICS_SESSION_DISCARD_MS) {
5503
+ this.closeSession("idle_timeout");
5504
+ } else {
5505
+ this.discardSession();
5506
+ }
5507
+ this.openSession();
5508
+ }
5509
+ /** Olvida la sesión abierta sin enviar nada (ver onDocumentVisible). */
5510
+ discardSession() {
5511
+ this.sessionOpen = false;
5512
+ this.navObserver?.discard();
5513
+ this.analytics?.discardSession(this.analyticsIdentity());
5514
+ this.engagement?.consume();
5515
+ this.engagement?.pause();
5516
+ this.analyticsFeedbackSessionId = void 0;
5517
+ this.tracking?.setSessionId(null);
5518
+ this.log("tracking \xB7 session discarded after", ANALYTICS_SESSION_DISCARD_MS / 6e4, "min hidden");
5519
+ }
5520
+ /**
5521
+ * Geolocalización por IP (país/ciudad para analytics). La caché persistente es local y se
5522
+ * aplica siempre que `geolocation` no esté desactivado. El lookup a terceros (ipapi.co →
5523
+ * ipwho.is → ipinfo.io, fallback con timeout) solo se hace si el dato se va a ENVIAR:
5524
+ * analytics configurado + tracking activo + caché ausente o caducada. Una vez por instancia.
5525
+ */
5526
+ resolveGeo() {
5527
+ const storage = this.storage;
5528
+ if (!this.geoEnabled || !storage || !this.analytics) return;
5529
+ const cachedGeo = readCachedGeo(storage, Date.now());
5530
+ if (cachedGeo) {
5531
+ this.analytics.updateDevice(cachedGeo);
5532
+ return;
5533
+ }
5534
+ if (!this.geoWanted || this.geoLookupStarted) return;
5535
+ if (!this.tracking?.isTrackingEnabled()) return;
5536
+ this.geoLookupStarted = true;
5537
+ fetchGeo().then((geo) => {
5538
+ if (geo) {
5539
+ this.analytics?.updateDevice(geo);
5540
+ writeCachedGeo(storage, geo, Date.now());
5541
+ }
5542
+ }).catch(() => {
5543
+ });
5426
5544
  }
5427
5545
  analyticsIdentity() {
5428
5546
  return {
@@ -5432,22 +5550,32 @@ var DeepdotsPopups = class {
5432
5550
  }
5433
5551
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
5434
5552
  setupAnalyticsFlush() {
5553
+ this.startAnalyticsFlushTimer();
5435
5554
  if (typeof document === "undefined" || typeof window === "undefined") return;
5436
- this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
5437
5555
  window.addEventListener("pagehide", () => {
5438
5556
  clearInterval(this.analyticsFlushTimer);
5439
5557
  this.closeSession("page_hide");
5440
5558
  });
5559
+ window.addEventListener("pageshow", (event) => {
5560
+ if (!event.persisted) return;
5561
+ this.startAnalyticsFlushTimer();
5562
+ this.openSession();
5563
+ });
5441
5564
  document.addEventListener("visibilitychange", () => {
5442
5565
  if (document.visibilityState === "hidden") {
5443
5566
  this.flushEngagement();
5444
5567
  this.engagement?.pause();
5445
5568
  this.flushAnalytics();
5569
+ this.hiddenAt = Date.now();
5446
5570
  } else if (document.visibilityState === "visible") {
5447
- this.engagement?.resume();
5571
+ this.onDocumentVisible();
5448
5572
  }
5449
5573
  });
5450
5574
  }
5575
+ startAnalyticsFlushTimer() {
5576
+ clearInterval(this.analyticsFlushTimer);
5577
+ this.analyticsFlushTimer = setInterval(() => this.onAnalyticsFlushTick(), ANALYTICS_FLUSH_INTERVAL_MS);
5578
+ }
5451
5579
  /** Enable auto-launch functionality with configured triggers */
5452
5580
  autoLaunch() {
5453
5581
  if (!this.initialized) {
@@ -5541,6 +5669,7 @@ var DeepdotsPopups = class {
5541
5669
  this.emitEvent("popup_shown", def.surveyId, { popupId: def.id });
5542
5670
  }
5543
5671
  shouldShow(def, pathUrl, skipPathCheck = false) {
5672
+ if (this.storage) this.loadPopupState(this.storage);
5544
5673
  if (this.isPathExcluded(def, pathUrl)) {
5545
5674
  return false;
5546
5675
  }
@@ -6067,22 +6196,32 @@ var DeepdotsPopups = class {
6067
6196
  this.persistPopupState();
6068
6197
  }
6069
6198
  /**
6070
- * Rehidrata el estado de redisplay desde el storage. `answeredSurveys` se reconstruye
6071
- * desde las entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
6199
+ * Fusiona el estado de redisplay del storage con el de memoria: por cada popup/survey gana
6200
+ * la entrada MÁS RECIENTE. Fusionar y no sustituir es lo que hace convivir a varias
6201
+ * pestañas sobre el mismo `localStorage`: se llama en el `init()`, antes de cada decisión
6202
+ * de mostrar y antes de cada escritura. `answeredSurveys` se reconstruye desde las
6203
+ * entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
6072
6204
  */
6073
6205
  loadPopupState(storage) {
6074
6206
  const state = readPopupState(storage);
6075
6207
  Object.entries(state.lastShown).forEach(([popupId, timestamp]) => {
6076
- this.lastShown.set(popupId, timestamp);
6208
+ if (timestamp > (this.lastShown.get(popupId) ?? -Infinity)) this.lastShown.set(popupId, timestamp);
6077
6209
  });
6078
6210
  Object.entries(state.progress).forEach(([surveyId, entry]) => {
6211
+ const current = this.surveyProgress.get(surveyId);
6212
+ if (current && current.timestamp >= entry.timestamp) return;
6079
6213
  this.surveyProgress.set(surveyId, { status: entry.status, timestamp: entry.timestamp });
6080
6214
  if (entry.status === "COMPLETED") this.answeredSurveys.add(surveyId);
6081
6215
  });
6082
6216
  }
6083
- /** Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria. */
6217
+ /**
6218
+ * Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria.
6219
+ * Fusiona antes de escribir: escribir solo la foto en memoria borraba lo que otra pestaña
6220
+ * hubiese guardado desde nuestro `init()`.
6221
+ */
6084
6222
  persistPopupState() {
6085
6223
  if (!this.storage) return;
6224
+ this.loadPopupState(this.storage);
6086
6225
  writePopupState(this.storage, {
6087
6226
  lastShown: Object.fromEntries(this.lastShown),
6088
6227
  progress: Object.fromEntries(this.surveyProgress)