@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/README.md CHANGED
@@ -177,6 +177,11 @@ Config fields:
177
177
  - `mode?: 'server' | 'client'`
178
178
  - `debug?: boolean`
179
179
  - `popups?: PopupDefinition[]`
180
+ - `language?: string` — BCP-47 tag (`'en'`, `'en-US'`, …) used for popup language
181
+ segmentation (`segments.lang`) and for the analytics context. If omitted it is
182
+ auto-detected from `navigator.language`, falling back to the `Intl` locale (which is
183
+ what makes it work in React Native, where `navigator.language` does not exist). If no
184
+ source resolves a language, popups with `segments.lang` cannot be filtered and are shown.
180
185
 
181
186
  ### `autoLaunch()`
182
187
 
package/dist/index.d.mts CHANGED
@@ -91,14 +91,36 @@ interface AnalyticsEnvelope {
91
91
  };
92
92
  events: AnalyticsEvent[];
93
93
  }
94
- type AnalyticsSink = (payload: AnalyticsEnvelope) => void;
94
+ /** Contexto del flush, para que el sink pueda adaptar el transporte. */
95
+ interface AnalyticsFlushMeta {
96
+ /**
97
+ * `true` cuando el flush ocurre porque la página/app se está cerrando. El sink debe usar
98
+ * un transporte que sobreviva al unload (sendBeacon) y no puede esperar respuestas.
99
+ */
100
+ final?: boolean;
101
+ /**
102
+ * `true` cuando este es el ÚLTIMO lote de la sesión (cierre de página, app a background,
103
+ * cambio de usuario, tracking desactivado). El sink lo envía con `completed:true` y olvida
104
+ * el `sessionId`, para que el lote siguiente abra un registro nuevo.
105
+ */
106
+ sessionEnd?: boolean;
107
+ }
108
+ /**
109
+ * Envío del lote. Si devuelve una promesa que **rechaza**, el manager re-encola los
110
+ * eventos para reintentarlos en el siguiente flush (fallo transitorio: red o 5xx).
111
+ * Resolver = lote entregado o descartado definitivamente (no se reintenta).
112
+ */
113
+ type AnalyticsSink = (payload: AnalyticsEnvelope, meta?: AnalyticsFlushMeta) => void | Promise<void>;
95
114
 
96
115
  /**
97
116
  * Mapeo del envelope de analytics → body de `POST /sdk/feedback`.
98
117
  *
99
118
  * La analítica se envía como un Feedback del modelo del Surveys SDK, agrupado por una
100
- * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false` y
101
- * `finished:false` (nunca se "cierra"); el backend cose por `sessionId` + `user_id`.
119
+ * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false`; el
120
+ * backend cose por `sessionId` + `user_id`. El ÚLTIMO lote de una sesión (cierre de página,
121
+ * app a background, cambio de usuario) va con `completed:true`: cierra el registro y el
122
+ * siguiente lote omite el `sessionId` viejo para que el backend abra uno nuevo.
123
+ * `feedback.finished` se queda siempre en `false` (no es la señal de cierre acordada).
102
124
  *
103
125
  * Encoding:
104
126
  * - todo va en `feedback.metadata`: contexto (user_id, session_id, platform…) + eventos
@@ -134,7 +156,15 @@ interface AnalyticsFeedbackBody {
134
156
  /** sessionId devuelto por el primer POST; permite que el backend agrupe todos los eventos en un solo registro. */
135
157
  sessionId?: string;
136
158
  }
