@magicfeedback/popup-sdk 1.1.6 → 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.
@@ -76,6 +76,8 @@ interface AnalyticsEnvelope {
76
76
  device?: DeviceInfo;
77
77
  /** User attributes del cliente, usados para breakdowns (registration_status, pass_type, …). */
78
78
  attributes: Record<string, string>;
79
+ /** Métricas del host: valores medibles persistentes → van en `feedback.metrics` del body. */
80
+ metrics?: Record<string, string>;
79
81
  };
80
82
  events: AnalyticsEvent[];
81
83
  }
@@ -92,7 +94,8 @@ interface AnalyticsEnvelope {
92
94
  * - cada entrada usa `value: string[]` (array de un elemento)
93
95
  * - eventos: {key: nombre_evento, value: [JSON(timestamp + params)]}
94
96
  * - identidad (user_id) → `profile` como `external-user-id`
95
- * - `answers` vacío, `metrics` no se usa
97
+ * - métricas del host (setMetric) → `feedback.metrics` (mismo shape que metadata, sin prefijo)
98
+ * - `answers` vacío
96
99
  * - `text` vacío
97
100
  */
98
101
 
@@ -135,6 +138,14 @@ interface DeepdotsInitParams {
135
138
  storage?: KeyValueStorage;
136
139
  /** Platform used in the analytics envelope. Default is 'web'; in RN pass 'android'/'ios'. */
137
140
  platform?: 'web' | 'android' | 'ios';
141
+ /**
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.
147
+ */
148
+ language?: string;
138
149
  /** Injectable device info (RN: from react-native-device-info). If missing, it is derived from the browser. */
139
150
  device?: DeviceInfo;
140
151
  /**
@@ -324,6 +335,11 @@ declare class DeepdotsPopups {
324
335
  private contact;
325
336
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
326
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;
327
343
  /** Initialize the SDK with configuration */
328
344
  init(config: DeepdotsInitParams): void;
329
345
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -336,6 +352,11 @@ declare class DeepdotsPopups {
336
352
  track(name: string, params?: Record<string, unknown>): void;
337
353
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
338
354
  setUserAttributes(attributes: Record<string, string | number | boolean>): void;
355
+ /**
356
+ * Registra/actualiza una métrica (valor medible) → `feedback.metrics` del body.
357
+ * Persistente y sobrescribe por key. No-op si tracking off.
358
+ */
359
+ setMetric(key: string, value: string | number | boolean): void;
339
360
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
340
361
  reportError(error: unknown, options?: ReportErrorOptions): void;
341
362
  /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
@@ -373,8 +394,14 @@ declare class DeepdotsPopups {
373
394
  onBackground(): void;
374
395
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
375
396
  previewAnalytics(): AnalyticsEnvelope;
376
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
377
- 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;
378
405
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
379
406
  private flushEngagement;
380
407
  private analyticsIdentity;
@@ -76,6 +76,8 @@ interface AnalyticsEnvelope {
76
76
  device?: DeviceInfo;
77
77
  /** User attributes del cliente, usados para breakdowns (registration_status, pass_type, …). */
78
78
  attributes: Record<string, string>;
79
+ /** Métricas del host: valores medibles persistentes → van en `feedback.metrics` del body. */
80
+ metrics?: Record<string, string>;
79
81
  };
80
82
  events: AnalyticsEvent[];
81
83
  }
@@ -92,7 +94,8 @@ interface AnalyticsEnvelope {
92
94
  * - cada entrada usa `value: string[]` (array de un elemento)
93
95
  * - eventos: {key: nombre_evento, value: [JSON(timestamp + params)]}
94
96
  * - identidad (user_id) → `profile` como `external-user-id`
95
- * - `answers` vacío, `metrics` no se usa
97
+ * - métricas del host (setMetric) → `feedback.metrics` (mismo shape que metadata, sin prefijo)
98
+ * - `answers` vacío
96
99
  * - `text` vacío
97
100
  */
98
101
 
@@ -135,6 +138,14 @@ interface DeepdotsInitParams {
135
138
  storage?: KeyValueStorage;
136
139
  /** Platform used in the analytics envelope. Default is 'web'; in RN pass 'android'/'ios'. */
137
140
  platform?: 'web' | 'android' | 'ios';
141
+ /**
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.
147
+ */
148
+ language?: string;
138
149
  /** Injectable device info (RN: from react-native-device-info). If missing, it is derived from the browser. */
139
150
  device?: DeviceInfo;
140
151
  /**
@@ -324,6 +335,11 @@ declare class DeepdotsPopups {
324
335
  private contact;
325
336
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
326
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;
327
343
  /** Initialize the SDK with configuration */
328
344
  init(config: DeepdotsInitParams): void;
329
345
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -336,6 +352,11 @@ declare class DeepdotsPopups {
336
352
  track(name: string, params?: Record<string, unknown>): void;
337
353
  /** User attributes para breakdowns (registration_status, pass_type, sector, pass_status…). */
338
354
  setUserAttributes(attributes: Record<string, string | number | boolean>): void;
355
+ /**
356
+ * Registra/actualiza una métrica (valor medible) → `feedback.metrics` del body.
357
+ * Persistente y sobrescribe por key. No-op si tracking off.
358
+ */
359
+ setMetric(key: string, value: string | number | boolean): void;
339
360
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
340
361
  reportError(error: unknown, options?: ReportErrorOptions): void;
341
362
  /** Engancha `global.ErrorUtils` de RN para capturar errores JS no manejados. No-op si tracking off. */
@@ -373,8 +394,14 @@ declare class DeepdotsPopups {
373
394
  onBackground(): void;
374
395
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
375
396
  previewAnalytics(): AnalyticsEnvelope;
376
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
377
- 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;
378
405
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
379
406
  private flushEngagement;
380
407
  private analyticsIdentity;
@@ -143,8 +143,8 @@ var init_tracking_manager = __esm({
143
143
 
144
144
  // src/ui/font.ts
145
145
  function fontFormatFromUrl(url) {
146
- const clean = url.split("?")[0].split("#")[0];
147
- const ext = clean.slice(clean.lastIndexOf(".") + 1).toLowerCase();
146
+ const clean2 = url.split("?")[0].split("#")[0];
147
+ const ext = clean2.slice(clean2.lastIndexOf(".") + 1).toLowerCase();
148
148
  return FORMAT_BY_EXT[ext];
149
149
  }
150
150
  function sanitizeFamily(family) {
@@ -2019,6 +2019,8 @@ var AnalyticsManager = class {
2019
2019
  constructor(options = {}) {
2020
2020
  this.events = [];
2021
2021
  this.attributes = {};
2022
+ /** Métricas del host (valores medibles persistentes). Sobrescriben por key; se reenvían en cada flush. */
2023
+ this.metrics = {};
2022
2024
  /** Mini-services activos: nombre → timestamp de entrada. El orden de inserción marca el "más reciente". */
2023
2025
  this.activeMiniServices = /* @__PURE__ */ new Map();
2024
2026
  this.sink = options.sink ?? dryRunSink;
@@ -2028,6 +2030,7 @@ var AnalyticsManager = class {
2028
2030
  this.device = options.device;
2029
2031
  this.platform = options.platform ?? "web";
2030
2032
  this.maxBatchSize = options.maxBatchSize ?? 20;
2033
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
2031
2034
  this.onFlushNeeded = options.onFlushNeeded;
2032
2035
  }
2033
2036
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -2041,6 +2044,14 @@ var AnalyticsManager = class {
2041
2044
  this.attributes[k] = String(v);
2042
2045
  }
2043
2046
  }
2047
+ /**
2048
+ * Registra/actualiza una métrica (valor medible). Persistente: se reenvía en cada flush.
2049
+ * Repetir la misma key SOBRESCRIBE; el valor se coerciona a string; ignora key vacía.
2050
+ */
2051
+ setMetric(key, value) {
2052
+ if (!key) return;
2053
+ this.metrics[key] = String(value);
2054
+ }
2044
2055
  /**
2045
2056
  * Marca el inicio de un mini-service; etiqueta los eventos siguientes con `mini_service`.
2046
2057
  * Admite varios activos a la vez (concurrencia); el "actual" para etiquetar es el más reciente.
@@ -2080,6 +2091,7 @@ var AnalyticsManager = class {
2080
2091
  timestamp: this.now(),
2081
2092
  params: Object.keys(merged).length ? merged : void 0
2082
2093
  });
2094
+ this.enforceBufferCap();
2083
2095
  if (this.events.length >= this.maxBatchSize) {
2084
2096
  this.onFlushNeeded?.();
2085
2097
  }
@@ -2104,19 +2116,49 @@ var AnalyticsManager = class {
2104
2116
  platform: this.platform,
2105
2117
  language: this.language,
2106
2118
  device: this.device,
2107
- attributes: { ...this.attributes }
2119
+ attributes: { ...this.attributes },
2120
+ metrics: { ...this.metrics }
2108
2121
  },
2109
2122
  events: [...this.events]
2110
2123
  };
2111
2124
  }
2112
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
2113
- 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) {
2114
2133
  if (this.events.length === 0) return null;
2134
+ const batch = this.events;
2115
2135
  const payload = this.buildPayload(identity);
2116
- this.sink(payload);
2117
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
+ }
2118
2147
  return payload;
2119
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
+ }
2120
2162
  };
2121
2163
 
2122
2164
  // src/analytics/feedback-payload.ts
@@ -2160,10 +2202,15 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
2160
2202
  value: [JSON.stringify({ timestamp: e.timestamp, ...e.params ?? {} })]
2161
2203
  });
2162
2204
  }
2205
+ const metrics = [];
2206
+ for (const [k, v] of Object.entries(context.metrics ?? {})) {
2207
+ pushKV(metrics, k, v);
2208
+ }
2163
2209
  return {
2164
2210
  feedback: {
2165
2211
  text: "",
2166
2212
  answers: [],
2213
+ metrics,
2167
2214
  metadata,
2168
2215
  profile,
2169
2216
  finished: false
@@ -2174,33 +2221,80 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
2174
2221
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
2175
2222
  };
2176
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
+ }
2177
2235
  function createFeedbackSink(options) {
2178
2236
  let feedbackSessionId;
2179
- return (envelope) => {
2180
- 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
+ }
2181
2250
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
2182
2251
  if (!f) {
2183
2252
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
2184
2253
  return;
2185
2254
  }
2186
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
2187
- f(`${options.baseUrl}/sdk/feedback`, {
2255
+ const res = await f(url, {
2188
2256
  method: "POST",
2189
2257
  headers: { "Content-Type": "application/json" },
2190
- body: JSON.stringify(body)
2191
- }).then(async (res) => {
2192
- if (res.ok) {
2193
- try {
2194
- const data = await res.json();
2195
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
2196
- feedbackSessionId = data.sessionId;
2197
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
2198
- options.onSessionId?.(feedbackSessionId);
2199
- }
2200
- } catch {
2201
- }
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}`);
2202
2266
  }
2203
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
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);
2279
+ }
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;
2204
2298
  };
2205
2299
  }
2206
2300
 
@@ -2344,21 +2438,96 @@ function collectDeviceInfo(appVersion) {
2344
2438
  }
2345
2439
 
2346
2440
  // src/analytics/geo-info.ts
2347
- async function collectGeoInfo(fetchImpl) {
2348
- const f = fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
2349
- if (!f) return null;
2441
+ var GEO_STORAGE_KEY = "deepdots.geo";
2442
+ var GEO_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
2443
+ var GEO_TIMEOUT_MS = 3e3;
2444
+ function readCachedGeo(storage, now) {
2445
+ try {
2446
+ const raw = storage.getItem(GEO_STORAGE_KEY);
2447
+ if (!raw) return null;
2448
+ const cached = JSON.parse(raw);
2449
+ if (typeof cached.ts !== "number" || now - cached.ts > GEO_TTL_MS) return null;
2450
+ const geo = {};
2451
+ if (cached.country) geo.country = cached.country;
2452
+ if (cached.city) geo.city = cached.city;
2453
+ return geo.country || geo.city ? geo : null;
2454
+ } catch {
2455
+ return null;
2456
+ }
2457
+ }
2458
+ function writeCachedGeo(storage, geo, now) {
2459
+ try {
2460
+ const payload = { ...geo, ts: now };
2461
+ storage.setItem(GEO_STORAGE_KEY, JSON.stringify(payload));
2462
+ } catch {
2463
+ }
2464
+ }
2465
+ var DEFAULT_GEO_PROVIDERS = [
2466
+ {
2467
+ // https://ipapi.co/json/
2468
+ url: "https://ipapi.co/json/",
2469
+ parse: (d) => geoFrom(d.country_code, d.city)
2470
+ },
2471
+ {
2472
+ // https://ipwho.is/ (devuelve success:false con 200 en errores)
2473
+ url: "https://ipwho.is/",
2474
+ parse: (d) => d.success === false ? null : geoFrom(d.country_code, d.city)
2475
+ },
2476
+ {
2477
+ // https://ipinfo.io/json (country = código ISO)
2478
+ url: "https://ipinfo.io/json",
2479
+ parse: (d) => geoFrom(d.country, d.city)
2480
+ }
2481
+ ];
2482
+ function geoFrom(country, city) {
2483
+ const c = typeof country === "string" && country ? country : void 0;
2484
+ const t = typeof city === "string" && city ? city : void 0;
2485
+ if (!c && !t) return null;
2486
+ return { country: c, city: t };
2487
+ }
2488
+ async function fetchOne(provider, f, timeoutMs) {
2489
+ const controller = new AbortController();
2490
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2350
2491
  try {
2351
- const res = await f("https://ipapi.co/json/");
2492
+ const res = await f(provider.url, { signal: controller.signal });
2352
2493
  if (!res.ok) return null;
2353
2494
  const data = await res.json();
2354
- const country = data.country_code || void 0;
2355
- const city = data.city || void 0;
2356
- if (!country && !city) return null;
2357
- return { country, city };
2495
+ return provider.parse(data);
2358
2496
  } catch {
2359
2497
  return null;
2498
+ } finally {
2499
+ clearTimeout(timer);
2360
2500
  }
2361
2501
  }
2502
+ async function fetchGeo(options = {}) {
2503
+ const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
2504
+ if (!f) return null;
2505
+ const providers = options.providers ?? DEFAULT_GEO_PROVIDERS;
2506
+ const timeoutMs = options.timeoutMs ?? GEO_TIMEOUT_MS;
2507
+ for (const provider of providers) {
2508
+ const geo = await fetchOne(provider, f, timeoutMs);
2509
+ if (geo) return geo;
2510
+ }
2511
+ return null;
2512
+ }
2513
+
2514
+ // src/analytics/language.ts
2515
+ function clean(value) {
2516
+ const trimmed = value?.trim();
2517
+ return trimmed ? trimmed : void 0;
2518
+ }
2519
+ function resolveLanguage(sources) {
2520
+ const explicit = clean(sources.explicit);
2521
+ if (explicit) return explicit;
2522
+ const navLang = clean(sources.navigator?.language);
2523
+ if (navLang) return navLang;
2524
+ try {
2525
+ const locale = clean(sources.intl?.DateTimeFormat().resolvedOptions().locale);
2526
+ if (locale) return locale;
2527
+ } catch {
2528
+ }
2529
+ return void 0;
2530
+ }
2362
2531
 
2363
2532
  // src/analytics/engagement-tracker.ts
2364
2533
  var EngagementTracker = class {
@@ -2619,6 +2788,11 @@ var DeepdotsPopups = class {
2619
2788
  this.contact = null;
2620
2789
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
2621
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;
2622
2796
  }
2623
2797
  /** Initialize the SDK with configuration */
2624
2798
  init(config) {
@@ -2660,15 +2834,22 @@ var DeepdotsPopups = class {
2660
2834
  baseUrl: this.baseUrl,
2661
2835
  keys: config.analytics,
2662
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,
2663
2839
  onSessionId: (id) => {
2664
2840
  this.analyticsFeedbackSessionId = id;
2665
2841
  }
2666
2842
  }) : createDryRunSink((...a) => this.logger.log(...a));
2667
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
+ });
2668
2849
  this.analytics = new AnalyticsManager({
2669
2850
  sink: analyticsSink,
2670
2851
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
2671
- language: typeof navigator !== "undefined" ? navigator.language : void 0,
2852
+ language: this.language,
2672
2853
  platform: config.platform ?? "web",
2673
2854
  device,
2674
2855
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -2685,8 +2866,13 @@ var DeepdotsPopups = class {
2685
2866
  if (this.tracking?.isTrackingEnabled()) {
2686
2867
  this.crashReporter.install();
2687
2868
  }
2688
- collectGeoInfo().then((geo) => {
2689
- if (geo) this.analytics?.updateDevice(geo);
2869
+ const cachedGeo = readCachedGeo(storage, Date.now());
2870
+ if (cachedGeo) this.analytics.updateDevice(cachedGeo);
2871
+ fetchGeo().then((geo) => {
2872
+ if (geo) {
2873
+ this.analytics?.updateDevice(geo);
2874
+ writeCachedGeo(storage, geo, Date.now());
2875
+ }
2690
2876
  }).catch(() => {
2691
2877
  });
2692
2878
  this.navObserver = new NavigationObserver();
@@ -2743,6 +2929,15 @@ var DeepdotsPopups = class {
2743
2929
  if (!this.tracking?.isTrackingEnabled()) return;
2744
2930
  this.analytics?.setUserAttributes(attributes);
2745
2931
  }
2932
+ /**
2933
+ * Registra/actualiza una métrica (valor medible) → `feedback.metrics` del body.
2934
+ * Persistente y sobrescribe por key. No-op si tracking off.
2935
+ */
2936
+ setMetric(key, value) {
2937
+ if (!this.tracking?.isTrackingEnabled()) return;
2938
+ this.analytics?.setMetric(key, value);
2939
+ this.log("analytics \xB7 setMetric:", key, value);
2940
+ }
2746
2941
  /** Reporta un error del host (manejado o no) → evento `deepdots_app_crash`. No-op si tracking off. */
2747
2942
  reportError(error, options) {
2748
2943
  if (!this.tracking?.isTrackingEnabled()) return;
@@ -2830,10 +3025,14 @@ var DeepdotsPopups = class {
2830
3025
  events: []
2831
3026
  };
2832
3027
  }
2833
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
2834
- 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) {
2835
3034
  if (!this.tracking?.isTrackingEnabled()) return;
2836
- this.analytics?.flush(this.analyticsIdentity());
3035
+ this.analytics?.flush(this.analyticsIdentity(), options);
2837
3036
  }
2838
3037
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
2839
3038
  flushEngagement() {
@@ -2856,7 +3055,7 @@ var DeepdotsPopups = class {
2856
3055
  this.navObserver?.stop();
2857
3056
  if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
2858
3057
  this.flushEngagement();
2859
- this.flushAnalytics();
3058
+ this.flushAnalytics({ final: true });
2860
3059
  });
2861
3060
  document.addEventListener("visibilitychange", () => {
2862
3061
  if (document.visibilityState === "hidden") {
@@ -2977,14 +3176,16 @@ var DeepdotsPopups = class {
2977
3176
  matchesSegmentsLang(def) {
2978
3177
  const langs = def.segments?.lang;
2979
3178
  if (!langs || langs.length === 0) return true;
2980
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
2981
- if (!browserLang) {
2982
- 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 });
2983
3182
  return true;
2984
3183
  }
2985
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
3184
+ const matches = langs.some(
3185
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
3186
+ );
2986
3187
  if (!matches) {
2987
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
3188
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
2988
3189
  }
2989
3190
  return matches;
2990
3191
  }