@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.js CHANGED
@@ -3077,6 +3077,11 @@ async function renderPopup(container, surveyId, productId, actions, emit, onClos
3077
3077
  const isDark = style?.theme === "dark";
3078
3078
  const theme = {
3079
3079
  popupBg: isDark ? "#1e1e1e" : "#fff",
3080
+ // El popup fija su propio fondo, así que debe fijar también el esquema de color.
3081
+ // Sin esto los controles de formulario (input/textarea/select) caen al estilo por
3082
+ // defecto del navegador: en un host con `prefers-color-scheme: dark` se pintan
3083
+ // oscuros con texto blanco sobre el fondo claro del popup.
3084
+ colorScheme: isDark ? "dark" : "light",
3080
3085
  textPrimary: isDark ? "#f0f0f0" : "#111",
3081
3086
  closeBtnHoverBg: isDark ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.06)",
3082
3087
  closeBtnHoverColor: isDark ? "#fff" : "#000000"
@@ -3099,6 +3104,7 @@ async function renderPopup(container, surveyId, productId, actions, emit, onClos
3099
3104
  flex-direction: column;
3100
3105
  justify-content: flex-start;
3101
3106
  background: ${theme.popupBg};
3107
+ color-scheme: ${theme.colorScheme};
3102
3108
  border-radius: 8px;
3103
3109
  padding: 24px;
3104
3110
  box-shadow: 0 4px 6px rgba(0,0,0,0.1);
@@ -3550,6 +3556,9 @@ __export(index_exports, {
3550
3556
  CrashReporter: () => CrashReporter,
3551
3557
  DeepdotsPopups: () => DeepdotsPopups,
3552
3558
  InMemoryStorage: () => InMemoryStorage,
3559
+ MAX_TRACKED_MESSAGES: () => MAX_TRACKED_MESSAGES,
3560
+ MESSAGE_CHANNELS: () => MESSAGE_CHANNELS,
3561
+ MessageGuard: () => MessageGuard,
3553
3562
  NoopPopupRenderer: () => NoopPopupRenderer,
3554
3563
  POPUP_TRIGGER_CONDITION_STATUSES: () => POPUP_TRIGGER_CONDITION_STATUSES,
3555
3564
  ReactNativePopupRenderer: () => ReactNativePopupRenderer,
@@ -3968,6 +3977,7 @@ var AnalyticsManager = class {
3968
3977
  this.device = options.device;
3969
3978
  this.platform = options.platform ?? "web";
3970
3979
  this.maxBatchSize = options.maxBatchSize ?? 20;
3980
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
3971
3981
  this.onFlushNeeded = options.onFlushNeeded;
3972
3982
  }
3973
3983
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -4016,6 +4026,15 @@ var AnalyticsManager = class {
4016
4026
  this.exitMiniService(name);
4017
4027
  }
4018
4028
  }
4029
+ /**
4030
+ * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
4031
+ * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
4032
+ * vieja, y lo que llegue después es del usuario nuevo.
4033
+ */
4034
+ resetUserScope() {
4035
+ this.attributes = {};
4036
+ this.metrics = {};
4037
+ }
4019
4038
  /** Registra un evento de analítica (modelo GA: nombre + params). */
4020
4039
  track(name, params) {
4021
4040
  const current = this.getMiniService();
@@ -4028,6 +4047,7 @@ var AnalyticsManager = class {
4028
4047
  timestamp: this.now(),
4029
4048
  params: Object.keys(merged).length ? merged : void 0
4030
4049
  });
4050
+ this.enforceBufferCap();
4031
4051
  if (this.events.length >= this.maxBatchSize) {
4032
4052
  this.onFlushNeeded?.();
4033
4053
  }
@@ -4058,14 +4078,43 @@ var AnalyticsManager = class {
4058
4078
  events: [...this.events]
4059
4079
  };
4060
4080
  }
4061
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
4062
- flush(identity) {
4081
+ /**
4082
+ * Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos.
4083
+ *
4084
+ * Si el sink falla de forma transitoria (promesa rechazada o excepción síncrona) el lote
4085
+ * se **re-encola** al principio del buffer y se reintenta en el siguiente flush: un error
4086
+ * de red o un 5xx no debe hacer desaparecer los eventos.
4087
+ */
4088
+ flush(identity, meta) {
4063
4089
  if (this.events.length === 0) return null;
4090
+ const batch = this.events;
4064
4091
  const payload = this.buildPayload(identity);
4065
- this.sink(payload);
4066
4092
  this.events = [];
4093
+ let result;
4094
+ try {
4095
+ result = meta ? this.sink(payload, meta) : this.sink(payload);
4096
+ } catch {
4097
+ this.requeue(batch);
4098
+ return payload;
4099
+ }
4100
+ if (result && typeof result.then === "function") {
4101
+ result.catch(() => this.requeue(batch));
4102
+ }
4067
4103
  return payload;
4068
4104
  }
4105
+ /**
4106
+ * Devuelve un lote fallido al buffer, delante de lo que haya llegado mientras se enviaba
4107
+ * (orden cronológico). Con el buffer lleno se descartan los eventos más antiguos.
4108
+ */
4109
+ requeue(batch) {
4110
+ this.events = [...batch, ...this.events];
4111
+ this.enforceBufferCap();
4112
+ }
4113
+ /** Techo del buffer: si el envío lleva rato fallando, se sacrifica lo más antiguo. */
4114
+ enforceBufferCap() {
4115
+ const overflow = this.events.length - this.maxBufferedEvents;
4116
+ if (overflow > 0) this.events.splice(0, overflow);
4117
+ }
4069
4118
  };
4070
4119
 
4071
4120
  // src/analytics/feedback-payload.ts
@@ -4073,7 +4122,7 @@ function pushKV(list, key, value) {
4073
4122
  if (value === void 0 || value === null || value === "") return;
4074
4123
  list.push({ key, value: [String(value)] });
4075
4124
  }
4076
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
4125
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
4077
4126
  const { context } = envelope;
4078
4127
  const profile = [];
4079
4128
  pushKV(profile, "external-user-id", envelope.userId);
@@ -4124,37 +4173,91 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
4124
4173
  },
4125
4174
  publicKey: keys.publicKey,
4126
4175
  integration: keys.integration,
4127
- completed: false,
4176
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
4177
+ completed: options?.sessionEnd === true,
4128
4178
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
4129
4179
  };
