@magicfeedback/popup-sdk 1.8.2 → 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/README.md CHANGED
@@ -122,6 +122,11 @@ Config fields:
122
122
  - `trackingEnabled?: boolean` — starts analytics on (default) or off, for consent flows
123
123
  - `analytics?: { publicKey: string; integration: string }` — enables the real analytics
124
124
  delivery. Without it the analytics channel stays in dry-run and only logs the payload.
125
+ - `geolocation?: boolean` — IP geolocation (country/city) for analytics, default `true`. It
126
+ calls third-party services (`ipapi.co`, then `ipwho.is`, then `ipinfo.io` as fallbacks, 3 s
127
+ timeout each, stopping at the first answer). It only runs when `analytics` is set, tracking
128
+ is enabled and the 30-day cache is missing or expired, at most once per SDK instance. Pass
129
+ `false` to never call those services nor attach country/city.
125
130
  - `appVersion?: string` — reported in the analytics device context
126
131
  - `storage?: KeyValueStorage` — persistence override (React Native uses MMKV; the browser
127
132
  defaults to `localStorage`)
@@ -268,7 +273,7 @@ session start/end and unhandled errors are automatic. The rest is instrumented b
268
273
  | `trackFunnelStep(funnel, step, taskId, params?)` | A funnel step, correlated by `taskId` |
269
274
  | `trackFindabilityFriction(topic, params?)` | Friction signal |
270
275
  | `trackMeaningfulInteraction(interactionType, params?)` | A meaningful interaction, grouped by `interaction_type` |
