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