4130
4180
  }
4181
+ var KEEPALIVE_MAX_BYTES = 6e4;
4182
+ function isRetryableStatus(status) {
4183
+ return status >= 500 || status === 408 || status === 429;
4184
+ }
4185
+ async function safeBodyText(res) {
4186
+ try {
4187
+ return (await res.text()).slice(0, 500);
4188
+ } catch {
4189
+ return "";
4190
+ }
4191
+ }
4131
4192
  function createFeedbackSink(options) {
4132
4193
  let feedbackSessionId;
4133
- return (envelope) => {
4134
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
4194
+ let firstPostInFlight = null;
4195
+ const url = `${options.baseUrl}/sdk/feedback`;
4196
+ const post = async (body, final, sessionEnd = false) => {
4197
+ const json = JSON.stringify(body);
4198
+ const small = json.length <= KEEPALIVE_MAX_BYTES;
4199
+ if (final && options.sendBeaconImpl && small) {
4200
+ const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
4201
+ if (options.sendBeaconImpl(url, payload)) {
4202
+ options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
4203
+ return;
4204
+ }
4205
+ options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
4206
+ }
4135
4207
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
4136
4208
  if (!f) {
4137
4209
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
4138
4210
  return;
4139
4211
  }
4140
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
4141
- f(`${options.baseUrl}/sdk/feedback`, {
4212
+ const res = await f(url, {
4142
4213
  method: "POST",
4143
4214
  headers: { "Content-Type": "application/json" },
4144
- body: JSON.stringify(body)
4145
- }).then(async (res) => {
4146
- if (res.ok) {
4147
- try {
4148
- const data = await res.json();
4149
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
4150
- feedbackSessionId = data.sessionId;
4151
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
4152
- options.onSessionId?.(feedbackSessionId);
4153
- }
4154
- } catch {
4155
- }
4215
+ body: json,
4216
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
4217
+ ...small ? { keepalive: true } : {}
4218
+ });
4219
+ if (!res.ok) {
4220
+ const detail = await safeBodyText(res);
4221
+ if (isRetryableStatus(res.status)) {
4222
+ throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
4223
+ }
4224
+ options.log?.(
4225
+ `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
4226
+ detail
4227
+ );
4228
+ return;
4229
+ }
4230
+ if (sessionEnd) return;
4231
+ try {
4232
+ const data = await res.json();
4233
+ if (data?.sessionId && data.sessionId !== feedbackSessionId) {
4234
+ feedbackSessionId = data.sessionId;
4235
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
4236
+ options.onSessionId?.(feedbackSessionId);
4156
4237
  }
4157
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
4238
+ } catch {
4239
+ }
4240
+ };
4241
+ return async (envelope, meta) => {
4242
+ const final = meta?.final === true;
4243
+ const sessionEnd = meta?.sessionEnd === true;
4244
+ if (!feedbackSessionId && firstPostInFlight && !final) {
4245
+ await firstPostInFlight;
4246
+ }
4247
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
4248
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
4249
+ const sent = post(body, final, sessionEnd);
4250
+ if (sessionEnd) {
4251
+ feedbackSessionId = void 0;
4252
+ firstPostInFlight = null;
4253
+ options.onSessionReset?.();
4254
+ } else if (!feedbackSessionId) {
4255
+ firstPostInFlight = sent.then(
4256
+ () => void 0,
4257
+ () => void 0
4258
+ );
4259
+ }
4260
+ await sent;
4158
4261
  };
4159
4262
  }
4160
4263
 
@@ -4587,6 +4690,7 @@ var CrashReporter = class {
4587
4690
  init_logger();
4588
4691
 
4589
4692
  // src/analytics/messaging.ts
4693
+ var MESSAGE_CHANNELS = ["push", "in_app"];
4590
4694
  function buildMessageParams(stage, o) {
4591
4695
  const p = {
4592
4696
  stage,
@@ -4599,6 +4703,57 @@ function buildMessageParams(stage, o) {
4599
4703
  if (o.currency) p.currency = o.currency;
4600
4704
  return { ...p, ...o.params ?? {} };
4601
4705
  }
4706
+ var MAX_TRACKED_MESSAGES = 500;
4707
+ var MessageGuard = class {
4708
+ constructor(maxTrackedMessages = MAX_TRACKED_MESSAGES) {
4709
+ this.maxTrackedMessages = maxTrackedMessages;
4710
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
4711
+ this.seen = /* @__PURE__ */ new Map();
4712
+ }
4713
+ evaluate(stage, options) {
4714
+ const channel = options.channel;
4715
+ if (!MESSAGE_CHANNELS.includes(channel)) {
4716
+ return {
4717
+ emit: false,
4718
+ reason: "invalid_channel",
4719
+ detail: `channel "${channel}" no v\xE1lido (esperado ${MESSAGE_CHANNELS.join(" | ")})`
4720
+ };
4721
+ }
4722
+ const entry = this.seen.get(options.id);
4723
+ if (!entry) {
4724
+ this.remember(options.id, channel, stage);
4725
+ return { emit: true };
4726
+ }
4727
+ if (entry.channel !== channel) {
4728
+ return {
4729
+ emit: false,
4730
+ reason: "channel_conflict",
4731
+ detail: `message_id "${options.id}" ya se report\xF3 en channel "${entry.channel}"; se descarta "${channel}"`
4732
+ };
4733
+ }
4734
+ if (entry.stages.has(stage)) {
4735
+ return {
4736
+ emit: false,
4737
+ reason: "duplicate_stage",
4738
+ detail: `stage "${stage}" ya emitido para message_id "${options.id}"`
4739
+ };
4740
+ }
4741
+ entry.stages.add(stage);
4742
+ return { emit: true };
4743
+ }
4744
+ /** Olvida todo lo vigilado (nueva sesión). */
4745
+ reset() {
4746
+ this.seen.clear();
4747
+ }
4748
+ remember(id, channel, stage) {
4749
+ this.seen.set(id, { channel, stages: /* @__PURE__ */ new Set([stage]) });
4750
+ while (this.seen.size > this.maxTrackedMessages) {
4751
+ const oldest = this.seen.keys().next();
4752
+ if (oldest.done) break;
4753
+ this.seen.delete(oldest.value);
4754
+ }
4755
+ }
4756
+ };
4602
4757
 
4603
4758
  // src/core/deepdots-popups.ts
4604
4759
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -4640,20 +4795,40 @@ var DeepdotsPopups = class {
4640
4795
  this.analyticsFlushTimer = void 0;
4641
4796
  /** Crash & error reporting (#14–17). Null hasta init(). */
4642
4797
  this.crashReporter = null;
4798
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
4799
+ this.messageGuard = new MessageGuard();
4643
4800
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
4644
4801
  this.navObserver = null;
4645
4802
  /** Tiempo activo (engagement time, #8). */
4646
4803
  this.engagement = null;
4647
4804
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
4648
4805
  this.contact = null;
4806
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
4807
+ this.storage = null;
4649
4808
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
4650
4809
  this.navStarted = false;
4810
+ /**
4811
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
4812
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
4813
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
4814
+ */
4815
+ this.sessionOpen = false;
4816
+ /**
4817
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
4818
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
4819
+ */
4820
+ this.language = void 0;
4651
4821
  }
4652
4822
  /** Initialize the SDK with configuration */
4653
4823
  init(config) {
4654
4824
  this.logger = config.logger ?? console;
4655
4825
  setLogger(this.logger);
4656
4826
  if (this.initialized) {
4827
+ const nextUserId = config.userId || void 0;
4828
+ if (nextUserId !== this.config?.userId) {
4829
+ this.setUserId(nextUserId);
4830
+ return;
4831
+ }
4657
4832
  this.log("SDK already initialized");
4658
4833
  return;
4659
4834
  }
@@ -4666,6 +4841,7 @@ var DeepdotsPopups = class {
4666
4841
  userId: config.userId || void 0
4667
4842
  };
4668
4843
  const storage = config.storage ?? createDefaultStorage();
4844
+ this.storage = storage;
4669
4845
  this.tracking = new TrackingManager({
4670
4846
  storage,
4671
4847
  clientUserId: this.config.userId,
@@ -4689,19 +4865,26 @@ var DeepdotsPopups = class {
4689
4865
  baseUrl: this.baseUrl,
4690
4866
  keys: config.analytics,
4691
4867
  log: (...a) => this.log(...a),
4868
+ // sendBeacon: único transporte que sobrevive al cierre de la página.
4869
+ sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
4692
4870
  onSessionId: (id) => {
4693
4871
  this.analyticsFeedbackSessionId = id;
4872
+ },
4873
+ onSessionReset: () => {
4874
+ this.analyticsFeedbackSessionId = void 0;
4694
4875
  }
4695
4876
  }) : createDryRunSink((...a) => this.logger.log(...a));
4696
4877
  const device = config.device ?? collectDeviceInfo(config.appVersion);
4878
+ this.language = resolveLanguage({
4879
+ explicit: config.language,
4880
+ navigator: typeof navigator !== "undefined" ? navigator : void 0,
4881
+ intl: typeof Intl !== "undefined" ? Intl : void 0
4882
+ });
4883
+ this.messageGuard.reset();
4697
4884
  this.analytics = new AnalyticsManager({
4698
4885
  sink: analyticsSink,
4699
4886
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
4700
- language: resolveLanguage({
4701
- explicit: config.language,
4702
- navigator: typeof navigator !== "undefined" ? navigator : void 0,
4703
- intl: typeof Intl !== "undefined" ? Intl : void 0
4704
- }),
4887
+ language: this.language,
4705
4888
  platform: config.platform ?? "web",
4706
4889
  device,
4707
4890
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -4733,7 +4916,7 @@ var DeepdotsPopups = class {
4733
4916
  this.engagement = new EngagementTracker();
4734
4917
  this.engagement.resume();
4735
4918
  this.setupAnalyticsFlush();
4736
- this.track("deepdots_session_start", {});
4919
+ this.openSession();
4737
4920
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
4738
4921
  if (this.tracking?.isTrackingEnabled()) {
4739
4922
  for (const rec of pendingCrashes) {
@@ -4765,7 +4948,9 @@ var DeepdotsPopups = class {
4765
4948
  }
4766
4949
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
4767
4950
  setTrackingEnabled(enabled) {
4951
+ if (!enabled) this.closeSession("tracking_disabled");
4768
4952
  this.tracking?.setTrackingEnabled(enabled);
4953
+ if (enabled) this.openSession();
4769
4954
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
4770
4955
  }
4771
4956
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -4835,8 +5020,18 @@ var DeepdotsPopups = class {
4835
5020
  trackFunnelStep(funnel, step, taskId, params) {
4836
5021
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
4837
5022
  }
4838
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
5023
+ /**
5024
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
5025
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
5026
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
5027
+ * en otro canal — ver `MessageGuard`.
5028
+ */
4839
5029
  trackMessage(stage, options) {
5030
+ const verdict = this.messageGuard.evaluate(stage, options);
5031
+ if (!verdict.emit) {
5032
+ console.warn(`[DeepdotsPopups] trackMessage descartado (${verdict.reason}): ${verdict.detail}`);
5033
+ return;
5034
+ }
4840
5035
  this.track("deepdots_message", buildMessageParams(stage, options));
4841
5036
  }
4842
5037
  /**
@@ -4852,21 +5047,86 @@ var DeepdotsPopups = class {
4852
5047
  this.navObserver.visit(name);
4853
5048
  }
4854
5049
  }
4855
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
5050
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
4856
5051
  onForeground() {
4857
5052
  this.engagement?.resume();
5053
+ this.openSession();
4858
5054
  }
4859
5055
  /**
4860
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
4861
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
5056
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
5057
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
5058
+ * envía el último lote con `completed:true`.
5059
+ *
5060
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
4862
5061
  */
4863
5062
  onBackground() {
5063
+ this.closeSession("background");
5064
+ this.engagement?.pause();
5065
+ }
5066
+ /**
5067
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
5068
+ * `session_end` y envía el último lote con `completed:true`.
5069
+ */
5070
+ endSession() {
5071
+ this.closeSession("manual");
5072
+ }
5073
+ /**
5074
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
5075
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
5076
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
5077
+ *
5078
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
5079
+ */
5080
+ setUserId(userId) {
5081
+ const next = userId || void 0;
5082
+ if (!this.initialized || !this.config || next === this.config.userId) return;
5083
+ this.closeSession("user_change");
5084
+ const enabled = this.tracking?.isTrackingEnabled() ?? true;
5085
+ const storage = this.storage ?? createDefaultStorage();
5086
+ this.config.userId = next;
5087
+ this.tracking = new TrackingManager({ storage, clientUserId: next, enabled });
5088
+ this.contact = next ? new ContactManager({
5089
+ storage,
5090
+ publicKey: this.config.apiKey ?? "",
5091
+ userId: next,
5092
+ post: (body) => this.postContact(body)
5093
+ }) : null;
5094
+ this.analytics?.resetUserScope();
5095
+ this.messageGuard.reset();
5096
+ this.log("tracking \xB7 user_change \xB7 user_id:", this.tracking.getUserId());
5097
+ this.openSession();
5098
+ }
5099
+ /**
5100
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
5101
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
5102
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
5103
+ *
5104
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
5105
+ * el `session_end`.
5106
+ */
5107
+ closeSession(reason) {
5108
+ if (!this.sessionOpen) return;
5109
+ if (!this.tracking?.isTrackingEnabled()) return;
5110
+ this.sessionOpen = false;
4864
5111
  this.navObserver?.stop();
4865
5112
  this.navStarted = false;
4866
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
5113
+ this.analytics?.exitAllMiniServices();
4867
5114
  this.flushEngagement();
4868
- this.engagement?.pause();
4869
- this.flushAnalytics();
5115
+ this.track("deepdots_session_end", { reason });
5116
+ this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
5117
+ this.analyticsFeedbackSessionId = void 0;
5118
+ this.tracking?.setSessionId(null);
5119
+ this.log("tracking \xB7 session_end:", reason);
5120
+ }
5121
+ /**
5122
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
5123
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
5124
+ */
5125
+ openSession() {
5126
+ if (this.sessionOpen) return;
5127
+ if (!this.tracking?.isTrackingEnabled()) return;
5128
+ this.sessionOpen = true;
5129
+ this.track("deepdots_session_start", {});
4870
5130
  }
4871
5131
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
4872
5132
  previewAnalytics() {
@@ -4877,10 +5137,14 @@ var DeepdotsPopups = class {
4877
5137
  events: []
4878
5138
  };
4879
5139
  }
4880
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
4881
- flushAnalytics() {
5140
+ /**
5141
+ * Envía el lote acumulado de analytics y vacía el buffer.
5142
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
5143
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
5144
+ */
5145
+ flushAnalytics(options) {
4882
5146
  if (!this.tracking?.isTrackingEnabled()) return;
4883
- this.analytics?.flush(this.analyticsIdentity());
5147
+ this.analytics?.flush(this.analyticsIdentity(), options);
4884
5148
  }
4885
5149
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
4886
5150
  flushEngagement() {
@@ -4900,10 +5164,7 @@ var DeepdotsPopups = class {
4900
5164
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
4901
5165
  window.addEventListener("pagehide", () => {
4902
5166
  clearInterval(this.analyticsFlushTimer);
4903
- this.navObserver?.stop();
4904
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
4905
- this.flushEngagement();
4906
- this.flushAnalytics();
5167
+ this.closeSession("page_hide");
4907
5168
  });
4908
5169
  document.addEventListener("visibilitychange", () => {
4909
5170
  if (document.visibilityState === "hidden") {
@@ -5024,14 +5285,16 @@ var DeepdotsPopups = class {
5024
5285
  matchesSegmentsLang(def) {
5025
5286
  const langs = def.segments?.lang;
5026
5287
  if (!langs || langs.length === 0) return true;
5027
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
5028
- if (!browserLang) {
5029
- this.debug("No navigator.language available for lang comparison", { popupId: def.id, langs });
5288
+ const currentLang = (this.language ?? "").toLowerCase();
5289
+ if (!currentLang) {
5290
+ this.debug("No language available for lang comparison", { popupId: def.id, langs });
5030
5291
  return true;
5031
5292
  }
5032
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
5293
+ const matches = langs.some(
5294
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
5295
+ );
5033
5296
  if (!matches) {
5034
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
5297
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
5035
5298
  }
5036
5299
  return matches;
5037
5300
  }
@@ -5579,7 +5842,8 @@ function setupReactNative(sdk, config, deps = {}) {
5579
5842
  if (deps.appState?.addEventListener) {
5580
5843
  subscription = deps.appState.addEventListener("change", (state) => {
5581
5844
  if (state === "active") sdk.onForeground();
5582
- else sdk.onBackground();
5845
+ else if (state === "background") sdk.onBackground();
5846
+ else sdk.flushAnalytics();
5583
5847
  });
5584
5848
  }
5585
5849
  return () => {