@magicfeedback/popup-sdk 1.1.7 → 1.2.0

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.
@@ -366,7 +366,7 @@ var BrowserPopupRenderer = class {
366
366
  if (!this.container) return;
367
367
  this.visible = true;
368
368
  const container = this.container;
369
- void import("./renderPopup-PS3NJDUR.mjs").then(({ renderPopup }) => {
369
+ void import("./renderPopup-NHWJ7YSI.mjs").then(({ renderPopup }) => {
370
370
  renderPopup(container, surveyId, productId, actions, emit, onClose, env, userId, style, sessionId, miniService, analyticsFeedbackSessionId);
371
371
  });
372
372
  }
@@ -410,6 +410,7 @@ var AnalyticsManager = class {
410
410
  this.device = options.device;
411
411
  this.platform = options.platform ?? "web";
412
412
  this.maxBatchSize = options.maxBatchSize ?? 20;
413
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
413
414
  this.onFlushNeeded = options.onFlushNeeded;
414
415
  }
415
416
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -458,6 +459,15 @@ var AnalyticsManager = class {
458
459
  this.exitMiniService(name);
459
460
  }
460
461
  }
462
+ /**
463
+ * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
464
+ * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
465
+ * vieja, y lo que llegue después es del usuario nuevo.
466
+ */
467
+ resetUserScope() {
468
+ this.attributes = {};
469
+ this.metrics = {};
470
+ }
461
471
  /** Registra un evento de analítica (modelo GA: nombre + params). */
462
472
  track(name, params) {
463
473
  const current = this.getMiniService();
@@ -470,6 +480,7 @@ var AnalyticsManager = class {
470
480
  timestamp: this.now(),
471
481
  params: Object.keys(merged).length ? merged : void 0
472
482
  });
483
+ this.enforceBufferCap();
473
484
  if (this.events.length >= this.maxBatchSize) {
474
485
  this.onFlushNeeded?.();
475
486
  }
@@ -500,14 +511,43 @@ var AnalyticsManager = class {
500
511
  events: [...this.events]
501
512
  };
502
513
  }
503
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
504
- flush(identity) {
514
+ /**
515
+ * Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos.
516
+ *
517
+ * Si el sink falla de forma transitoria (promesa rechazada o excepción síncrona) el lote
518
+ * se **re-encola** al principio del buffer y se reintenta en el siguiente flush: un error
519
+ * de red o un 5xx no debe hacer desaparecer los eventos.
520
+ */
521
+ flush(identity, meta) {
505
522
  if (this.events.length === 0) return null;
523
+ const batch = this.events;
506
524
  const payload = this.buildPayload(identity);
507
- this.sink(payload);
508
525
  this.events = [];
526
+ let result;
527
+ try {
528
+ result = meta ? this.sink(payload, meta) : this.sink(payload);
529
+ } catch {
530
+ this.requeue(batch);
531
+ return payload;
532
+ }
533
+ if (result && typeof result.then === "function") {
534
+ result.catch(() => this.requeue(batch));
535
+ }
509
536
  return payload;
510
537
  }
538
+ /**
539
+ * Devuelve un lote fallido al buffer, delante de lo que haya llegado mientras se enviaba
540
+ * (orden cronológico). Con el buffer lleno se descartan los eventos más antiguos.
541
+ */
542
+ requeue(batch) {
543
+ this.events = [...batch, ...this.events];
544
+ this.enforceBufferCap();
545
+ }
546
+ /** Techo del buffer: si el envío lleva rato fallando, se sacrifica lo más antiguo. */
547
+ enforceBufferCap() {
548
+ const overflow = this.events.length - this.maxBufferedEvents;
549
+ if (overflow > 0) this.events.splice(0, overflow);
550
+ }
511
551
  };
512
552
 
513
553
  // src/analytics/feedback-payload.ts
@@ -515,7 +555,7 @@ function pushKV(list, key, value) {
515
555
  if (value === void 0 || value === null || value === "") return;
516
556
  list.push({ key, value: [String(value)] });
517
557
  }
518
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
558
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
519
559
  const { context } = envelope;
520
560
  const profile = [];
521
561
  pushKV(profile, "external-user-id", envelope.userId);
@@ -566,37 +606,91 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
566
606
  },
567
607
  publicKey: keys.publicKey,
568
608
  integration: keys.integration,
569
- completed: false,
609
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
610
+ completed: options?.sessionEnd === true,
570
611
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
571
612
  };
