@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.
@@ -1149,6 +1149,11 @@ async function renderPopup(container, surveyId, productId, actions, emit, onClos
1149
1149
  const isDark = style?.theme === "dark";
1150
1150
  const theme = {
1151
1151
  popupBg: isDark ? "#1e1e1e" : "#fff",
1152
+ // El popup fija su propio fondo, así que debe fijar también el esquema de color.
1153
+ // Sin esto los controles de formulario (input/textarea/select) caen al estilo por
1154
+ // defecto del navegador: en un host con `prefers-color-scheme: dark` se pintan
1155
+ // oscuros con texto blanco sobre el fondo claro del popup.
1156
+ colorScheme: isDark ? "dark" : "light",
1152
1157
  textPrimary: isDark ? "#f0f0f0" : "#111",
1153
1158
  closeBtnHoverBg: isDark ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.06)",
1154
1159
  closeBtnHoverColor: isDark ? "#fff" : "#000000"
@@ -1171,6 +1176,7 @@ async function renderPopup(container, surveyId, productId, actions, emit, onClos
1171
1176
  flex-direction: column;
1172
1177
  justify-content: flex-start;
1173
1178
  background: ${theme.popupBg};
1179
+ color-scheme: ${theme.colorScheme};
1174
1180
  border-radius: 8px;
1175
1181
  padding: 24px;
1176
1182
  box-shadow: 0 4px 6px rgba(0,0,0,0.1);
@@ -2030,6 +2036,7 @@ var AnalyticsManager = class {
2030
2036
  this.device = options.device;
2031
2037
  this.platform = options.platform ?? "web";
2032
2038
  this.maxBatchSize = options.maxBatchSize ?? 20;
2039
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
2033
2040
  this.onFlushNeeded = options.onFlushNeeded;
2034
2041
  }
2035
2042
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -2078,6 +2085,15 @@ var AnalyticsManager = class {
2078
2085
  this.exitMiniService(name);
2079
2086
  }
2080
2087
  }
2088
+ /**
2089
+ * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
2090
+ * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
2091
+ * vieja, y lo que llegue después es del usuario nuevo.
2092
+ */
2093
+ resetUserScope() {
2094
+ this.attributes = {};
2095
+ this.metrics = {};
2096
+ }
2081
2097
  /** Registra un evento de analítica (modelo GA: nombre + params). */
2082
2098
  track(name, params) {
2083
2099
  const current = this.getMiniService();
@@ -2090,6 +2106,7 @@ var AnalyticsManager = class {
2090
2106
  timestamp: this.now(),
2091
2107
  params: Object.keys(merged).length ? merged : void 0
2092
2108
  });
2109
+ this.enforceBufferCap();
2093
2110
  if (this.events.length >= this.maxBatchSize) {
2094
2111
  this.onFlushNeeded?.();
2095
2112
  }
@@ -2120,14 +2137,43 @@ var AnalyticsManager = class {
2120
2137
  events: [...this.events]
2121
2138
  };
2122
2139
  }
2123
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
2124
- flush(identity) {
2140
+ /**
2141
+ * Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos.
2142
+ *
2143
+ * Si el sink falla de forma transitoria (promesa rechazada o excepción síncrona) el lote
2144
+ * se **re-encola** al principio del buffer y se reintenta en el siguiente flush: un error
2145
+ * de red o un 5xx no debe hacer desaparecer los eventos.
2146
+ */
2147
+ flush(identity, meta) {
2125
2148
  if (this.events.length === 0) return null;
2149
+ const batch = this.events;
2126
2150
  const payload = this.buildPayload(identity);
2127
- this.sink(payload);
2128
2151
  this.events = [];
2152
+ let result;
2153
+ try {
2154
+ result = meta ? this.sink(payload, meta) : this.sink(payload);
2155
+ } catch {
2156
+ this.requeue(batch);
2157
+ return payload;
2158
+ }
2159
+ if (result && typeof result.then === "function") {
2160
+ result.catch(() => this.requeue(batch));
2161
+ }
2129
2162
  return payload;
2130
2163
  }
2164
+ /**
2165
+ * Devuelve un lote fallido al buffer, delante de lo que haya llegado mientras se enviaba
2166
+ * (orden cronológico). Con el buffer lleno se descartan los eventos más antiguos.
2167
+ */
2168
+ requeue(batch) {
2169
+ this.events = [...batch, ...this.events];
2170
+ this.enforceBufferCap();
2171
+ }
2172
+ /** Techo del buffer: si el envío lleva rato fallando, se sacrifica lo más antiguo. */
2173
+ enforceBufferCap() {
2174
+ const overflow = this.events.length - this.maxBufferedEvents;
2175
+ if (overflow > 0) this.events.splice(0, overflow);
2176
+ }
2131
2177
  };