271
- | `enterMiniService(name, entryPointType?)` / `exitMiniService(name)` | Mini-service, with duration |
276
+ | `enterMiniService(name, entryPointType?)` / `exitMiniService(name)` | A task flow with a start and an end — [not a page](#mini-services--enterminiservicename-entrypointtype--exitminiservicename) |
272
277
  | `setUserAttributes(map)` / `setMetric(key, value)` | Breakdown dimensions / measurable values |
273
278
  | `setUserId(userId?)` | Login, logout or account switch |
274
279
  | `endSession()` | Closes the session explicitly |
@@ -332,6 +337,32 @@ values (oldest evicted first). A rejected event does not consume state: after a
332
337
  > restricted permissions, will not fire it — so a client-side `delivered` count sits below the
333
338
  > real one. For a reliable denominator, take `delivered` from your sending provider.
334
339
 
340
+ ### Mini-services — `enterMiniService(name, entryPointType?)` / `exitMiniService(name)`
341
+
342
+ A mini-service is a **self-contained task inside the app, with a start and an end**: a booking,
343
+ a checkout, an onboarding, a form the user fills in. Deepdots treats it as a flow. It reports
344
+ completion and drop-off per mini-service, draws it in the session journey as a lane between
345
+ where it was entered and where it was left, and tags every event emitted while it is active
346
+ with `mini_service`.
347
+
348
+ ```ts
349
+ popups.enterMiniService('checkout', 'cart_button'); // the user starts the task
350
+ // ...
351
+ popups.exitMiniService('checkout'); // and finishes it
352
+ ```
353
+
354
+ - Enter it when the task starts and exit it when the task is done. One still open when the
355
+ session closes is exited by the SDK.
356
+ - `entryPointType` is optional and says where the user started it from (a banner, a menu, a
357
+ deep link). It is sent as `entry_point_type`.
358
+
359
+ **A mini-service is not a page, a section or an area of the app.** Navigation is already
360
+ recorded as `page_view`, with the screen and how long the user stayed on it, and time per area
361
+ comes from grouping those page views by screen. Opening a mini-service for every section turns
362
+ each navigation into an abandoned flow: a move from `/feedback` to `/explorer` reads as
363
+ *"entered the explorer mini-service and never completed it"*, the journey loses the edge
364
+ between the two pages, and every section counts as drop-off.
365
+
335
366
  ## Supported Trigger Types
336
367
 
337
368
  Popup definitions use these trigger types:
package/dist/index.d.mts CHANGED
@@ -105,6 +105,12 @@ interface AnalyticsFlushMeta {
105
105
  * el `sessionId`, para que el lote siguiente abra un registro nuevo.
106
106
  */
107
107
  sessionEnd?: boolean;
108
+ /**
109
+ * `true` para olvidar la sesión SIN enviar nada (el payload llega vacío): el backend ya la
110
+ * cerró por inactividad. El sink descarta su `sessionId` para que el lote siguiente abra un
111
+ * registro nuevo, igual que tras `sessionEnd`, pero sin POST.
112
+ */
113
+ resetSession?: boolean;
108
114
  }
109
115
  /**
110
116
  * Envío del lote. Si devuelve una promesa que **rechaza**, el manager re-encola los
@@ -254,6 +260,13 @@ interface DeepdotsInitParams {
254
260
  * to the configured integration; if missing, it stays in dry-run mode (console.log only).
255
261
  */
256
262
  analytics?: AnalyticsKeys;
263
+ /**
264
+ * IP geolocation (country/city) added to analytics. Default `true`. The lookup calls
265
+ * third-party services (ipapi.co, then ipwho.is, then ipinfo.io as fallbacks, 3 s timeout
266
+ * each) and only runs when `analytics` is configured, tracking is enabled and the 30-day
267
+ * cache is missing or expired. Set `false` to never call them nor attach country/city.
268
+ */
269
+ geolocation?: boolean;
257
270
  /**
258
271
  * Internal user attributes known only by the host (language, age, plan, ...), sent
259
272
  * to the backend Contact for segmentation/targeting. Requires `userId`. Equivalent to calling
@@ -330,8 +343,10 @@ type EventListener = (event: DeepdotsEvent) => void;
330
343
  * - `user_change`: `setUserId()` o un `init()` con otro `userId` (login/logout del host).
331
344
  * - `tracking_disabled`: `setTrackingEnabled(false)` (consentimiento revocado).
332
345
  * - `manual`: `endSession()` llamado por el host.
346
+ * - `idle_timeout`: la pestaña vuelve a verse tras más de 30 min oculta (web). La sesión
347
+ * anterior se cierra y se abre otra, como hace GA con su timeout de sesión.
333
348
  */
334
- type SessionEndReason = 'page_hide' | 'background' | 'user_change' | 'tracking_disabled' | 'manual';
349
+ type SessionEndReason = 'page_hide' | 'background' | 'user_change' | 'tracking_disabled' | 'manual' | 'idle_timeout';
335
350
  /** Trigger type used by remote popup definitions */
336
351
  type PopupTriggerType = 'time_on_page' | 'scroll' | 'exit' | 'click' | 'event';
337
352
  type PopupTriggerConditionStatus = 'SHOWED' | 'PARTIAL' | 'COMPLETED';
@@ -704,11 +719,21 @@ declare class DeepdotsPopups {
704
719
  * nueva al volver a foreground o al conceder el consentimiento más tarde.
705
720
  */
706
721
  private sessionOpen;
722
+ /** Último `user_engagement` emitido (ms epoch): marca el latido. */
723
+ private lastEngagementAt;
724
+ /** Cuándo se ocultó la pestaña por última vez (ms epoch); null si está visible. */
725
+ private hiddenAt;
707
726
  /**
708
727
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
709
728
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
710
729
  */
711
730
  private language;
731
+ /** `geolocation` del init: si es false no se usa ni la caché ni el lookup. */
732
+ private geoEnabled;
733
+ /** Solo se consulta a terceros si el país/ciudad se va a enviar (analytics configurado). */
734
+ private geoWanted;
735
+ /** Evita más de un lookup por instancia (el consentimiento puede ir y venir). */
736
+ private geoLookupStarted;
712
737
  /** Initialize the SDK with configuration */
713
738
  init(config: DeepdotsInitParams): void;
714
739
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -820,6 +845,26 @@ declare class DeepdotsPopups {
820
845
  }): void;
821
846
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
822
847
  private flushEngagement;
848
+ /** Tick del flush periódico: latido de engagement si toca, y envío de lo acumulado. */
849
+ private onAnalyticsFlushTick;
850
+ /**
851
+ * La pestaña vuelve a verse. Tras más de ANALYTICS_SESSION_TIMEOUT_MS oculta, la sesión
852
+ * anterior termina y empieza otra: seguir con el mismo sessionId mezclaba dos visitas y,
853
+ * si el backend ya la había cerrado por inactividad, la partía en dos feedbacks.
854
+ * - Oculta menos de ANALYTICS_SESSION_DISCARD_MS: el backend aún la tiene abierta → se
855
+ * cierra con normalidad (page_view de la pantalla, session_end `idle_timeout`, completed).
856
+ * - Más: ya la cerró el backend → se descarta sin enviar nada.
857
+ */
858
+ private onDocumentVisible;
859
+ /** Olvida la sesión abierta sin enviar nada (ver onDocumentVisible). */
860
+ private discardSession;
861
+ /**
862
+ * Geolocalización por IP (país/ciudad para analytics). La caché persistente es local y se
863
+ * aplica siempre que `geolocation` no esté desactivado. El lookup a terceros (ipapi.co →
864
+ * ipwho.is → ipinfo.io, fallback con timeout) solo se hace si el dato se va a ENVIAR:
865
+ * analytics configurado + tracking activo + caché ausente o caducada. Una vez por instancia.
866
+ */
867
+ private resolveGeo;
823
868
  private analyticsIdentity;
824
869
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
825
870
  private setupAnalyticsFlush;
package/dist/index.d.ts CHANGED
@@ -105,6 +105,12 @@ interface AnalyticsFlushMeta {
105
105
  * el `sessionId`, para que el lote siguiente abra un registro nuevo.
106
106
  */
107
107
  sessionEnd?: boolean;
108
+ /**
109
+ * `true` para olvidar la sesión SIN enviar nada (el payload llega vacío): el backend ya la
110
+ * cerró por inactividad. El sink descarta su `sessionId` para que el lote siguiente abra un
111
+ * registro nuevo, igual que tras `sessionEnd`, pero sin POST.
112
+ */
113
+ resetSession?: boolean;
108
114
  }
109
115
  /**
110
116
  * Envío del lote. Si devuelve una promesa que **rechaza**, el manager re-encola los
@@ -254,6 +260,13 @@ interface DeepdotsInitParams {
254
260
  * to the configured integration; if missing, it stays in dry-run mode (console.log only).
255
261
  */
256
262
  analytics?: AnalyticsKeys;
263
+ /**
264
+ * IP geolocation (country/city) added to analytics. Default `true`. The lookup calls
265
+ * third-party services (ipapi.co, then ipwho.is, then ipinfo.io as fallbacks, 3 s timeout
266
+ * each) and only runs when `analytics` is configured, tracking is enabled and the 30-day
267
+ * cache is missing or expired. Set `false` to never call them nor attach country/city.
268
+ */
269
+ geolocation?: boolean;
257
270
  /**
258
271
  * Internal user attributes known only by the host (language, age, plan, ...), sent
259
272
  * to the backend Contact for segmentation/targeting. Requires `userId`. Equivalent to calling
@@ -330,8 +343,10 @@ type EventListener = (event: DeepdotsEvent) => void;
330
343
  * - `user_change`: `setUserId()` o un `init()` con otro `userId` (login/logout del host).
331
344
  * - `tracking_disabled`: `setTrackingEnabled(false)` (consentimiento revocado).
332
345
  * - `manual`: `endSession()` llamado por el host.
346
+ * - `idle_timeout`: la pestaña vuelve a verse tras más de 30 min oculta (web). La sesión
347
+ * anterior se cierra y se abre otra, como hace GA con su timeout de sesión.
333
348
  */
334
- type SessionEndReason = 'page_hide' | 'background' | 'user_change' | 'tracking_disabled' | 'manual';
349
+ type SessionEndReason = 'page_hide' | 'background' | 'user_change' | 'tracking_disabled' | 'manual' | 'idle_timeout';
335
350
  /** Trigger type used by remote popup definitions */
336
351
  type PopupTriggerType = 'time_on_page' | 'scroll' | 'exit' | 'click' | 'event';
337
352
  type PopupTriggerConditionStatus = 'SHOWED' | 'PARTIAL' | 'COMPLETED';
@@ -704,11 +719,21 @@ declare class DeepdotsPopups {
704
719
  * nueva al volver a foreground o al conceder el consentimiento más tarde.
705
720
  */
706
721
  private sessionOpen;
722
+ /** Último `user_engagement` emitido (ms epoch): marca el latido. */
723
+ private lastEngagementAt;
724
+ /** Cuándo se ocultó la pestaña por última vez (ms epoch); null si está visible. */
725
+ private hiddenAt;
707
726
  /**
708
727
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
709
728
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
710
729
  */
711
730
  private language;
731
+ /** `geolocation` del init: si es false no se usa ni la caché ni el lookup. */
732
+ private geoEnabled;
733
+ /** Solo se consulta a terceros si el país/ciudad se va a enviar (analytics configurado). */
734
+ private geoWanted;
735
+ /** Evita más de un lookup por instancia (el consentimiento puede ir y venir). */
736
+ private geoLookupStarted;
712
737
  /** Initialize the SDK with configuration */
713
738
  init(config: DeepdotsInitParams): void;
714
739
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -820,6 +845,26 @@ declare class DeepdotsPopups {
820
845
  }): void;
821
846
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
822
847
  private flushEngagement;
848
+ /** Tick del flush periódico: latido de engagement si toca, y envío de lo acumulado. */
849
+ private onAnalyticsFlushTick;
850
+ /**
851
+ * La pestaña vuelve a verse. Tras más de ANALYTICS_SESSION_TIMEOUT_MS oculta, la sesión
852
+ * anterior termina y empieza otra: seguir con el mismo sessionId mezclaba dos visitas y,
853
+ * si el backend ya la había cerrado por inactividad, la partía en dos feedbacks.
854
+ * - Oculta menos de ANALYTICS_SESSION_DISCARD_MS: el backend aún la tiene abierta → se
855
+ * cierra con normalidad (page_view de la pantalla, session_end `idle_timeout`, completed).
856
+ * - Más: ya la cerró el backend → se descarta sin enviar nada.
857
+ */
858
+ private onDocumentVisible;
859
+ /** Olvida la sesión abierta sin enviar nada (ver onDocumentVisible). */
860
+ private discardSession;
861
+ /**
862
+ * Geolocalización por IP (país/ciudad para analytics). La caché persistente es local y se
863
+ * aplica siempre que `geolocation` no esté desactivado. El lookup a terceros (ipapi.co →
864
+ * ipwho.is → ipinfo.io, fallback con timeout) solo se hace si el dato se va a ENVIAR:
865
+ * analytics configurado + tracking activo + caché ausente o caducada. Una vez por instancia.
866
+ */
867
+ private resolveGeo;
823
868
  private analyticsIdentity;
824
869
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
825
870
  private setupAnalyticsFlush;
package/dist/index.js CHANGED
@@ -2454,9 +2454,9 @@ var init_logo = __esm({
2454
2454
  }
2455
2455
  });
