@magicfeedback/popup-sdk 1.1.8 → 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.
@@ -86,8 +86,11 @@ interface AnalyticsEnvelope {
86
86
  * Mapeo del envelope de analytics → body de `POST /sdk/feedback`.
87
87
  *
88
88
  * La analítica se envía como un Feedback del modelo del Surveys SDK, agrupado por una
89
- * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false` y
90
- * `finished:false` (nunca se "cierra"); el backend cose por `sessionId` + `user_id`.
89
+ * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false`; el
90
+ * backend cose por `sessionId` + `user_id`. El ÚLTIMO lote de una sesión (cierre de página,
91
+ * app a background, cambio de usuario) va con `completed:true`: cierra el registro y el
92
+ * siguiente lote omite el `sessionId` viejo para que el backend abra uno nuevo.
93
+ * `feedback.finished` se queda siempre en `false` (no es la señal de cierre acordada).
91
94
  *
92
95
  * Encoding:
93
96
  * - todo va en `feedback.metadata`: contexto (user_id, session_id, platform…) + eventos
@@ -279,13 +282,16 @@ interface ReactNativeErrorUtils {
279
282
  * el funnel se correlaciona por `message_id` y se agrupa por `message_title`.
280
283
  */
281
284
  type MessageStage = 'delivered' | 'clicked' | 'converted';
285
+ /** Canales válidos. Un mensaje tiene UN canal: se valida en runtime porque el host puede no ser TS. */
286
+ declare const MESSAGE_CHANNELS: readonly ["push", "in_app"];
287
+ type MessageChannel = (typeof MESSAGE_CHANNELS)[number];
282
288
  interface TrackMessageOptions {
283
289
  /** Identificador del mensaje/notificación — correlaciona el funnel delivered→clicked→converted. */
284
290
  id: string;
285
291
  /** Título del mensaje — dimensión de agrupación de #18–22. */
286
292
  title: string;
287
293
  /** Canal de entrega. */
288
- channel: 'push' | 'in_app';
294
+ channel: MessageChannel;
289
295
  /** Nombre de la campaña (opcional). */
290
296
  campaign?: string;
291
297
  /** Valor de conversión (opcional, típico en `stage: 'converted'`). */
@@ -327,14 +333,24 @@ declare class DeepdotsPopups {
327
333
  private analyticsFlushTimer;
328
334
  /** Crash & error reporting (#14–17). Null hasta init(). */
329
335
  private crashReporter;
336
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
337
+ private messageGuard;
330
338
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
331
339
  private navObserver;
332
340
  /** Tiempo activo (engagement time, #8). */
333
341
  private engagement;
334
342
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
335
343
  private contact;
344
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
345
+ private storage;
336
346
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
337
347
  private navStarted;
348
+ /**
349
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
350
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
351
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
352
+ */
353
+ private sessionOpen;
338
354
  /**
339
355
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
340
356
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -378,20 +394,55 @@ declare class DeepdotsPopups {
378
394
  trackFindabilityFriction(frictionTopic: string, params?: Record<string, unknown>): void;
379
395
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
380
396
  trackFunnelStep(funnel: string, step: string, taskId: string, params?: Record<string, unknown>): void;
381
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
397
+ /**
398
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
399
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
400
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
401
+ * en otro canal — ver `MessageGuard`.
402
+ */
382
403
  trackMessage(stage: MessageStage, options: TrackMessageOptions): void;
383
404
  /**
384
405
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
385
406
  * El host la llama en cada cambio de pantalla; emite `page_view` al salir de la anterior.
386
407
  */
387
408
  setScreen(name: string): void;
388
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
409
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
389
410
  onForeground(): void;
390
411
  /**
391
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
392
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
412
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
413
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
414
+ * envía el último lote con `completed:true`.
415
+ *
416
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
393
417
  */
394
418
  onBackground(): void;
419
+ /**
420
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
421
+ * `session_end` y envía el último lote con `completed:true`.
422
+ */
423
+ endSession(): void;
424
+ /**
425
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
426
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
427
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
428
+ *
429
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
430
+ */
431
+ setUserId(userId?: string): void;
432
+ /**
433
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
434
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
435
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
436
+ *
437
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
438
+ * el `session_end`.
439
+ */
440
+ private closeSession;
441
+ /**
442
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
443
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
444
+ */
445
+ private openSession;
395
446
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
396
447
  previewAnalytics(): AnalyticsEnvelope;
397
448
  /**
@@ -401,6 +452,7 @@ declare class DeepdotsPopups {
401
452
  */
402
453
  flushAnalytics(options?: {
403
454
  final?: boolean;
455
+ sessionEnd?: boolean;
404
456
  }): void;
405
457
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
406
458
  private flushEngagement;
@@ -86,8 +86,11 @@ interface AnalyticsEnvelope {
86
86
  * Mapeo del envelope de analytics → body de `POST /sdk/feedback`.
87
87
  *
88
88
  * La analítica se envía como un Feedback del modelo del Surveys SDK, agrupado por una
89
- * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false` y
90
- * `finished:false` (nunca se "cierra"); el backend cose por `sessionId` + `user_id`.
89
+ * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false`; el
90
+ * backend cose por `sessionId` + `user_id`. El ÚLTIMO lote de una sesión (cierre de página,
91
+ * app a background, cambio de usuario) va con `completed:true`: cierra el registro y el
92
+ * siguiente lote omite el `sessionId` viejo para que el backend abra uno nuevo.
93
+ * `feedback.finished` se queda siempre en `false` (no es la señal de cierre acordada).
91
94
  *
92
95
  * Encoding:
93
96
  * - todo va en `feedback.metadata`: contexto (user_id, session_id, platform…) + eventos
@@ -279,13 +282,16 @@ interface ReactNativeErrorUtils {
279
282
  * el funnel se correlaciona por `message_id` y se agrupa por `message_title`.
280
283
  */
281
284
  type MessageStage = 'delivered' | 'clicked' | 'converted';
285
+ /** Canales válidos. Un mensaje tiene UN canal: se valida en runtime porque el host puede no ser TS. */
286
+ declare const MESSAGE_CHANNELS: readonly ["push", "in_app"];
287
+ type MessageChannel = (typeof MESSAGE_CHANNELS)[number];
282
288
  interface TrackMessageOptions {
283
289
  /** Identificador del mensaje/notificación — correlaciona el funnel delivered→clicked→converted. */
284
290
  id: string;
285
291
  /** Título del mensaje — dimensión de agrupación de #18–22. */
286
292
  title: string;
287
293
  /** Canal de entrega. */
288
- channel: 'push' | 'in_app';
294
+ channel: MessageChannel;
289
295
  /** Nombre de la campaña (opcional). */
290
296
  campaign?: string;
291
297
  /** Valor de conversión (opcional, típico en `stage: 'converted'`). */
@@ -327,14 +333,24 @@ declare class DeepdotsPopups {
327
333
  private analyticsFlushTimer;
328
334
  /** Crash & error reporting (#14–17). Null hasta init(). */
329
335
  private crashReporter;
336
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
337
+ private messageGuard;
330
338
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
331
339
  private navObserver;
332
340
  /** Tiempo activo (engagement time, #8). */
333
341
  private engagement;
334
342
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
335
343
  private contact;
344
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
345
+ private storage;
336
346
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
337
347
  private navStarted;
348
+ /**
349
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
350
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
351
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
352
+ */
353
+ private sessionOpen;
338
354
  /**
339
355
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
340
356
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -378,20 +394,55 @@ declare class DeepdotsPopups {
378
394
  trackFindabilityFriction(frictionTopic: string, params?: Record<string, unknown>): void;
379
395
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
380
396
  trackFunnelStep(funnel: string, step: string, taskId: string, params?: Record<string, unknown>): void;
381
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
397
+ /**
398
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
399
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
400
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
401
+ * en otro canal — ver `MessageGuard`.
402
+ */
382
403
  trackMessage(stage: MessageStage, options: TrackMessageOptions): void;
383
404
  /**
384
405
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
385
406
  * El host la llama en cada cambio de pantalla; emite `page_view` al salir de la anterior.
386
407
  */
387
408
  setScreen(name: string): void;
388
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
409
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
389
410
  onForeground(): void;
390
411
  /**
391
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
392
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
412
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
413
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
414
+ * envía el último lote con `completed:true`.
415
+ *
416
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
393
417
  */
394
418
  onBackground(): void;
419
+ /**
420
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
421
+ * `session_end` y envía el último lote con `completed:true`.
422
+ */
423
+ endSession(): void;
424
+ /**
425
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
426
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
427
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
428
+ *
429
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
430
+ */
431
+ setUserId(userId?: string): void;
432
+ /**
433
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
434
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
435
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
436
+ *
437
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
438
+ * el `session_end`.
439
+ */
440
+ private closeSession;
441
+ /**
442
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
443
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
444
+ */
445
+ private openSession;
395
446
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
396
447
  previewAnalytics(): AnalyticsEnvelope;
397
448
  /**
@@ -401,6 +452,7 @@ declare class DeepdotsPopups {
401
452
  */
402
453
  flushAnalytics(options?: {
403
454
  final?: boolean;
455
+ sessionEnd?: boolean;
404
456
  }): void;
405
457
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
406
458
  private flushEngagement;
@@ -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);
@@ -2079,6 +2085,15 @@ var AnalyticsManager = class {
2079
2085
  this.exitMiniService(name);
2080
2086
  }
2081
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
+ }
2082
2097
  /** Registra un evento de analítica (modelo GA: nombre + params). */
2083
2098
  track(name, params) {
2084
2099
  const current = this.getMiniService();
@@ -2166,7 +2181,7 @@ function pushKV(list, key, value) {
2166
2181
  if (value === void 0 || value === null || value === "") return;
2167
2182
  list.push({ key, value: [String(value)] });
2168
2183
  }
2169
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
2184
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
2170
2185
  const { context } = envelope;
2171
2186
  const profile = [];
2172
2187
  pushKV(profile, "external-user-id", envelope.userId);
@@ -2217,7 +2232,8 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
2217
2232
  },
2218
2233
  publicKey: keys.publicKey,
2219
2234
  integration: keys.integration,
2220
- completed: false,
2235
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
2236
+ completed: options?.sessionEnd === true,
2221
2237
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
2222
2238
  };
2223
2239
  }
@@ -2236,7 +2252,7 @@ function createFeedbackSink(options) {
2236
2252
  let feedbackSessionId;
2237
2253
  let firstPostInFlight = null;
2238
2254
  const url = `${options.baseUrl}/sdk/feedback`;
2239
- const post = async (body, final) => {
2255
+ const post = async (body, final, sessionEnd = false) => {
2240
2256
  const json = JSON.stringify(body);
2241
2257
  const small = json.length <= KEEPALIVE_MAX_BYTES;
2242
2258
  if (final && options.sendBeaconImpl && small) {
@@ -2270,6 +2286,7 @@ function createFeedbackSink(options) {
2270
2286
  );
2271
2287
  return;
2272
2288
  }
2289
+ if (sessionEnd) return;
2273
2290
  try {
2274
2291
  const data = await res.json();
2275
2292
  if (data?.sessionId && data.sessionId !== feedbackSessionId) {
@@ -2282,13 +2299,18 @@ function createFeedbackSink(options) {
2282
2299
  };
2283
2300
  return async (envelope, meta) => {
2284
2301
  const final = meta?.final === true;
2302
+ const sessionEnd = meta?.sessionEnd === true;
2285
2303
  if (!feedbackSessionId && firstPostInFlight && !final) {
2286
2304
  await firstPostInFlight;
2287
2305
  }
2288
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
2306
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
2289
2307
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
2290
- const sent = post(body, final);
2291
- if (!feedbackSessionId) {
2308
+ const sent = post(body, final, sessionEnd);
2309
+ if (sessionEnd) {
2310
+ feedbackSessionId = void 0;
2311
+ firstPostInFlight = null;
2312
+ options.onSessionReset?.();
2313
+ } else if (!feedbackSessionId) {
2292
2314
  firstPostInFlight = sent.then(
2293
2315
  () => void 0,
2294
2316
  () => void 0
@@ -2727,6 +2749,7 @@ var CrashReporter = class {
2727
2749
  init_logger();
2728
2750
 
2729
2751
  // src/analytics/messaging.ts
2752
+ var MESSAGE_CHANNELS = ["push", "in_app"];
2730
2753
  function buildMessageParams(stage, o) {
2731
2754
  const p = {
2732
2755
  stage,
@@ -2739,6 +2762,57 @@ function buildMessageParams(stage, o) {
2739
2762
  if (o.currency) p.currency = o.currency;
2740
2763
  return { ...p, ...o.params ?? {} };
2741
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
+ };
2742
2816
 
2743
2817
  // src/core/deepdots-popups.ts
2744
2818
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -2780,14 +2854,24 @@ var DeepdotsPopups = class {
2780
2854
  this.analyticsFlushTimer = void 0;
2781
2855
  /** Crash & error reporting (#14–17). Null hasta init(). */
2782
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();
2783
2859
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
2784
2860
  this.navObserver = null;
2785
2861
  /** Tiempo activo (engagement time, #8). */
2786
2862
  this.engagement = null;
2787
2863
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
2788
2864
  this.contact = null;
2865
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
2866
+ this.storage = null;
2789
2867
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
2790
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;
2791
2875
  /**
2792
2876
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
2793
2877
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -2799,6 +2883,11 @@ var DeepdotsPopups = class {
2799
2883
  this.logger = config.logger ?? console;
2800
2884
  setLogger(this.logger);
2801
2885
  if (this.initialized) {
2886
+ const nextUserId = config.userId || void 0;
2887
+ if (nextUserId !== this.config?.userId) {
2888
+ this.setUserId(nextUserId);
2889
+ return;
2890
+ }
2802
2891
  this.log("SDK already initialized");
2803
2892
  return;
2804
2893
  }
@@ -2811,6 +2900,7 @@ var DeepdotsPopups = class {
2811
2900
  userId: config.userId || void 0
2812
2901
  };
2813
2902
  const storage = config.storage ?? createDefaultStorage();
2903
+ this.storage = storage;
2814
2904
  this.tracking = new TrackingManager({
2815
2905
  storage,
2816
2906
  clientUserId: this.config.userId,
@@ -2838,6 +2928,9 @@ var DeepdotsPopups = class {
2838
2928
  sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
2839
2929
  onSessionId: (id) => {
2840
2930
  this.analyticsFeedbackSessionId = id;
2931
+ },
2932
+ onSessionReset: () => {
2933
+ this.analyticsFeedbackSessionId = void 0;
2841
2934
  }
2842
2935
  }) : createDryRunSink((...a) => this.logger.log(...a));
2843
2936
  const device = config.device ?? collectDeviceInfo(config.appVersion);
@@ -2846,6 +2939,7 @@ var DeepdotsPopups = class {
2846
2939
  navigator: typeof navigator !== "undefined" ? navigator : void 0,
2847
2940
  intl: typeof Intl !== "undefined" ? Intl : void 0
2848
2941
  });
2942
+ this.messageGuard.reset();
2849
2943
  this.analytics = new AnalyticsManager({
2850
2944
  sink: analyticsSink,
2851
2945
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
@@ -2881,7 +2975,7 @@ var DeepdotsPopups = class {
2881
2975
  this.engagement = new EngagementTracker();
2882
2976
  this.engagement.resume();
2883
2977
  this.setupAnalyticsFlush();
2884
- this.track("deepdots_session_start", {});
2978
+ this.openSession();
2885
2979
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
2886
2980
  if (this.tracking?.isTrackingEnabled()) {
2887
2981
  for (const rec of pendingCrashes) {
@@ -2913,7 +3007,9 @@ var DeepdotsPopups = class {
2913
3007
  }
2914
3008
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
2915
3009
  setTrackingEnabled(enabled) {
3010
+ if (!enabled) this.closeSession("tracking_disabled");
2916
3011
  this.tracking?.setTrackingEnabled(enabled);
3012
+ if (enabled) this.openSession();
2917
3013
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
2918
3014
  }
2919
3015
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -2983,8 +3079,18 @@ var DeepdotsPopups = class {
2983
3079
  trackFunnelStep(funnel, step, taskId, params) {
2984
3080
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
2985
3081
  }
2986
- /** 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
+ */
2987
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
+ }
2988
3094
  this.track("deepdots_message", buildMessageParams(stage, options));
2989
3095
  }
2990
3096
  /**
@@ -3000,21 +3106,86 @@ var DeepdotsPopups = class {
3000
3106
  this.navObserver.visit(name);
3001
3107
  }
3002
3108
  }
3003
- /** 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. */
3004
3110
  onForeground() {
3005
3111
  this.engagement?.resume();
3112
+ this.openSession();
3006
3113
  }
3007
3114
  /**
3008
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
3009
- * 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.
3010
3120
  */
3011
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;
3012
3170
  this.navObserver?.stop();
3013
3171
  this.navStarted = false;
3014
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
3172
+ this.analytics?.exitAllMiniServices();
3015
3173
  this.flushEngagement();
3016
- this.engagement?.pause();
3017
- 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", {});
3018
3189
  }
3019
3190
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
3020
3191
  previewAnalytics() {
@@ -3052,10 +3223,7 @@ var DeepdotsPopups = class {
3052
3223
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
3053
3224
  window.addEventListener("pagehide", () => {
3054
3225
  clearInterval(this.analyticsFlushTimer);
3055
- this.navObserver?.stop();
3056
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
3057
- this.flushEngagement();
3058
- this.flushAnalytics({ final: true });
3226
+ this.closeSession("page_hide");
3059
3227
  });
3060
3228
  document.addEventListener("visibilitychange", () => {
3061
3229
  if (document.visibilityState === "hidden") {
@@ -3730,7 +3898,8 @@ function setupReactNative(sdk2, config, deps = {}) {
3730
3898
  if (deps.appState?.addEventListener) {
3731
3899
  subscription = deps.appState.addEventListener("change", (state) => {
3732
3900
  if (state === "active") sdk2.onForeground();
3733
- else sdk2.onBackground();
3901
+ else if (state === "background") sdk2.onBackground();
3902
+ else sdk2.flushAnalytics();
3734
3903
  });
3735
3904
  }
3736
3905
  return () => {