2132
2178
 
2133
2179
  // src/analytics/feedback-payload.ts
@@ -2135,7 +2181,7 @@ function pushKV(list, key, value) {
2135
2181
  if (value === void 0 || value === null || value === "") return;
2136
2182
  list.push({ key, value: [String(value)] });
2137
2183
  }
2138
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
2184
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
2139
2185
  const { context } = envelope;
2140
2186
  const profile = [];
2141
2187
  pushKV(profile, "external-user-id", envelope.userId);
@@ -2186,37 +2232,91 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
2186
2232
  },
2187
2233
  publicKey: keys.publicKey,
2188
2234
  integration: keys.integration,
2189
- completed: false,
2235
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
2236
+ completed: options?.sessionEnd === true,
2190
2237
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
2191
2238
  };
2192
2239
  }
2240
+ var KEEPALIVE_MAX_BYTES = 6e4;
2241
+ function isRetryableStatus(status) {
2242
+ return status >= 500 || status === 408 || status === 429;
2243
+ }
2244
+ async function safeBodyText(res) {
2245
+ try {
2246
+ return (await res.text()).slice(0, 500);
2247
+ } catch {
2248
+ return "";
2249
+ }
2250
+ }
2193
2251
  function createFeedbackSink(options) {
2194
2252
  let feedbackSessionId;
2195
- return (envelope) => {
2196
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
2253
+ let firstPostInFlight = null;
2254
+ const url = `${options.baseUrl}/sdk/feedback`;
2255
+ const post = async (body, final, sessionEnd = false) => {
2256
+ const json = JSON.stringify(body);
2257
+ const small = json.length <= KEEPALIVE_MAX_BYTES;
2258
+ if (final && options.sendBeaconImpl && small) {
2259
+ const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
2260
+ if (options.sendBeaconImpl(url, payload)) {
2261
+ options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
2262
+ return;
2263
+ }
2264
+ options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
2265
+ }
2197
2266
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
2198
2267
  if (!f) {
2199
2268
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
2200
2269
  return;
2201
2270
  }
2202
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
2203
- f(`${options.baseUrl}/sdk/feedback`, {
2271
+ const res = await f(url, {
2204
2272
  method: "POST",
2205
2273
  headers: { "Content-Type": "application/json" },
2206
- body: JSON.stringify(body)
2207
- }).then(async (res) => {
2208
- if (res.ok) {
2209
- try {
2210
- const data = await res.json();
2211
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
2212
- feedbackSessionId = data.sessionId;
2213
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
2214
- options.onSessionId?.(feedbackSessionId);
2215
- }
2216
- } catch {
2217
- }
2274
+ body: json,
2275
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
2276
+ ...small ? { keepalive: true } : {}
2277
+ });
2278
+ if (!res.ok) {
2279
+ const detail = await safeBodyText(res);
2280
+ if (isRetryableStatus(res.status)) {
2281
+ throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
2282
+ }
2283
+ options.log?.(
2284
+ `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
2285
+ detail
2286
+ );
2287
+ return;
2288
+ }
2289
+ if (sessionEnd) return;
2290
+ try {
2291
+ const data = await res.json();
2292
+ if (data?.sessionId && data.sessionId !== feedbackSessionId) {
2293
+ feedbackSessionId = data.sessionId;
2294
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
2295
+ options.onSessionId?.(feedbackSessionId);
2218
2296
  }
2219
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
2297
+ } catch {
2298
+ }
2299
+ };
2300
+ return async (envelope, meta) => {
2301
+ const final = meta?.final === true;
2302
+ const sessionEnd = meta?.sessionEnd === true;
2303
+ if (!feedbackSessionId && firstPostInFlight && !final) {
2304
+ await firstPostInFlight;
2305
+ }
2306
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
2307
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
2308
+ const sent = post(body, final, sessionEnd);
2309
+ if (sessionEnd) {
2310
+ feedbackSessionId = void 0;
2311
+ firstPostInFlight = null;
2312
+ options.onSessionReset?.();
2313
+ } else if (!feedbackSessionId) {
2314
+ firstPostInFlight = sent.then(
2315
+ () => void 0,
2316
+ () => void 0
2317
+ );
2318
+ }
2319
+ await sent;
2220
2320
  };
2221
2321
  }
2222
2322
 
@@ -2649,6 +2749,7 @@ var CrashReporter = class {
2649
2749
  init_logger();
2650
2750
 
2651
2751
  // src/analytics/messaging.ts
2752
+ var MESSAGE_CHANNELS = ["push", "in_app"];
2652
2753
  function buildMessageParams(stage, o) {
2653
2754
  const p = {
2654
2755
  stage,
@@ -2661,6 +2762,57 @@ function buildMessageParams(stage, o) {
2661
2762
  if (o.currency) p.currency = o.currency;
2662
2763
  return { ...p, ...o.params ?? {} };
2663
2764
  }
2765
+ var MAX_TRACKED_MESSAGES = 500;
2766
+ var MessageGuard = class {
2767
+ constructor(maxTrackedMessages = MAX_TRACKED_MESSAGES) {
2768
+ this.maxTrackedMessages = maxTrackedMessages;
2769
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
2770
+ this.seen = /* @__PURE__ */ new Map();
2771
+ }
2772
+ evaluate(stage, options) {
2773
+ const channel = options.channel;
2774
+ if (!MESSAGE_CHANNELS.includes(channel)) {
2775
+ return {
2776
+ emit: false,
2777
+ reason: "invalid_channel",
2778
+ detail: `channel "${channel}" no v\xE1lido (esperado ${MESSAGE_CHANNELS.join(" | ")})`
2779
+ };
2780
+ }
2781
+ const entry = this.seen.get(options.id);
2782
+ if (!entry) {
2783
+ this.remember(options.id, channel, stage);
2784
+ return { emit: true };
2785
+ }
2786
+ if (entry.channel !== channel) {
2787
+ return {
2788
+ emit: false,
2789
+ reason: "channel_conflict",
2790
+ detail: `message_id "${options.id}" ya se report\xF3 en channel "${entry.channel}"; se descarta "${channel}"`
2791
+ };
2792
+ }
2793
+ if (entry.stages.has(stage)) {
2794
+ return {
2795
+ emit: false,
2796
+ reason: "duplicate_stage",
2797
+ detail: `stage "${stage}" ya emitido para message_id "${options.id}"`
2798
+ };
2799
+ }
2800
+ entry.stages.add(stage);
2801
+ return { emit: true };
2802
+ }
2803
+ /** Olvida todo lo vigilado (nueva sesión). */
2804
+ reset() {
2805
+ this.seen.clear();
2806
+ }
2807
+ remember(id, channel, stage) {
2808
+ this.seen.set(id, { channel, stages: /* @__PURE__ */ new Set([stage]) });
2809
+ while (this.seen.size > this.maxTrackedMessages) {
2810
+ const oldest = this.seen.keys().next();
2811
+ if (oldest.done) break;
2812
+ this.seen.delete(oldest.value);
2813
+ }
2814
+ }
2815
+ };
2664
2816
 
2665
2817
  // src/core/deepdots-popups.ts
2666
2818
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -2702,20 +2854,40 @@ var DeepdotsPopups = class {
2702
2854
  this.analyticsFlushTimer = void 0;
2703
2855
  /** Crash & error reporting (#14–17). Null hasta init(). */
2704
2856
  this.crashReporter = null;
2857
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
2858
+ this.messageGuard = new MessageGuard();
2705
2859
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
2706
2860
  this.navObserver = null;
2707
2861
  /** Tiempo activo (engagement time, #8). */
2708
2862
  this.engagement = null;
2709
2863
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
2710
2864
  this.contact = null;
2865
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
2866
+ this.storage = null;
2711
2867
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
2712
2868
  this.navStarted = false;
2869
+ /**
2870
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
2871
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
2872
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
2873
+ */
2874
+ this.sessionOpen = false;
2875
+ /**
2876
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
2877
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
2878
+ */
2879
+ this.language = void 0;
2713
2880
  }
2714
2881
  /** Initialize the SDK with configuration */
2715
2882
  init(config) {
2716
2883
  this.logger = config.logger ?? console;
2717
2884
  setLogger(this.logger);
2718
2885
  if (this.initialized) {
2886
+ const nextUserId = config.userId || void 0;
2887
+ if (nextUserId !== this.config?.userId) {
2888
+ this.setUserId(nextUserId);
2889
+ return;
2890
+ }
2719
2891
  this.log("SDK already initialized");
2720
2892
  return;
2721
2893
  }
@@ -2728,6 +2900,7 @@ var DeepdotsPopups = class {
2728
2900
  userId: config.userId || void 0
2729
2901
  };
2730
2902
  const storage = config.storage ?? createDefaultStorage();
2903
+ this.storage = storage;
2731
2904
  this.tracking = new TrackingManager({
2732
2905
  storage,
2733
2906
  clientUserId: this.config.userId,
@@ -2751,19 +2924,26 @@ var DeepdotsPopups = class {
2751
2924
  baseUrl: this.baseUrl,
2752
2925
  keys: config.analytics,
2753
2926
  log: (...a) => this.log(...a),
2927
+ // sendBeacon: único transporte que sobrevive al cierre de la página.
2928
+ sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
2754
2929
  onSessionId: (id) => {
2755
2930
  this.analyticsFeedbackSessionId = id;
2931
+ },
2932
+ onSessionReset: () => {
2933
+ this.analyticsFeedbackSessionId = void 0;
2756
2934
  }
2757
2935
  }) : createDryRunSink((...a) => this.logger.log(...a));
2758
2936
  const device = config.device ?? collectDeviceInfo(config.appVersion);
2937
+ this.language = resolveLanguage({
2938
+ explicit: config.language,
2939
+ navigator: typeof navigator !== "undefined" ? navigator : void 0,
2940
+ intl: typeof Intl !== "undefined" ? Intl : void 0
2941
+ });
2942
+ this.messageGuard.reset();
2759
2943
  this.analytics = new AnalyticsManager({
2760
2944
  sink: analyticsSink,
2761
2945
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
2762
- language: resolveLanguage({
2763
- explicit: config.language,
2764
- navigator: typeof navigator !== "undefined" ? navigator : void 0,
2765
- intl: typeof Intl !== "undefined" ? Intl : void 0
2766
- }),
2946
+ language: this.language,
2767
2947
  platform: config.platform ?? "web",
2768
2948
  device,
2769
2949
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -2795,7 +2975,7 @@ var DeepdotsPopups = class {
2795
2975
  this.engagement = new EngagementTracker();
2796
2976
  this.engagement.resume();
2797
2977
  this.setupAnalyticsFlush();
2798
- this.track("deepdots_session_start", {});
2978
+ this.openSession();
2799
2979
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
2800
2980
  if (this.tracking?.isTrackingEnabled()) {
2801
2981
  for (const rec of pendingCrashes) {
@@ -2827,7 +3007,9 @@ var DeepdotsPopups = class {
2827
3007
  }
2828
3008
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
2829
3009
  setTrackingEnabled(enabled) {
3010
+ if (!enabled) this.closeSession("tracking_disabled");
2830
3011
  this.tracking?.setTrackingEnabled(enabled);
3012
+ if (enabled) this.openSession();
2831
3013
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
2832
3014
  }
2833
3015
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -2897,8 +3079,18 @@ var DeepdotsPopups = class {
2897
3079
  trackFunnelStep(funnel, step, taskId, params) {
2898
3080
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
2899
3081
  }
2900
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
3082
+ /**
3083
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
3084
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
3085
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
3086
+ * en otro canal — ver `MessageGuard`.
3087
+ */
2901
3088
  trackMessage(stage, options) {
3089
+ const verdict = this.messageGuard.evaluate(stage, options);
3090
+ if (!verdict.emit) {
3091
+ console.warn(`[DeepdotsPopups] trackMessage descartado (${verdict.reason}): ${verdict.detail}`);
3092
+ return;
3093
+ }
2902
3094
  this.track("deepdots_message", buildMessageParams(stage, options));
2903
3095
  }
2904
3096
  /**
@@ -2914,21 +3106,86 @@ var DeepdotsPopups = class {
2914
3106
  this.navObserver.visit(name);
2915
3107
  }
2916
3108
  }
2917
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
3109
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
2918
3110
  onForeground() {
2919
3111
  this.engagement?.resume();
3112
+ this.openSession();
2920
3113
  }
2921
3114
  /**
2922
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
2923
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
3115
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
3116
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
3117
+ * envía el último lote con `completed:true`.
3118
+ *
3119
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
2924
3120
  */
2925
3121
  onBackground() {
3122
+ this.closeSession("background");
3123
+ this.engagement?.pause();
3124
+ }
3125
+ /**
3126
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
3127
+ * `session_end` y envía el último lote con `completed:true`.
3128
+ */
3129
+ endSession() {
3130
+ this.closeSession("manual");
3131
+ }
3132
+ /**
3133
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
3134
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
3135
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
3136
+ *
3137
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
3138
+ */
3139
+ setUserId(userId) {
3140
+ const next = userId || void 0;
3141
+ if (!this.initialized || !this.config || next === this.config.userId) return;
3142
+ this.closeSession("user_change");
3143
+ const enabled = this.tracking?.isTrackingEnabled() ?? true;
3144
+ const storage = this.storage ?? createDefaultStorage();
3145
+ this.config.userId = next;
3146
+ this.tracking = new TrackingManager({ storage, clientUserId: next, enabled });
3147
+ this.contact = next ? new ContactManager({
3148
+ storage,
3149
+ publicKey: this.config.apiKey ?? "",
3150
+ userId: next,
3151
+ post: (body) => this.postContact(body)
3152
+ }) : null;
3153
+ this.analytics?.resetUserScope();
3154
+ this.messageGuard.reset();
3155
+ this.log("tracking \xB7 user_change \xB7 user_id:", this.tracking.getUserId());
3156
+ this.openSession();
3157
+ }
3158
+ /**
3159
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
3160
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
3161
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
3162
+ *
3163
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
3164
+ * el `session_end`.
3165
+ */
3166
+ closeSession(reason) {
3167
+ if (!this.sessionOpen) return;
3168
+ if (!this.tracking?.isTrackingEnabled()) return;
3169
+ this.sessionOpen = false;
2926
3170
  this.navObserver?.stop();
2927
3171
  this.navStarted = false;
2928
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
3172
+ this.analytics?.exitAllMiniServices();
2929
3173
  this.flushEngagement();
2930
- this.engagement?.pause();
2931
- this.flushAnalytics();
3174
+ this.track("deepdots_session_end", { reason });
3175
+ this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
3176
+ this.analyticsFeedbackSessionId = void 0;
3177
+ this.tracking?.setSessionId(null);
3178
+ this.log("tracking \xB7 session_end:", reason);
3179
+ }
3180
+ /**
3181
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
3182
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
3183
+ */
3184
+ openSession() {
3185
+ if (this.sessionOpen) return;
3186
+ if (!this.tracking?.isTrackingEnabled()) return;
3187
+ this.sessionOpen = true;
3188
+ this.track("deepdots_session_start", {});
2932
3189
  }
2933
3190
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
2934
3191
  previewAnalytics() {
@@ -2939,10 +3196,14 @@ var DeepdotsPopups = class {
2939
3196
  events: []
2940
3197
  };
2941
3198
  }
2942
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
2943
- flushAnalytics() {
3199
+ /**
3200
+ * Envía el lote acumulado de analytics y vacía el buffer.
3201
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
3202
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
3203
+ */
3204
+ flushAnalytics(options) {
2944
3205
  if (!this.tracking?.isTrackingEnabled()) return;
2945
- this.analytics?.flush(this.analyticsIdentity());
3206
+ this.analytics?.flush(this.analyticsIdentity(), options);
2946
3207
  }
2947
3208
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
2948
3209
  flushEngagement() {
@@ -2962,10 +3223,7 @@ var DeepdotsPopups = class {
2962
3223
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
2963
3224
  window.addEventListener("pagehide", () => {
2964
3225
  clearInterval(this.analyticsFlushTimer);
2965
- this.navObserver?.stop();
2966
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
2967
- this.flushEngagement();
2968
- this.flushAnalytics();
3226
+ this.closeSession("page_hide");
2969
3227
  });
2970
3228
  document.addEventListener("visibilitychange", () => {
2971
3229
  if (document.visibilityState === "hidden") {
@@ -3086,14 +3344,16 @@ var DeepdotsPopups = class {
3086
3344
  matchesSegmentsLang(def) {
3087
3345
  const langs = def.segments?.lang;
3088
3346
  if (!langs || langs.length === 0) return true;
3089
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
3090
- if (!browserLang) {
3091
- this.debug("No navigator.language available for lang comparison", { popupId: def.id, langs });
3347
+ const currentLang = (this.language ?? "").toLowerCase();
3348
+ if (!currentLang) {
3349
+ this.debug("No language available for lang comparison", { popupId: def.id, langs });
3092
3350
  return true;
3093
3351
  }
3094
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
3352
+ const matches = langs.some(
3353
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
3354
+ );
3095
3355
  if (!matches) {
3096
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
3356
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
3097
3357
  }
3098
3358
  return matches;
3099
3359
  }
@@ -3638,7 +3898,8 @@ function setupReactNative(sdk2, config, deps = {}) {
3638
3898
  if (deps.appState?.addEventListener) {
3639
3899
  subscription = deps.appState.addEventListener("change", (state) => {
3640
3900
  if (state === "active") sdk2.onForeground();
3641
- else sdk2.onBackground();
3901
+ else if (state === "background") sdk2.onBackground();
3902
+ else sdk2.flushAnalytics();
3642
3903
  });
3643
3904
  }
3644
3905
  return () => {