572
613
  }
614
+ var KEEPALIVE_MAX_BYTES = 6e4;
615
+ function isRetryableStatus(status) {
616
+ return status >= 500 || status === 408 || status === 429;
617
+ }
618
+ async function safeBodyText(res) {
619
+ try {
620
+ return (await res.text()).slice(0, 500);
621
+ } catch {
622
+ return "";
623
+ }
624
+ }
573
625
  function createFeedbackSink(options) {
574
626
  let feedbackSessionId;
575
- return (envelope) => {
576
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
627
+ let firstPostInFlight = null;
628
+ const url = `${options.baseUrl}/sdk/feedback`;
629
+ const post = async (body, final, sessionEnd = false) => {
630
+ const json = JSON.stringify(body);
631
+ const small = json.length <= KEEPALIVE_MAX_BYTES;
632
+ if (final && options.sendBeaconImpl && small) {
633
+ const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
634
+ if (options.sendBeaconImpl(url, payload)) {
635
+ options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
636
+ return;
637
+ }
638
+ options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
639
+ }
577
640
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
578
641
  if (!f) {
579
642
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
580
643
  return;
581
644
  }
582
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
583
- f(`${options.baseUrl}/sdk/feedback`, {
645
+ const res = await f(url, {
584
646
  method: "POST",
585
647
  headers: { "Content-Type": "application/json" },
586
- body: JSON.stringify(body)
587
- }).then(async (res) => {
588
- if (res.ok) {
589
- try {
590
- const data = await res.json();
591
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
592
- feedbackSessionId = data.sessionId;
593
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
594
- options.onSessionId?.(feedbackSessionId);
595
- }
596
- } catch {
597
- }
648
+ body: json,
649
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
650
+ ...small ? { keepalive: true } : {}
651
+ });
652
+ if (!res.ok) {
653
+ const detail = await safeBodyText(res);
654
+ if (isRetryableStatus(res.status)) {
655
+ throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
598
656
  }
599
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
657
+ options.log?.(
658
+ `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
659
+ detail
660
+ );
661
+ return;
662
+ }
663
+ if (sessionEnd) return;
664
+ try {
665
+ const data = await res.json();
666
+ if (data?.sessionId && data.sessionId !== feedbackSessionId) {
667
+ feedbackSessionId = data.sessionId;
668
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
669
+ options.onSessionId?.(feedbackSessionId);
670
+ }
671
+ } catch {
672
+ }
673
+ };
674
+ return async (envelope, meta) => {
675
+ const final = meta?.final === true;
676
+ const sessionEnd = meta?.sessionEnd === true;
677
+ if (!feedbackSessionId && firstPostInFlight && !final) {
678
+ await firstPostInFlight;
679
+ }
680
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
681
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
682
+ const sent = post(body, final, sessionEnd);
683
+ if (sessionEnd) {
684
+ feedbackSessionId = void 0;
685
+ firstPostInFlight = null;
686
+ options.onSessionReset?.();
687
+ } else if (!feedbackSessionId) {
688
+ firstPostInFlight = sent.then(
689
+ () => void 0,
690
+ () => void 0
691
+ );
692
+ }
693
+ await sent;
600
694
  };
601
695
  }
602
696
 
@@ -1025,6 +1119,7 @@ var CrashReporter = class {
1025
1119
  };
1026
1120
 
1027
1121
  // src/analytics/messaging.ts
1122
+ var MESSAGE_CHANNELS = ["push", "in_app"];
1028
1123
  function buildMessageParams(stage, o) {
1029
1124
  const p = {
1030
1125
  stage,
@@ -1037,6 +1132,57 @@ function buildMessageParams(stage, o) {
1037
1132
  if (o.currency) p.currency = o.currency;
1038
1133
  return { ...p, ...o.params ?? {} };
1039
1134
  }
1135
+ var MAX_TRACKED_MESSAGES = 500;
1136
+ var MessageGuard = class {
1137
+ constructor(maxTrackedMessages = MAX_TRACKED_MESSAGES) {
1138
+ this.maxTrackedMessages = maxTrackedMessages;
1139
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
1140
+ this.seen = /* @__PURE__ */ new Map();
1141
+ }
1142
+ evaluate(stage, options) {
1143
+ const channel = options.channel;
1144
+ if (!MESSAGE_CHANNELS.includes(channel)) {
1145
+ return {
1146
+ emit: false,
1147
+ reason: "invalid_channel",
1148
+ detail: `channel "${channel}" no v\xE1lido (esperado ${MESSAGE_CHANNELS.join(" | ")})`
1149
+ };
1150
+ }
1151
+ const entry = this.seen.get(options.id);
1152
+ if (!entry) {
1153
+ this.remember(options.id, channel, stage);
1154
+ return { emit: true };
1155
+ }
1156
+ if (entry.channel !== channel) {
1157
+ return {
1158
+ emit: false,
1159
+ reason: "channel_conflict",
1160
+ detail: `message_id "${options.id}" ya se report\xF3 en channel "${entry.channel}"; se descarta "${channel}"`
1161
+ };
1162
+ }
1163
+ if (entry.stages.has(stage)) {
1164
+ return {
1165
+ emit: false,
1166
+ reason: "duplicate_stage",
1167
+ detail: `stage "${stage}" ya emitido para message_id "${options.id}"`
1168
+ };
1169
+ }
1170
+ entry.stages.add(stage);
1171
+ return { emit: true };
1172
+ }
1173
+ /** Olvida todo lo vigilado (nueva sesión). */
1174
+ reset() {
1175
+ this.seen.clear();
1176
+ }
1177
+ remember(id, channel, stage) {
1178
+ this.seen.set(id, { channel, stages: /* @__PURE__ */ new Set([stage]) });
1179
+ while (this.seen.size > this.maxTrackedMessages) {
1180
+ const oldest = this.seen.keys().next();
1181
+ if (oldest.done) break;
1182
+ this.seen.delete(oldest.value);
1183
+ }
1184
+ }
1185
+ };
1040
1186
 
1041
1187
  // src/core/deepdots-popups.ts
1042
1188
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -1078,20 +1224,40 @@ var DeepdotsPopups = class {
1078
1224
  this.analyticsFlushTimer = void 0;
1079
1225
  /** Crash & error reporting (#14–17). Null hasta init(). */
1080
1226
  this.crashReporter = null;
1227
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
1228
+ this.messageGuard = new MessageGuard();
1081
1229
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
1082
1230
  this.navObserver = null;
1083
1231
  /** Tiempo activo (engagement time, #8). */
1084
1232
  this.engagement = null;
1085
1233
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
1086
1234
  this.contact = null;
1235
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
1236
+ this.storage = null;
1087
1237
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
1088
1238
  this.navStarted = false;
1239
+ /**
1240
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
1241
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
1242
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
1243
+ */
1244
+ this.sessionOpen = false;
1245
+ /**
1246
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
1247
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
1248
+ */
1249
+ this.language = void 0;
1089
1250
  }
1090
1251
  /** Initialize the SDK with configuration */
1091
1252
  init(config) {
1092
1253
  this.logger = config.logger ?? console;
1093
1254
  setLogger(this.logger);
1094
1255
  if (this.initialized) {
1256
+ const nextUserId = config.userId || void 0;
1257
+ if (nextUserId !== this.config?.userId) {
1258
+ this.setUserId(nextUserId);
1259
+ return;
1260
+ }
1095
1261
  this.log("SDK already initialized");
1096
1262
  return;
1097
1263
  }
@@ -1104,6 +1270,7 @@ var DeepdotsPopups = class {
1104
1270
  userId: config.userId || void 0
1105
1271
  };
1106
1272
  const storage = config.storage ?? createDefaultStorage();
1273
+ this.storage = storage;
1107
1274
  this.tracking = new TrackingManager({
1108
1275
  storage,
1109
1276
  clientUserId: this.config.userId,
@@ -1127,19 +1294,26 @@ var DeepdotsPopups = class {
1127
1294
  baseUrl: this.baseUrl,
1128
1295
  keys: config.analytics,
1129
1296
  log: (...a) => this.log(...a),
1297
+ // sendBeacon: único transporte que sobrevive al cierre de la página.
1298
+ sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
1130
1299
  onSessionId: (id) => {
1131
1300
  this.analyticsFeedbackSessionId = id;
1301
+ },
1302
+ onSessionReset: () => {
1303
+ this.analyticsFeedbackSessionId = void 0;
1132
1304
  }
1133
1305
  }) : createDryRunSink((...a) => this.logger.log(...a));
1134
1306
  const device = config.device ?? collectDeviceInfo(config.appVersion);
1307
+ this.language = resolveLanguage({
1308
+ explicit: config.language,
1309
+ navigator: typeof navigator !== "undefined" ? navigator : void 0,
1310
+ intl: typeof Intl !== "undefined" ? Intl : void 0
1311
+ });
1312
+ this.messageGuard.reset();
1135
1313
  this.analytics = new AnalyticsManager({
1136
1314
  sink: analyticsSink,
1137
1315
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
1138
- language: resolveLanguage({
1139
- explicit: config.language,
1140
- navigator: typeof navigator !== "undefined" ? navigator : void 0,
1141
- intl: typeof Intl !== "undefined" ? Intl : void 0
1142
- }),
1316
+ language: this.language,
1143
1317
  platform: config.platform ?? "web",
1144
1318
  device,
1145
1319
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -1171,7 +1345,7 @@ var DeepdotsPopups = class {
1171
1345
  this.engagement = new EngagementTracker();
1172
1346
  this.engagement.resume();
1173
1347
  this.setupAnalyticsFlush();
1174
- this.track("deepdots_session_start", {});
1348
+ this.openSession();
1175
1349
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
1176
1350
  if (this.tracking?.isTrackingEnabled()) {
1177
1351
  for (const rec of pendingCrashes) {
@@ -1203,7 +1377,9 @@ var DeepdotsPopups = class {
1203
1377
  }
1204
1378
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
1205
1379
  setTrackingEnabled(enabled) {
1380
+ if (!enabled) this.closeSession("tracking_disabled");
1206
1381
  this.tracking?.setTrackingEnabled(enabled);
1382
+ if (enabled) this.openSession();
1207
1383
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
1208
1384
  }
1209
1385
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -1273,8 +1449,18 @@ var DeepdotsPopups = class {
1273
1449
  trackFunnelStep(funnel, step, taskId, params) {
1274
1450
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1275
1451
  }
1276
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
1452
+ /**
1453
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
1454
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
1455
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
1456
+ * en otro canal — ver `MessageGuard`.
1457
+ */
1277
1458
  trackMessage(stage, options) {
1459
+ const verdict = this.messageGuard.evaluate(stage, options);
1460
+ if (!verdict.emit) {
1461
+ console.warn(`[DeepdotsPopups] trackMessage descartado (${verdict.reason}): ${verdict.detail}`);
1462
+ return;
1463
+ }
1278
1464
  this.track("deepdots_message", buildMessageParams(stage, options));
1279
1465
  }
1280
1466
  /**
@@ -1290,21 +1476,86 @@ var DeepdotsPopups = class {
1290
1476
  this.navObserver.visit(name);
1291
1477
  }
1292
1478
  }
1293
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
1479
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
1294
1480
  onForeground() {
1295
1481
  this.engagement?.resume();
1482
+ this.openSession();
1296
1483
  }
1297
1484
  /**
1298
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
1299
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
1485
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
1486
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
1487
+ * envía el último lote con `completed:true`.
1488
+ *
1489
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
1300
1490
  */
1301
1491
  onBackground() {
1492
+ this.closeSession("background");
1493
+ this.engagement?.pause();
1494
+ }
1495
+ /**
1496
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
1497
+ * `session_end` y envía el último lote con `completed:true`.
1498
+ */
1499
+ endSession() {
1500
+ this.closeSession("manual");
1501
+ }
1502
+ /**
1503
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
1504
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
1505
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
1506
+ *
1507
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
1508
+ */
1509
+ setUserId(userId) {
1510
+ const next = userId || void 0;
1511
+ if (!this.initialized || !this.config || next === this.config.userId) return;
1512
+ this.closeSession("user_change");
1513
+ const enabled = this.tracking?.isTrackingEnabled() ?? true;
1514
+ const storage = this.storage ?? createDefaultStorage();
1515
+ this.config.userId = next;
1516
+ this.tracking = new TrackingManager({ storage, clientUserId: next, enabled });
1517
+ this.contact = next ? new ContactManager({
1518
+ storage,
1519
+ publicKey: this.config.apiKey ?? "",
1520
+ userId: next,
1521
+ post: (body) => this.postContact(body)
1522
+ }) : null;
1523
+ this.analytics?.resetUserScope();
1524
+ this.messageGuard.reset();
1525
+ this.log("tracking \xB7 user_change \xB7 user_id:", this.tracking.getUserId());
1526
+ this.openSession();
1527
+ }
1528
+ /**
1529
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
1530
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
1531
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
1532
+ *
1533
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
1534
+ * el `session_end`.
1535
+ */
1536
+ closeSession(reason) {
1537
+ if (!this.sessionOpen) return;
1538
+ if (!this.tracking?.isTrackingEnabled()) return;
1539
+ this.sessionOpen = false;
1302
1540
  this.navObserver?.stop();
1303
1541
  this.navStarted = false;
1304
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1542
+ this.analytics?.exitAllMiniServices();
1305
1543
  this.flushEngagement();
1306
- this.engagement?.pause();
1307
- this.flushAnalytics();
1544
+ this.track("deepdots_session_end", { reason });
1545
+ this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
1546
+ this.analyticsFeedbackSessionId = void 0;
1547
+ this.tracking?.setSessionId(null);
1548
+ this.log("tracking \xB7 session_end:", reason);
1549
+ }
1550
+ /**
1551
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
1552
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
1553
+ */
1554
+ openSession() {
1555
+ if (this.sessionOpen) return;
1556
+ if (!this.tracking?.isTrackingEnabled()) return;
1557
+ this.sessionOpen = true;
1558
+ this.track("deepdots_session_start", {});
1308
1559
  }
1309
1560
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
1310
1561
  previewAnalytics() {
@@ -1315,10 +1566,14 @@ var DeepdotsPopups = class {
1315
1566
  events: []
1316
1567
  };
1317
1568
  }
1318
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
1319
- flushAnalytics() {
1569
+ /**
1570
+ * Envía el lote acumulado de analytics y vacía el buffer.
1571
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
1572
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
1573
+ */
1574
+ flushAnalytics(options) {
1320
1575
  if (!this.tracking?.isTrackingEnabled()) return;
1321
- this.analytics?.flush(this.analyticsIdentity());
1576
+ this.analytics?.flush(this.analyticsIdentity(), options);
1322
1577
  }
1323
1578
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
1324
1579
  flushEngagement() {
@@ -1338,10 +1593,7 @@ var DeepdotsPopups = class {
1338
1593
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
1339
1594
  window.addEventListener("pagehide", () => {
1340
1595
  clearInterval(this.analyticsFlushTimer);
1341
- this.navObserver?.stop();
1342
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1343
- this.flushEngagement();
1344
- this.flushAnalytics();
1596
+ this.closeSession("page_hide");
1345
1597
  });
1346
1598
  document.addEventListener("visibilitychange", () => {
1347
1599
  if (document.visibilityState === "hidden") {
@@ -1462,14 +1714,16 @@ var DeepdotsPopups = class {
1462
1714
  matchesSegmentsLang(def) {
1463
1715
  const langs = def.segments?.lang;
1464
1716
  if (!langs || langs.length === 0) return true;
1465
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
1466
- if (!browserLang) {
1467
- this.debug("No navigator.language available for lang comparison", { popupId: def.id, langs });
1717
+ const currentLang = (this.language ?? "").toLowerCase();
1718
+ if (!currentLang) {
1719
+ this.debug("No language available for lang comparison", { popupId: def.id, langs });
1468
1720
  return true;
1469
1721
  }
1470
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
1722
+ const matches = langs.some(
1723
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
1724
+ );
1471
1725
  if (!matches) {
1472
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
1726
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
1473
1727
  }
1474
1728
  return matches;
1475
1729
  }
@@ -1801,7 +2055,7 @@ var DeepdotsPopups = class {
1801
2055
  }
1802
2056
  if (!this.popupContainer) return;
1803
2057
  const container = this.popupContainer;
1804
- void import("./renderPopup-PS3NJDUR.mjs").then(({ renderPopup }) => {
2058
+ void import("./renderPopup-NHWJ7YSI.mjs").then(({ renderPopup }) => {
1805
2059
  renderPopup(
1806
2060
  container,
1807
2061
  surveyId,
@@ -2014,7 +2268,8 @@ function setupReactNative(sdk2, config, deps = {}) {
2014
2268
  if (deps.appState?.addEventListener) {
2015
2269
  subscription = deps.appState.addEventListener("change", (state) => {
2016
2270
  if (state === "active") sdk2.onForeground();
2017
- else sdk2.onBackground();
2271
+ else if (state === "background") sdk2.onBackground();
2272
+ else sdk2.flushAnalytics();
2018
2273
  });
2019
2274
  }
2020
2275
  return () => {