137
- declare function buildAnalyticsFeedbackBody(envelope: AnalyticsEnvelope, keys: AnalyticsKeys, feedbackSessionId?: string): AnalyticsFeedbackBody;
159
+ interface BuildBodyOptions {
160
+ /**
161
+ * `true` en el último lote de la sesión → `completed:true` (cierra el registro en backend).
162
+ * El body SÍ lleva el `sessionId` actual (es el registro que se cierra); es el lote
163
+ * SIGUIENTE el que lo omite.
164
+ */
165
+ sessionEnd?: boolean;
166
+ }
167
+ declare function buildAnalyticsFeedbackBody(envelope: AnalyticsEnvelope, keys: AnalyticsKeys, feedbackSessionId?: string, options?: BuildBodyOptions): AnalyticsFeedbackBody;
138
168
  interface FeedbackSinkOptions {
139
169
  /** Base URL del backend (api(.|-dev.)deepdots.com) ya resuelta por el entorno. */
140
170
  baseUrl: string;
@@ -143,15 +173,31 @@ interface FeedbackSinkOptions {
143
173
  log?: (...args: unknown[]) => void;
144
174
  /** fetch inyectable (tests / RN). */
145
175
  fetchImpl?: typeof fetch;
176
+ /**
177
+ * `navigator.sendBeacon` inyectable. Solo se usa en el flush final (cierre de página):
178
+ * sobrevive al unload, a cambio de no poder leer la respuesta. Si no se pasa, el flush
179
+ * final usa `fetch` con `keepalive`.
180
+ */
181
+ sendBeaconImpl?: (url: string, body: Blob | string) => boolean;
146
182
  /** Callback invocado cuando el backend devuelve un nuevo sessionId (primer POST). */
147
183
  onSessionId?: (id: string) => void;
184
+ /** Callback invocado al cerrar la sesión (`completed:true`): el sessionId cacheado se olvida. */
185
+ onSessionReset?: () => void;
148
186
  }
149
187
  /**
150
188
  * Sink real: transforma el envelope y hace `POST {baseUrl}/sdk/feedback`.
151
- * Fire-and-forget (no bloquea el flush); errores solo se loguean.
152
- * Stateful: cachea el `sessionId` que devuelve el backend en la primera respuesta
153
- * y lo envía en los siguientes POSTs para que el backend agrupe todos los eventos
154
- * en un único registro de feedback.
189
+ *
190
+ * Stateful: cachea el `sessionId` que devuelve el backend en la primera respuesta y lo
191
+ * envía en los siguientes POSTs para que el backend agrupe todos los eventos en un único
192
+ * registro de feedback.
193
+ *
194
+ * Garantías de entrega:
195
+ * - mientras no se conozca el `sessionId`, los lotes se **serializan** (esperan la primera
196
+ * respuesta): dos POSTs a la vez sin `sessionId` crearían dos registros y partirían los datos;
197
+ * - `keepalive` para que el navegador no cancele el POST al navegar fuera, y `sendBeacon`
198
+ * en el flush final (cierre de página), donde `fetch` puede morir con el documento;
199
+ * - la promesa **rechaza** en fallo transitorio (red, 5xx, 408, 429) para que el
200
+ * `AnalyticsManager` re-encole el lote; un 4xx se loguea y se descarta.
155
201
  */
156
202
  declare function createFeedbackSink(options: FeedbackSinkOptions): AnalyticsSink;
157
203
 
@@ -190,9 +236,11 @@ interface DeepdotsInitParams {
190
236
  /** Platform used in the analytics envelope. Default is 'web'; in RN pass 'android'/'ios'. */
191
237
  platform?: 'web' | 'android' | 'ios';
192
238
  /**
193
- * BCP-47 language tag for the analytics context (e.g. 'es-ES'). If omitted, the SDK
194
- * auto-detects: `navigator.language` on web, or the `Intl` locale as a fallback (works in
195
- * React Native with Hermes, where `navigator.language` is absent).
239
+ * BCP-47 language tag (e.g. 'es-ES') used for the analytics context AND for popup
240
+ * language segmentation (`segments.lang`). If omitted, the SDK auto-detects:
241
+ * `navigator.language` on web, or the `Intl` locale as a fallback (works in
242
+ * React Native with Hermes, where `navigator.language` is absent). If no source
243
+ * resolves a language, popups with `segments.lang` cannot be filtered and are shown.
196
244
  */
197
245
  language?: string;
198
246
  /** Injectable device info (RN: from react-native-device-info). If missing, it is derived from the browser. */
@@ -247,6 +295,16 @@ interface DeepdotsEvent {
247
295
  * Event listener callback
248
296
  */
249
297
  type EventListener = (event: DeepdotsEvent) => void;
298
+ /**
299
+ * Motivo del `deepdots_session_end` (último lote de la sesión, enviado con `completed:true`):
300
+ * - `page_hide`: la página web se cierra o se navega fuera (`pagehide`).
301
+ * - `background`: la app pasa a background (RN/nativo). Única señal disponible en móvil:
302
+ * el kill de la app por el usuario o el SO NO produce ningún callback.
303
+ * - `user_change`: `setUserId()` o un `init()` con otro `userId` (login/logout del host).
304
+ * - `tracking_disabled`: `setTrackingEnabled(false)` (consentimiento revocado).
305
+ * - `manual`: `endSession()` llamado por el host.
306
+ */
307
+ type SessionEndReason = 'page_hide' | 'background' | 'user_change' | 'tracking_disabled' | 'manual';
250
308
  /** Trigger type used by remote popup definitions */
251
309
  type PopupTriggerType = 'time_on_page' | 'scroll' | 'exit' | 'click' | 'event';
252
310
  type PopupTriggerConditionStatus = 'SHOWED' | 'PARTIAL' | 'COMPLETED';
@@ -469,13 +527,16 @@ declare class CrashReporter {
469
527
  * el funnel se correlaciona por `message_id` y se agrupa por `message_title`.
470
528
  */
471
529
  type MessageStage = 'delivered' | 'clicked' | 'converted';
530
+ /** Canales válidos. Un mensaje tiene UN canal: se valida en runtime porque el host puede no ser TS. */
531
+ declare const MESSAGE_CHANNELS: readonly ["push", "in_app"];
532
+ type MessageChannel = (typeof MESSAGE_CHANNELS)[number];
472
533
  interface TrackMessageOptions {
473
534
  /** Identificador del mensaje/notificación — correlaciona el funnel delivered→clicked→converted. */
474
535
  id: string;
475
536
  /** Título del mensaje — dimensión de agrupación de #18–22. */
476
537
  title: string;
477
538
  /** Canal de entrega. */
478
- channel: 'push' | 'in_app';
539
+ channel: MessageChannel;
479
540
  /** Nombre de la campaña (opcional). */
480
541
  campaign?: string;
481
542
  /** Valor de conversión (opcional, típico en `stage: 'converted'`). */
@@ -486,6 +547,38 @@ interface TrackMessageOptions {
486
547
  }
487
548
  /** Construye los params del evento `deepdots_message` (omite opcionales ausentes). */
488
549
  declare function buildMessageParams(stage: MessageStage, o: TrackMessageOptions): Record<string, unknown>;
550
+ /** Techo de `message_id` vigilados por sesión; los más antiguos se evictan (memoria acotada). */
551
+ declare const MAX_TRACKED_MESSAGES = 500;
552
+ type MessageRejectionReason = 'invalid_channel' | 'duplicate_stage' | 'channel_conflict';
553
+ type MessageGuardVerdict = {
554
+ emit: true;
555
+ } | {
556
+ emit: false;
557
+ reason: MessageRejectionReason;
558
+ detail: string;
559
+ };
560
+ /**
561
+ * Protege el funnel de Messaging de las formas imposibles que el host puede producir por error
562
+ * (visto en producción: CTR > 100% por falta de `delivered` + el mismo `message_id` reportado en
563
+ * dos canales). Tres reglas, todas dentro de la sesión (se reinicia en cada `init()`):
564
+ *
565
+ * 1. `channel` fuera de `MESSAGE_CHANNELS` → se descarta el evento.
566
+ * 2. Un `(message_id, stage)` ya emitido → se emite una sola vez.
567
+ * 3. Un `message_id` ya visto en un canal → se descartan los eventos de otro canal.
568
+ *
569
+ * Los eventos rechazados NO mutan el estado: un rechazo nunca "consume" el stage bueno.
570
+ * Lo que el SDK no puede arreglar es la ausencia de `delivered`: eso lo instrumenta el host.
571
+ */
572
+ declare class MessageGuard {
573
+ private maxTrackedMessages;
574
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
575
+ private seen;
576
+ constructor(maxTrackedMessages?: number);
577
+ evaluate(stage: MessageStage, options: TrackMessageOptions): MessageGuardVerdict;
578
+ /** Olvida todo lo vigilado (nueva sesión). */
579
+ reset(): void;
580
+ private remember;
581
+ }
489
582
 
490
583
  /**
491
584
  * Main class for managing survey popups
@@ -519,14 +612,29 @@ declare class DeepdotsPopups {
519
612
  private analyticsFlushTimer;
520
613
  /** Crash & error reporting (#14–17). Null hasta init(). */
521
614
  private crashReporter;
615
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
616
+ private messageGuard;
522
617
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
523
618
  private navObserver;
524
619
  /** Tiempo activo (engagement time, #8). */
525
620
  private engagement;
526
621
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
527
622
  private contact;
623
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
624
+ private storage;
528
625
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
529
626
  private navStarted;
627
+ /**
628
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
629
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
630
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
631
+ */
632
+ private sessionOpen;
633
+ /**
634
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
635
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
636
+ */
637
+ private language;
530
638
  /** Initialize the SDK with configuration */
531
639
  init(config: DeepdotsInitParams): void;
532
640
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -565,24 +673,66 @@ declare class DeepdotsPopups {
565
673
  trackFindabilityFriction(frictionTopic: string, params?: Record<string, unknown>): void;
566
674
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
567
675
  trackFunnelStep(funnel: string, step: string, taskId: string, params?: Record<string, unknown>): void;
568
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
676
+ /**
677
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
678
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
679
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
680
+ * en otro canal — ver `MessageGuard`.
681
+ */
569
682
  trackMessage(stage: MessageStage, options: TrackMessageOptions): void;
570
683
  /**
571
684
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
572
685
  * El host la llama en cada cambio de pantalla; emite `page_view` al salir de la anterior.
573
686
  */
574
687
  setScreen(name: string): void;
575
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
688
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
576
689
  onForeground(): void;
577
690
  /**
578
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
579
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
691
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
692
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
693
+ * envía el último lote con `completed:true`.
694
+ *
695
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
580
696
  */
581
697
  onBackground(): void;
698
+ /**
699
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
700
+ * `session_end` y envía el último lote con `completed:true`.
701
+ */
702
+ endSession(): void;
703
+ /**
704
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
705
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
706
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
707
+ *
708
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
709
+ */
710
+ setUserId(userId?: string): void;
711
+ /**
712
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
713
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
714
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
715
+ *
716
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
717
+ * el `session_end`.
718
+ */
719
+ private closeSession;
720
+ /**
721
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
722
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
723
+ */
724
+ private openSession;
582
725
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
583
726
  previewAnalytics(): AnalyticsEnvelope;
584
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
585
- flushAnalytics(): void;
727
+ /**
728
+ * Envía el lote acumulado de analytics y vacía el buffer.
729
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
730
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
731
+ */
732
+ flushAnalytics(options?: {
733
+ final?: boolean;
734
+ sessionEnd?: boolean;
735
+ }): void;
586
736
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
587
737
  private flushEngagement;
588
738
  private analyticsIdentity;
@@ -775,4 +925,4 @@ declare function collectRnDevice(d: DeviceInfoLike): DeviceInfo;
775
925
  */
776
926
  declare function setupReactNative(sdk: DeepdotsPopups, config: DeepdotsInitParams, deps?: ReactNativeSetupDeps): () => void;
777
927
 
778
- export { type AnalyticsFeedbackBody, type AnalyticsKeys, type AppStateLike, BrowserPopupRenderer, type ContactAttributeValue, type ContactAttributes, type ContactBody, ContactManager, type CrashRecord, CrashReporter, type CrashSeverity, type DeepdotsConfig, type DeepdotsEvent, type DeepdotsEventType, type DeepdotsInitParams, type DeepdotsLogger, DeepdotsPopups, type DeviceInfo, type DeviceInfoLike, type DeviceSnapshot, type EventListener, type FeedbackKV, InMemoryStorage, type KeyValueStorage, type MessageStage, type MmkvLike, NoopPopupRenderer, POPUP_TRIGGER_CONDITION_STATUSES, type PopupDefinition, type PopupRenderer, type PopupTrigger, type PopupTriggerCondition, type PopupTriggerConditionStatus, type ReactNativeErrorUtils, ReactNativePopupRenderer, type ReactNativeRendererOptions, type ReactNativeSetupDeps, type ReactNativeSurveyPayload, type ReportErrorOptions, type TrackMessageOptions, type TriggerConfig, buildAnalyticsFeedbackBody, buildMessageParams, buildSurveyHtml, collectRnDevice, crashRecordToParams, createDefaultRenderer, createFeedbackSink, mmkvStorage, setupReactNative };
928
+ export { type AnalyticsFeedbackBody, type AnalyticsKeys, type AppStateLike, BrowserPopupRenderer, type BuildBodyOptions, type ContactAttributeValue, type ContactAttributes, type ContactBody, ContactManager, type CrashRecord, CrashReporter, type CrashSeverity, type DeepdotsConfig, type DeepdotsEvent, type DeepdotsEventType, type DeepdotsInitParams, type DeepdotsLogger, DeepdotsPopups, type DeviceInfo, type DeviceInfoLike, type DeviceSnapshot, type EventListener, type FeedbackKV, InMemoryStorage, type KeyValueStorage, MAX_TRACKED_MESSAGES, MESSAGE_CHANNELS, type MessageChannel, MessageGuard, type MessageGuardVerdict, type MessageRejectionReason, type MessageStage, type MmkvLike, NoopPopupRenderer, POPUP_TRIGGER_CONDITION_STATUSES, type PopupDefinition, type PopupRenderer, type PopupTrigger, type PopupTriggerCondition, type PopupTriggerConditionStatus, type ReactNativeErrorUtils, ReactNativePopupRenderer, type ReactNativeRendererOptions, type ReactNativeSetupDeps, type ReactNativeSurveyPayload, type ReportErrorOptions, type SessionEndReason, type TrackMessageOptions, type TriggerConfig, buildAnalyticsFeedbackBody, buildMessageParams, buildSurveyHtml, collectRnDevice, crashRecordToParams, createDefaultRenderer, createFeedbackSink, mmkvStorage, setupReactNative };
package/dist/index.d.ts CHANGED
@@ -91,14 +91,36 @@ interface AnalyticsEnvelope {
91
91
  };
92
92
  events: AnalyticsEvent[];
93
93
  }
94
- type AnalyticsSink = (payload: AnalyticsEnvelope) => void;
94
+ /** Contexto del flush, para que el sink pueda adaptar el transporte. */
95
+ interface AnalyticsFlushMeta {
96
+ /**
97
+ * `true` cuando el flush ocurre porque la página/app se está cerrando. El sink debe usar
98
+ * un transporte que sobreviva al unload (sendBeacon) y no puede esperar respuestas.
99
+ */
100
+ final?: boolean;
101
+ /**
102
+ * `true` cuando este es el ÚLTIMO lote de la sesión (cierre de página, app a background,
103
+ * cambio de usuario, tracking desactivado). El sink lo envía con `completed:true` y olvida
104
+ * el `sessionId`, para que el lote siguiente abra un registro nuevo.
105
+ */
106
+ sessionEnd?: boolean;
107
+ }
108
+ /**
109
+ * Envío del lote. Si devuelve una promesa que **rechaza**, el manager re-encola los
110
+ * eventos para reintentarlos en el siguiente flush (fallo transitorio: red o 5xx).
111
+ * Resolver = lote entregado o descartado definitivamente (no se reintenta).
112
+ */
113
+ type AnalyticsSink = (payload: AnalyticsEnvelope, meta?: AnalyticsFlushMeta) => void | Promise<void>;
95
114
 
96
115
  /**
97
116
  * Mapeo del envelope de analytics → body de `POST /sdk/feedback`.
98
117
  *
99
118
  * La analítica se envía como un Feedback del modelo del Surveys SDK, agrupado por una
100
- * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false` y
101
- * `finished:false` (nunca se "cierra"); el backend cose por `sessionId` + `user_id`.
119
+ * INTEGRACIÓN creada en la plataforma. Se manda en streaming con `completed:false`; el
120
+ * backend cose por `sessionId` + `user_id`. El ÚLTIMO lote de una sesión (cierre de página,
121
+ * app a background, cambio de usuario) va con `completed:true`: cierra el registro y el
122
+ * siguiente lote omite el `sessionId` viejo para que el backend abra uno nuevo.
123
+ * `feedback.finished` se queda siempre en `false` (no es la señal de cierre acordada).
102
124
  *
103
125
  * Encoding:
104
126
  * - todo va en `feedback.metadata`: contexto (user_id, session_id, platform…) + eventos
@@ -134,7 +156,15 @@ interface AnalyticsFeedbackBody {
134
156
  /** sessionId devuelto por el primer POST; permite que el backend agrupe todos los eventos en un solo registro. */
135
157
  sessionId?: string;
136
158
  }
137
- declare function buildAnalyticsFeedbackBody(envelope: AnalyticsEnvelope, keys: AnalyticsKeys, feedbackSessionId?: string): AnalyticsFeedbackBody;
159
+ interface BuildBodyOptions {
160
+ /**
161
+ * `true` en el último lote de la sesión → `completed:true` (cierra el registro en backend).
162
+ * El body SÍ lleva el `sessionId` actual (es el registro que se cierra); es el lote
163
+ * SIGUIENTE el que lo omite.
164
+ */
165
+ sessionEnd?: boolean;
166
+ }
167
+ declare function buildAnalyticsFeedbackBody(envelope: AnalyticsEnvelope, keys: AnalyticsKeys, feedbackSessionId?: string, options?: BuildBodyOptions): AnalyticsFeedbackBody;
138
168
  interface FeedbackSinkOptions {
139
169
  /** Base URL del backend (api(.|-dev.)deepdots.com) ya resuelta por el entorno. */
140
170
  baseUrl: string;
@@ -143,15 +173,31 @@ interface FeedbackSinkOptions {
143
173
  log?: (...args: unknown[]) => void;
144
174
  /** fetch inyectable (tests / RN). */
145
175
  fetchImpl?: typeof fetch;
176
+ /**
177
+ * `navigator.sendBeacon` inyectable. Solo se usa en el flush final (cierre de página):
178
+ * sobrevive al unload, a cambio de no poder leer la respuesta. Si no se pasa, el flush
179
+ * final usa `fetch` con `keepalive`.
180
+ */
181
+ sendBeaconImpl?: (url: string, body: Blob | string) => boolean;
146
182
  /** Callback invocado cuando el backend devuelve un nuevo sessionId (primer POST). */
147
183
  onSessionId?: (id: string) => void;
184
+ /** Callback invocado al cerrar la sesión (`completed:true`): el sessionId cacheado se olvida. */
185
+ onSessionReset?: () => void;
148
186
  }
149
187
  /**
150
188
  * Sink real: transforma el envelope y hace `POST {baseUrl}/sdk/feedback`.
151
- * Fire-and-forget (no bloquea el flush); errores solo se loguean.
152
- * Stateful: cachea el `sessionId` que devuelve el backend en la primera respuesta
153
- * y lo envía en los siguientes POSTs para que el backend agrupe todos los eventos
154
- * en un único registro de feedback.
189
+ *
190
+ * Stateful: cachea el `sessionId` que devuelve el backend en la primera respuesta y lo
191
+ * envía en los siguientes POSTs para que el backend agrupe todos los eventos en un único
192
+ * registro de feedback.
193
+ *
194
+ * Garantías de entrega:
195
+ * - mientras no se conozca el `sessionId`, los lotes se **serializan** (esperan la primera
196
+ * respuesta): dos POSTs a la vez sin `sessionId` crearían dos registros y partirían los datos;
197
+ * - `keepalive` para que el navegador no cancele el POST al navegar fuera, y `sendBeacon`
198
+ * en el flush final (cierre de página), donde `fetch` puede morir con el documento;
199
+ * - la promesa **rechaza** en fallo transitorio (red, 5xx, 408, 429) para que el
200
+ * `AnalyticsManager` re-encole el lote; un 4xx se loguea y se descarta.
155
201
  */
156
202
  declare function createFeedbackSink(options: FeedbackSinkOptions): AnalyticsSink;
157
203
 
@@ -190,9 +236,11 @@ interface DeepdotsInitParams {
190
236
  /** Platform used in the analytics envelope. Default is 'web'; in RN pass 'android'/'ios'. */
191
237
  platform?: 'web' | 'android' | 'ios';
192
238
  /**
193
- * BCP-47 language tag for the analytics context (e.g. 'es-ES'). If omitted, the SDK
194
- * auto-detects: `navigator.language` on web, or the `Intl` locale as a fallback (works in
195
- * React Native with Hermes, where `navigator.language` is absent).
239
+ * BCP-47 language tag (e.g. 'es-ES') used for the analytics context AND for popup
240
+ * language segmentation (`segments.lang`). If omitted, the SDK auto-detects:
241
+ * `navigator.language` on web, or the `Intl` locale as a fallback (works in
242
+ * React Native with Hermes, where `navigator.language` is absent). If no source
243
+ * resolves a language, popups with `segments.lang` cannot be filtered and are shown.
196
244
  */
197
245
  language?: string;
198
246
  /** Injectable device info (RN: from react-native-device-info). If missing, it is derived from the browser. */
@@ -247,6 +295,16 @@ interface DeepdotsEvent {
247
295
  * Event listener callback
248
296
  */
249
297
  type EventListener = (event: DeepdotsEvent) => void;
298
+ /**
299
+ * Motivo del `deepdots_session_end` (último lote de la sesión, enviado con `completed:true`):
300
+ * - `page_hide`: la página web se cierra o se navega fuera (`pagehide`).
301
+ * - `background`: la app pasa a background (RN/nativo). Única señal disponible en móvil:
302
+ * el kill de la app por el usuario o el SO NO produce ningún callback.
303
+ * - `user_change`: `setUserId()` o un `init()` con otro `userId` (login/logout del host).
304
+ * - `tracking_disabled`: `setTrackingEnabled(false)` (consentimiento revocado).
305
+ * - `manual`: `endSession()` llamado por el host.
306
+ */
307
+ type SessionEndReason = 'page_hide' | 'background' | 'user_change' | 'tracking_disabled' | 'manual';
250
308
  /** Trigger type used by remote popup definitions */
251
309
  type PopupTriggerType = 'time_on_page' | 'scroll' | 'exit' | 'click' | 'event';
252
310
  type PopupTriggerConditionStatus = 'SHOWED' | 'PARTIAL' | 'COMPLETED';
@@ -469,13 +527,16 @@ declare class CrashReporter {
469
527
  * el funnel se correlaciona por `message_id` y se agrupa por `message_title`.
470
528
  */
471
529
  type MessageStage = 'delivered' | 'clicked' | 'converted';
530
+ /** Canales válidos. Un mensaje tiene UN canal: se valida en runtime porque el host puede no ser TS. */
531
+ declare const MESSAGE_CHANNELS: readonly ["push", "in_app"];
532
+ type MessageChannel = (typeof MESSAGE_CHANNELS)[number];
472
533
  interface TrackMessageOptions {
473
534
  /** Identificador del mensaje/notificación — correlaciona el funnel delivered→clicked→converted. */
474
535
  id: string;
475
536
  /** Título del mensaje — dimensión de agrupación de #18–22. */
476
537
  title: string;
477
538
  /** Canal de entrega. */
478
- channel: 'push' | 'in_app';
539
+ channel: MessageChannel;
479
540
  /** Nombre de la campaña (opcional). */
480
541
  campaign?: string;
481
542
  /** Valor de conversión (opcional, típico en `stage: 'converted'`). */
@@ -486,6 +547,38 @@ interface TrackMessageOptions {
486
547
  }
487
548
  /** Construye los params del evento `deepdots_message` (omite opcionales ausentes). */
488
549
  declare function buildMessageParams(stage: MessageStage, o: TrackMessageOptions): Record<string, unknown>;
550
+ /** Techo de `message_id` vigilados por sesión; los más antiguos se evictan (memoria acotada). */
551
+ declare const MAX_TRACKED_MESSAGES = 500;
552
+ type MessageRejectionReason = 'invalid_channel' | 'duplicate_stage' | 'channel_conflict';
553
+ type MessageGuardVerdict = {
554
+ emit: true;
555
+ } | {
556
+ emit: false;
557
+ reason: MessageRejectionReason;
558
+ detail: string;
559
+ };
560
+ /**
561
+ * Protege el funnel de Messaging de las formas imposibles que el host puede producir por error
562
+ * (visto en producción: CTR > 100% por falta de `delivered` + el mismo `message_id` reportado en
563
+ * dos canales). Tres reglas, todas dentro de la sesión (se reinicia en cada `init()`):
564
+ *
565
+ * 1. `channel` fuera de `MESSAGE_CHANNELS` → se descarta el evento.
566
+ * 2. Un `(message_id, stage)` ya emitido → se emite una sola vez.
567
+ * 3. Un `message_id` ya visto en un canal → se descartan los eventos de otro canal.
568
+ *
569
+ * Los eventos rechazados NO mutan el estado: un rechazo nunca "consume" el stage bueno.
570
+ * Lo que el SDK no puede arreglar es la ausencia de `delivered`: eso lo instrumenta el host.
571
+ */
572
+ declare class MessageGuard {
573
+ private maxTrackedMessages;
574
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
575
+ private seen;
576
+ constructor(maxTrackedMessages?: number);
577
+ evaluate(stage: MessageStage, options: TrackMessageOptions): MessageGuardVerdict;
578
+ /** Olvida todo lo vigilado (nueva sesión). */
579
+ reset(): void;
580
+ private remember;
581
+ }
489
582
 
490
583
  /**
491
584
  * Main class for managing survey popups
@@ -519,14 +612,29 @@ declare class DeepdotsPopups {
519
612
  private analyticsFlushTimer;
520
613
  /** Crash & error reporting (#14–17). Null hasta init(). */
521
614
  private crashReporter;
615
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
616
+ private messageGuard;
522
617
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
523
618
  private navObserver;
524
619
  /** Tiempo activo (engagement time, #8). */
525
620
  private engagement;
526
621
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
527
622
  private contact;
623
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
624
+ private storage;
528
625
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
529
626
  private navStarted;
627
+ /**
628
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
629
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
630
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
631
+ */
632
+ private sessionOpen;
633
+ /**
634
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
635
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
636
+ */
637
+ private language;
530
638
  /** Initialize the SDK with configuration */
531
639
  init(config: DeepdotsInitParams): void;
532
640
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -565,24 +673,66 @@ declare class DeepdotsPopups {
565
673
  trackFindabilityFriction(frictionTopic: string, params?: Record<string, unknown>): void;
566
674
  /** Funnel: un paso del embudo, correlacionado por `taskId`. El backend reconstruye conversión/drop-off/tiempo. */
567
675
  trackFunnelStep(funnel: string, step: string, taskId: string, params?: Record<string, unknown>): void;
568
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
676
+ /**
677
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
678
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
679
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
680
+ * en otro canal — ver `MessageGuard`.
681
+ */
569
682
  trackMessage(stage: MessageStage, options: TrackMessageOptions): void;
570
683
  /**
571
684
  * Navegación MANUAL (entornos sin History API, p. ej. React Native con React Navigation).
572
685
  * El host la llama en cada cambio de pantalla; emite `page_view` al salir de la anterior.
573
686
  */
574
687
  setScreen(name: string): void;
575
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
688
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
576
689
  onForeground(): void;
577
690
  /**
578
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
579
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
691
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
692
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
693
+ * envía el último lote con `completed:true`.
694
+ *
695
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
580
696
  */
581
697
  onBackground(): void;
698
+ /**
699
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
700
+ * `session_end` y envía el último lote con `completed:true`.
701
+ */
702
+ endSession(): void;
703
+ /**
704
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
705
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
706
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
707
+ *
708
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
709
+ */
710
+ setUserId(userId?: string): void;
711
+ /**
712
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
713
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
714
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
715
+ *
716
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
717
+ * el `session_end`.
718
+ */
719
+ private closeSession;
720
+ /**
721
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
722
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
723
+ */
724
+ private openSession;
582
725
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
583
726
  previewAnalytics(): AnalyticsEnvelope;
584
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
585
- flushAnalytics(): void;
727
+ /**
728
+ * Envía el lote acumulado de analytics y vacía el buffer.
729
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
730
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
731
+ */
732
+ flushAnalytics(options?: {
733
+ final?: boolean;
734
+ sessionEnd?: boolean;
735
+ }): void;
586
736
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
587
737
  private flushEngagement;
588
738
  private analyticsIdentity;
@@ -775,4 +925,4 @@ declare function collectRnDevice(d: DeviceInfoLike): DeviceInfo;
775
925
  */
776
926
  declare function setupReactNative(sdk: DeepdotsPopups, config: DeepdotsInitParams, deps?: ReactNativeSetupDeps): () => void;
777
927
 
778
- export { type AnalyticsFeedbackBody, type AnalyticsKeys, type AppStateLike, BrowserPopupRenderer, type ContactAttributeValue, type ContactAttributes, type ContactBody, ContactManager, type CrashRecord, CrashReporter, type CrashSeverity, type DeepdotsConfig, type DeepdotsEvent, type DeepdotsEventType, type DeepdotsInitParams, type DeepdotsLogger, DeepdotsPopups, type DeviceInfo, type DeviceInfoLike, type DeviceSnapshot, type EventListener, type FeedbackKV, InMemoryStorage, type KeyValueStorage, type MessageStage, type MmkvLike, NoopPopupRenderer, POPUP_TRIGGER_CONDITION_STATUSES, type PopupDefinition, type PopupRenderer, type PopupTrigger, type PopupTriggerCondition, type PopupTriggerConditionStatus, type ReactNativeErrorUtils, ReactNativePopupRenderer, type ReactNativeRendererOptions, type ReactNativeSetupDeps, type ReactNativeSurveyPayload, type ReportErrorOptions, type TrackMessageOptions, type TriggerConfig, buildAnalyticsFeedbackBody, buildMessageParams, buildSurveyHtml, collectRnDevice, crashRecordToParams, createDefaultRenderer, createFeedbackSink, mmkvStorage, setupReactNative };
928
+ export { type AnalyticsFeedbackBody, type AnalyticsKeys, type AppStateLike, BrowserPopupRenderer, type BuildBodyOptions, type ContactAttributeValue, type ContactAttributes, type ContactBody, ContactManager, type CrashRecord, CrashReporter, type CrashSeverity, type DeepdotsConfig, type DeepdotsEvent, type DeepdotsEventType, type DeepdotsInitParams, type DeepdotsLogger, DeepdotsPopups, type DeviceInfo, type DeviceInfoLike, type DeviceSnapshot, type EventListener, type FeedbackKV, InMemoryStorage, type KeyValueStorage, MAX_TRACKED_MESSAGES, MESSAGE_CHANNELS, type MessageChannel, MessageGuard, type MessageGuardVerdict, type MessageRejectionReason, type MessageStage, type MmkvLike, NoopPopupRenderer, POPUP_TRIGGER_CONDITION_STATUSES, type PopupDefinition, type PopupRenderer, type PopupTrigger, type PopupTriggerCondition, type PopupTriggerConditionStatus, type ReactNativeErrorUtils, ReactNativePopupRenderer, type ReactNativeRendererOptions, type ReactNativeSetupDeps, type ReactNativeSurveyPayload, type ReportErrorOptions, type SessionEndReason, type TrackMessageOptions, type TriggerConfig, buildAnalyticsFeedbackBody, buildMessageParams, buildSurveyHtml, collectRnDevice, crashRecordToParams, createDefaultRenderer, createFeedbackSink, mmkvStorage, setupReactNative };