2456
2456
 
2457
- // ../../../../../../../Users/sarias/developer/Deepdots/deepdots-popup-sdk/node_modules/@magicfeedback/native/dist/magicfeedback-sdk.browser.js
2457
+ // node_modules/@magicfeedback/native/dist/magicfeedback-sdk.browser.js
2458
2458
  var require_magicfeedback_sdk_browser = __commonJS({
2459
- "../../../../../../../Users/sarias/developer/Deepdots/deepdots-popup-sdk/node_modules/@magicfeedback/native/dist/magicfeedback-sdk.browser.js"(exports, module2) {
2459
+ "node_modules/@magicfeedback/native/dist/magicfeedback-sdk.browser.js"(exports, module2) {
2460
2460
  "use strict";
2461
2461
  !(function(e, t) {
2462
2462
  "object" == typeof exports && "object" == typeof module2 ? module2.exports = t() : "function" == typeof define && define.amd ? define([], t) : "object" == typeof exports ? exports.magicfeedback = t() : e.magicfeedback = t();
@@ -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
@@ -6843,6 +6859,12 @@ function createFeedbackSink(options) {
6843
6859
  }
6844
6860
  };
6845
6861
  return async (envelope, meta) => {
6862
+ if (meta?.resetSession) {
6863
+ feedbackSessionId = void 0;
6864
+ firstPostInFlight = null;
6865
+ options.onSessionReset?.();
6866
+ return;
6867
+ }
6846
6868
  const final = meta?.final === true;
6847
6869
  const sessionEnd = meta?.sessionEnd === true;
6848
6870
  if (!feedbackSessionId && firstPostInFlight && !final) {
@@ -6909,6 +6931,14 @@ var NavigationObserver = class {
6909
6931
  this.closeCurrent();
6910
6932
  this.currentScreen = null;
6911
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
+ }
6912
6942
  /**
6913
6943
  * Retoma el seguimiento en la pantalla actual tras un `stop()`, cuando se abre una sesión
6914
6944
  * nueva sin navegar (cambio de usuario, vuelta de la bfcache). No-op si nunca se instaló
@@ -7442,6 +7472,10 @@ var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
7442
7472
  var DAY_IN_MS = 24 * 60 * 60 * 1e3;
7443
7473
  var ANALYTICS_MAX_BATCH_SIZE = 20;
7444
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;
7445
7479
  var RESERVED_EVENT_PREFIX = "deepdots_";
7446
7480
  var CUSTOM_EVENT_PREFIX = "deepdots_event_";
7447
7481
  var DeepdotsPopups = class {
@@ -7505,11 +7539,21 @@ var DeepdotsPopups = class {
7505
7539
  * nueva al volver a foreground o al conceder el consentimiento más tarde.
7506
7540
  */
7507
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;
7508
7546
  /**
7509
7547
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
7510
7548
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
7511
7549
  */
7512
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;
7513
7557
  }
7514
7558
  /** Initialize the SDK with configuration */
7515
7559
  init(config) {
@@ -7595,15 +7639,9 @@ var DeepdotsPopups = class {
7595
7639
  if (this.tracking?.isTrackingEnabled()) {
7596
7640
  this.crashReporter.install();
7597
7641
  }
7598
- const cachedGeo = readCachedGeo(storage, Date.now());
7599
- if (cachedGeo) this.analytics.updateDevice(cachedGeo);
7600
- fetchGeo().then((geo) => {
7601
- if (geo) {
7602
- this.analytics?.updateDevice(geo);
7603
- writeCachedGeo(storage, geo, Date.now());
7604
- }
7605
- }).catch(() => {
7606
- });
7642
+ this.geoEnabled = config.geolocation ?? true;
7643
+ this.geoWanted = this.geoEnabled && !!config.analytics;
7644
+ this.resolveGeo();
7607
7645
  this.navObserver = new NavigationObserver();
7608
7646
  this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
7609
7647
  this.navObserver.install();
@@ -7645,6 +7683,7 @@ var DeepdotsPopups = class {
7645
7683
  if (!enabled) this.closeSession("tracking_disabled");
7646
7684
  this.tracking?.setTrackingEnabled(enabled);
7647
7685
  if (enabled) this.openSession();
7686
+ if (enabled) this.resolveGeo();
7648
7687
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
7649
7688
  }
7650
7689
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -7841,6 +7880,7 @@ var DeepdotsPopups = class {
7841
7880
  if (this.sessionOpen) return;
7842
7881
  if (!this.tracking?.isTrackingEnabled()) return;
7843
7882
  this.sessionOpen = true;
7883
+ this.lastEngagementAt = Date.now();
7844
7884
  this.track("deepdots_session_start", {});
7845
7885
  this.navObserver?.restart();
7846
7886
  if (this.isDocumentVisible()) this.engagement?.resume();
@@ -7871,7 +7911,76 @@ var DeepdotsPopups = class {
7871
7911
  flushEngagement() {
7872
7912
  if (!this.tracking?.isTrackingEnabled()) return;
7873
7913
  const ms = this.engagement?.consume() ?? 0;
7874
- 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
+ });
7875
7984
  }
7876
7985
  analyticsIdentity() {
7877
7986
  return {
@@ -7881,8 +7990,8 @@ var DeepdotsPopups = class {
7881
7990
  }
7882
7991
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
7883
7992
  setupAnalyticsFlush() {
7884
- if (typeof document === "undefined" || typeof window === "undefined") return;
7885
7993
  this.startAnalyticsFlushTimer();
7994
+ if (typeof document === "undefined" || typeof window === "undefined") return;
7886
7995
  window.addEventListener("pagehide", () => {
7887
7996
  clearInterval(this.analyticsFlushTimer);
7888
7997
  this.closeSession("page_hide");
@@ -7897,14 +8006,15 @@ var DeepdotsPopups = class {
7897
8006
  this.flushEngagement();
7898
8007
  this.engagement?.pause();
7899
8008
  this.flushAnalytics();
8009
+ this.hiddenAt = Date.now();
7900
8010
  } else if (document.visibilityState === "visible") {
7901
- if (this.sessionOpen) this.engagement?.resume();
8011
+ this.onDocumentVisible();
7902
8012
  }
7903
8013
  });
7904
8014
  }
7905
8015
  startAnalyticsFlushTimer() {
7906
8016
  clearInterval(this.analyticsFlushTimer);
7907
- this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
8017
+ this.analyticsFlushTimer = setInterval(() => this.onAnalyticsFlushTick(), ANALYTICS_FLUSH_INTERVAL_MS);
7908
8018
  }
7909
8019
  /** Enable auto-launch functionality with configured triggers */
7910
8020
  autoLaunch() {