@magicfeedback/popup-sdk 1.1.7 → 1.1.8

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.
@@ -139,9 +139,11 @@ interface DeepdotsInitParams {
139
139
  /** Platform used in the analytics envelope. Default is 'web'; in RN pass 'android'/'ios'. */
140
140
  platform?: 'web' | 'android' | 'ios';
141
141
  /**
142
- * BCP-47 language tag for the analytics context (e.g. 'es-ES'). If omitted, the SDK
143
- * auto-detects: `navigator.language` on web, or the `Intl` locale as a fallback (works in
144
- * React Native with Hermes, where `navigator.language` is absent).
142
+ * BCP-47 language tag (e.g. 'es-ES') used for the analytics context AND for popup
143
+ * language segmentation (`segments.lang`). If omitted, the SDK auto-detects:
144
+ * `navigator.language` on web, or the `Intl` locale as a fallback (works in
145
+ * React Native with Hermes, where `navigator.language` is absent). If no source
146
+ * resolves a language, popups with `segments.lang` cannot be filtered and are shown.
145
147
  */
146
148
  language?: string;
147
149
  /** Injectable device info (RN: from react-native-device-info). If missing, it is derived from the browser. */
@@ -333,6 +335,11 @@ declare class DeepdotsPopups {
333
335
  private contact;
334
336
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
335
337
  private navStarted;
338
+ /**
339
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
340
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
341
+ */
342
+ private language;
336
343
  /** Initialize the SDK with configuration */
337
344
  init(config: DeepdotsInitParams): void;
338
345
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -387,8 +394,14 @@ declare class DeepdotsPopups {
387
394
  onBackground(): void;
388
395
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
389
396
  previewAnalytics(): AnalyticsEnvelope;
390
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
391
- flushAnalytics(): void;
397
+ /**
398
+ * Envía el lote acumulado de analytics y vacía el buffer.
399
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
400
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
401
+ */
402
+ flushAnalytics(options?: {
403
+ final?: boolean;
404
+ }): void;
392
405
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
393
406
  private flushEngagement;
394
407
  private analyticsIdentity;
@@ -139,9 +139,11 @@ interface DeepdotsInitParams {
139
139
  /** Platform used in the analytics envelope. Default is 'web'; in RN pass 'android'/'ios'. */
140
140
  platform?: 'web' | 'android' | 'ios';
141
141
  /**
142
- * BCP-47 language tag for the analytics context (e.g. 'es-ES'). If omitted, the SDK
143
- * auto-detects: `navigator.language` on web, or the `Intl` locale as a fallback (works in
144
- * React Native with Hermes, where `navigator.language` is absent).
142
+ * BCP-47 language tag (e.g. 'es-ES') used for the analytics context AND for popup
143
+ * language segmentation (`segments.lang`). If omitted, the SDK auto-detects:
144
+ * `navigator.language` on web, or the `Intl` locale as a fallback (works in
145
+ * React Native with Hermes, where `navigator.language` is absent). If no source
146
+ * resolves a language, popups with `segments.lang` cannot be filtered and are shown.
145
147
  */
146
148
  language?: string;
147
149
  /** Injectable device info (RN: from react-native-device-info). If missing, it is derived from the browser. */
@@ -333,6 +335,11 @@ declare class DeepdotsPopups {
333
335
  private contact;
334
336
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
335
337
  private navStarted;
338
+ /**
339
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
340
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
341
+ */
342
+ private language;
336
343
  /** Initialize the SDK with configuration */
337
344
  init(config: DeepdotsInitParams): void;
338
345
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -387,8 +394,14 @@ declare class DeepdotsPopups {
387
394
  onBackground(): void;
388
395
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
389
396
  previewAnalytics(): AnalyticsEnvelope;
390
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
391
- flushAnalytics(): void;
397
+ /**
398
+ * Envía el lote acumulado de analytics y vacía el buffer.
399
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
400
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
401
+ */
402
+ flushAnalytics(options?: {
403
+ final?: boolean;
404
+ }): void;
392
405
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
393
406
  private flushEngagement;
394
407
  private analyticsIdentity;
@@ -2030,6 +2030,7 @@ var AnalyticsManager = class {
2030
2030
  this.device = options.device;
2031
2031
  this.platform = options.platform ?? "web";
2032
2032
  this.maxBatchSize = options.maxBatchSize ?? 20;
2033
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
2033
2034
  this.onFlushNeeded = options.onFlushNeeded;
2034
2035
  }
2035
2036
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -2090,6 +2091,7 @@ var AnalyticsManager = class {
2090
2091
  timestamp: this.now(),
2091
2092
  params: Object.keys(merged).length ? merged : void 0
2092
2093
  });
2094
+ this.enforceBufferCap();
2093
2095
  if (this.events.length >= this.maxBatchSize) {
2094
2096
  this.onFlushNeeded?.();
2095
2097
  }
@@ -2120,14 +2122,43 @@ var AnalyticsManager = class {
2120
2122
  events: [...this.events]
2121
2123
  };
2122
2124
  }
2123
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
2124
- flush(identity) {
2125
+ /**
2126
+ * Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos.
2127
+ *
2128
+ * Si el sink falla de forma transitoria (promesa rechazada o excepción síncrona) el lote
2129
+ * se **re-encola** al principio del buffer y se reintenta en el siguiente flush: un error
2130
+ * de red o un 5xx no debe hacer desaparecer los eventos.
2131
+ */
2132
+ flush(identity, meta) {
2125
2133
  if (this.events.length === 0) return null;
2134
+ const batch = this.events;
2126
2135
  const payload = this.buildPayload(identity);
2127
- this.sink(payload);
2128
2136
  this.events = [];
2137
+ let result;
2138
+ try {
2139
+ result = meta ? this.sink(payload, meta) : this.sink(payload);
2140
+ } catch {
2141
+ this.requeue(batch);
2142
+ return payload;
2143
+ }
2144
+ if (result && typeof result.then === "function") {
2145
+ result.catch(() => this.requeue(batch));
2146
+ }
2129
2147
  return payload;
2130
2148
  }
2149
+ /**
2150
+ * Devuelve un lote fallido al buffer, delante de lo que haya llegado mientras se enviaba
2151
+ * (orden cronológico). Con el buffer lleno se descartan los eventos más antiguos.
2152
+ */
2153
+ requeue(batch) {
2154
+ this.events = [...batch, ...this.events];
2155
+ this.enforceBufferCap();
2156
+ }
2157
+ /** Techo del buffer: si el envío lleva rato fallando, se sacrifica lo más antiguo. */
2158
+ enforceBufferCap() {
2159
+ const overflow = this.events.length - this.maxBufferedEvents;
2160
+ if (overflow > 0) this.events.splice(0, overflow);
2161
+ }
2131
2162
  };
2132
2163
 
2133
2164
  // src/analytics/feedback-payload.ts
@@ -2190,33 +2221,80 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
2190
2221
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
2191
2222
  };
2192
2223
  }
2224
+ var KEEPALIVE_MAX_BYTES = 6e4;
2225
+ function isRetryableStatus(status) {
2226
+ return status >= 500 || status === 408 || status === 429;
2227
+ }
2228
+ async function safeBodyText(res) {
2229
+ try {
2230
+ return (await res.text()).slice(0, 500);
2231
+ } catch {
2232
+ return "";
2233
+ }
2234
+ }
2193
2235
  function createFeedbackSink(options) {
2194
2236
  let feedbackSessionId;
2195
- return (envelope) => {
2196
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
2237
+ let firstPostInFlight = null;
2238
+ const url = `${options.baseUrl}/sdk/feedback`;
2239
+ const post = async (body, final) => {
2240
+ const json = JSON.stringify(body);
2241
+ const small = json.length <= KEEPALIVE_MAX_BYTES;
2242
+ if (final && options.sendBeaconImpl && small) {
2243
+ const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
2244
+ if (options.sendBeaconImpl(url, payload)) {
2245
+ options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
2246
+ return;
2247
+ }
2248
+ options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
2249
+ }
2197
2250
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
2198
2251
  if (!f) {
2199
2252
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
2200
2253
  return;
2201
2254
  }
2202
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
2203
- f(`${options.baseUrl}/sdk/feedback`, {
2255
+ const res = await f(url, {
2204
2256
  method: "POST",
2205
2257
  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
- }
2258
+ body: json,
2259
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
2260
+ ...small ? { keepalive: true } : {}
2261
+ });
2262
+ if (!res.ok) {
2263
+ const detail = await safeBodyText(res);
2264
+ if (isRetryableStatus(res.status)) {
2265
+ throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
2266
+ }
2267
+ options.log?.(
2268
+ `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
2269
+ detail
2270
+ );
2271
+ return;
2272
+ }
2273
+ try {
2274
+ const data = await res.json();
2275
+ if (data?.sessionId && data.sessionId !== feedbackSessionId) {
2276
+ feedbackSessionId = data.sessionId;
2277
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
2278
+ options.onSessionId?.(feedbackSessionId);
2218
2279
  }
2219
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
2280
+ } catch {
2281
+ }
2282
+ };
2283
+ return async (envelope, meta) => {
2284
+ const final = meta?.final === true;
2285
+ if (!feedbackSessionId && firstPostInFlight && !final) {
2286
+ await firstPostInFlight;
2287
+ }
2288
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
2289
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
2290
+ const sent = post(body, final);
2291
+ if (!feedbackSessionId) {
2292
+ firstPostInFlight = sent.then(
2293
+ () => void 0,
2294
+ () => void 0
2295
+ );
2296
+ }
2297
+ await sent;
2220
2298
  };
2221
2299
  }
2222
2300
 
@@ -2710,6 +2788,11 @@ var DeepdotsPopups = class {
2710
2788
  this.contact = null;
2711
2789
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
2712
2790
  this.navStarted = false;
2791
+ /**
2792
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
2793
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
2794
+ */
2795
+ this.language = void 0;
2713
2796
  }
2714
2797
  /** Initialize the SDK with configuration */
2715
2798
  init(config) {
@@ -2751,19 +2834,22 @@ var DeepdotsPopups = class {
2751
2834
  baseUrl: this.baseUrl,
2752
2835
  keys: config.analytics,
2753
2836
  log: (...a) => this.log(...a),
2837
+ // sendBeacon: único transporte que sobrevive al cierre de la página.
2838
+ sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
2754
2839
  onSessionId: (id) => {
2755
2840
  this.analyticsFeedbackSessionId = id;
2756
2841
  }
2757
2842
  }) : createDryRunSink((...a) => this.logger.log(...a));
2758
2843
  const device = config.device ?? collectDeviceInfo(config.appVersion);
2844
+ this.language = resolveLanguage({
2845
+ explicit: config.language,
2846
+ navigator: typeof navigator !== "undefined" ? navigator : void 0,
2847
+ intl: typeof Intl !== "undefined" ? Intl : void 0
2848
+ });
2759
2849
  this.analytics = new AnalyticsManager({
2760
2850
  sink: analyticsSink,
2761
2851
  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
- }),
2852
+ language: this.language,
2767
2853
  platform: config.platform ?? "web",
2768
2854
  device,
2769
2855
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -2939,10 +3025,14 @@ var DeepdotsPopups = class {
2939
3025
  events: []
2940
3026
  };
2941
3027
  }
2942
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
2943
- flushAnalytics() {
3028
+ /**
3029
+ * Envía el lote acumulado de analytics y vacía el buffer.
3030
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
3031
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
3032
+ */
3033
+ flushAnalytics(options) {
2944
3034
  if (!this.tracking?.isTrackingEnabled()) return;
2945
- this.analytics?.flush(this.analyticsIdentity());
3035
+ this.analytics?.flush(this.analyticsIdentity(), options);
2946
3036
  }
2947
3037
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
2948
3038
  flushEngagement() {
@@ -2965,7 +3055,7 @@ var DeepdotsPopups = class {
2965
3055
  this.navObserver?.stop();
2966
3056
  if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
2967
3057
  this.flushEngagement();
2968
- this.flushAnalytics();
3058
+ this.flushAnalytics({ final: true });
2969
3059
  });
2970
3060
  document.addEventListener("visibilitychange", () => {
2971
3061
  if (document.visibilityState === "hidden") {
@@ -3086,14 +3176,16 @@ var DeepdotsPopups = class {
3086
3176
  matchesSegmentsLang(def) {
3087
3177
  const langs = def.segments?.lang;
3088
3178
  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 });
3179
+ const currentLang = (this.language ?? "").toLowerCase();
3180
+ if (!currentLang) {
3181
+ this.debug("No language available for lang comparison", { popupId: def.id, langs });
3092
3182
  return true;
3093
3183
  }
3094
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
3184
+ const matches = langs.some(
3185
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
3186
+ );
3095
3187
  if (!matches) {
3096
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
3188
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
3097
3189
  }
3098
3190
  return matches;
3099
3191
  }