@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.
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,7 +91,20 @@ 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
+ /**
103
+ * Envío del lote. Si devuelve una promesa que **rechaza**, el manager re-encola los
104
+ * eventos para reintentarlos en el siguiente flush (fallo transitorio: red o 5xx).
105
+ * Resolver = lote entregado o descartado definitivamente (no se reintenta).
106
+ */
107
+ type AnalyticsSink = (payload: AnalyticsEnvelope, meta?: AnalyticsFlushMeta) => void | Promise<void>;
95
108
 
96
109
  /**
97
110
  * Mapeo del envelope de analytics → body de `POST /sdk/feedback`.
@@ -143,15 +156,29 @@ interface FeedbackSinkOptions {
143
156
  log?: (...args: unknown[]) => void;
144
157
  /** fetch inyectable (tests / RN). */
145
158
  fetchImpl?: typeof fetch;
159
+ /**
160
+ * `navigator.sendBeacon` inyectable. Solo se usa en el flush final (cierre de página):
161
+ * sobrevive al unload, a cambio de no poder leer la respuesta. Si no se pasa, el flush
162
+ * final usa `fetch` con `keepalive`.
163
+ */
164
+ sendBeaconImpl?: (url: string, body: Blob | string) => boolean;
146
165
  /** Callback invocado cuando el backend devuelve un nuevo sessionId (primer POST). */
147
166
  onSessionId?: (id: string) => void;
148
167
  }
149
168
  /**
150
169
  * 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.
170
+ *
171
+ * Stateful: cachea el `sessionId` que devuelve el backend en la primera respuesta y lo
172
+ * envía en los siguientes POSTs para que el backend agrupe todos los eventos en un único
173
+ * registro de feedback.
174
+ *
175
+ * Garantías de entrega:
176
+ * - mientras no se conozca el `sessionId`, los lotes se **serializan** (esperan la primera
177
+ * respuesta): dos POSTs a la vez sin `sessionId` crearían dos registros y partirían los datos;
178
+ * - `keepalive` para que el navegador no cancele el POST al navegar fuera, y `sendBeacon`
179
+ * en el flush final (cierre de página), donde `fetch` puede morir con el documento;
180
+ * - la promesa **rechaza** en fallo transitorio (red, 5xx, 408, 429) para que el
181
+ * `AnalyticsManager` re-encole el lote; un 4xx se loguea y se descarta.
155
182
  */
156
183
  declare function createFeedbackSink(options: FeedbackSinkOptions): AnalyticsSink;
157
184
 
@@ -190,9 +217,11 @@ interface DeepdotsInitParams {
190
217
  /** Platform used in the analytics envelope. Default is 'web'; in RN pass 'android'/'ios'. */
191
218
  platform?: 'web' | 'android' | 'ios';
192
219
  /**
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).
220
+ * BCP-47 language tag (e.g. 'es-ES') used for the analytics context AND for popup
221
+ * language segmentation (`segments.lang`). If omitted, the SDK auto-detects:
222
+ * `navigator.language` on web, or the `Intl` locale as a fallback (works in
223
+ * React Native with Hermes, where `navigator.language` is absent). If no source
224
+ * resolves a language, popups with `segments.lang` cannot be filtered and are shown.
196
225
  */
197
226
  language?: string;
198
227
  /** Injectable device info (RN: from react-native-device-info). If missing, it is derived from the browser. */
@@ -527,6 +556,11 @@ declare class DeepdotsPopups {
527
556
  private contact;
528
557
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
529
558
  private navStarted;
559
+ /**
560
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
561
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
562
+ */
563
+ private language;
530
564
  /** Initialize the SDK with configuration */
531
565
  init(config: DeepdotsInitParams): void;
532
566
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -581,8 +615,14 @@ declare class DeepdotsPopups {
581
615
  onBackground(): void;
582
616
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
583
617
  previewAnalytics(): AnalyticsEnvelope;
584
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
585
- flushAnalytics(): void;
618
+ /**
619
+ * Envía el lote acumulado de analytics y vacía el buffer.
620
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
621
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
622
+ */
623
+ flushAnalytics(options?: {
624
+ final?: boolean;
625
+ }): void;
586
626
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
587
627
  private flushEngagement;
588
628
  private analyticsIdentity;
package/dist/index.d.ts CHANGED
@@ -91,7 +91,20 @@ 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
+ /**
103
+ * Envío del lote. Si devuelve una promesa que **rechaza**, el manager re-encola los
104
+ * eventos para reintentarlos en el siguiente flush (fallo transitorio: red o 5xx).
105
+ * Resolver = lote entregado o descartado definitivamente (no se reintenta).
106
+ */
107
+ type AnalyticsSink = (payload: AnalyticsEnvelope, meta?: AnalyticsFlushMeta) => void | Promise<void>;
95
108
 
96
109
  /**
97
110
  * Mapeo del envelope de analytics → body de `POST /sdk/feedback`.
@@ -143,15 +156,29 @@ interface FeedbackSinkOptions {
143
156
  log?: (...args: unknown[]) => void;
144
157
  /** fetch inyectable (tests / RN). */
145
158
  fetchImpl?: typeof fetch;
159
+ /**
160
+ * `navigator.sendBeacon` inyectable. Solo se usa en el flush final (cierre de página):
161
+ * sobrevive al unload, a cambio de no poder leer la respuesta. Si no se pasa, el flush
162
+ * final usa `fetch` con `keepalive`.
163
+ */
164
+ sendBeaconImpl?: (url: string, body: Blob | string) => boolean;
146
165
  /** Callback invocado cuando el backend devuelve un nuevo sessionId (primer POST). */
147
166
  onSessionId?: (id: string) => void;
148
167
  }
149
168
  /**
150
169
  * 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.
170
+ *
171
+ * Stateful: cachea el `sessionId` que devuelve el backend en la primera respuesta y lo
172
+ * envía en los siguientes POSTs para que el backend agrupe todos los eventos en un único
173
+ * registro de feedback.
174
+ *
175
+ * Garantías de entrega:
176
+ * - mientras no se conozca el `sessionId`, los lotes se **serializan** (esperan la primera
177
+ * respuesta): dos POSTs a la vez sin `sessionId` crearían dos registros y partirían los datos;
178
+ * - `keepalive` para que el navegador no cancele el POST al navegar fuera, y `sendBeacon`
179
+ * en el flush final (cierre de página), donde `fetch` puede morir con el documento;
180
+ * - la promesa **rechaza** en fallo transitorio (red, 5xx, 408, 429) para que el
181
+ * `AnalyticsManager` re-encole el lote; un 4xx se loguea y se descarta.
155
182
  */
156
183
  declare function createFeedbackSink(options: FeedbackSinkOptions): AnalyticsSink;
157
184
 
@@ -190,9 +217,11 @@ interface DeepdotsInitParams {
190
217
  /** Platform used in the analytics envelope. Default is 'web'; in RN pass 'android'/'ios'. */
191
218
  platform?: 'web' | 'android' | 'ios';
192
219
  /**
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).
220
+ * BCP-47 language tag (e.g. 'es-ES') used for the analytics context AND for popup
221
+ * language segmentation (`segments.lang`). If omitted, the SDK auto-detects:
222
+ * `navigator.language` on web, or the `Intl` locale as a fallback (works in
223
+ * React Native with Hermes, where `navigator.language` is absent). If no source
224
+ * resolves a language, popups with `segments.lang` cannot be filtered and are shown.
196
225
  */
197
226
  language?: string;
198
227
  /** Injectable device info (RN: from react-native-device-info). If missing, it is derived from the browser. */
@@ -527,6 +556,11 @@ declare class DeepdotsPopups {
527
556
  private contact;
528
557
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
529
558
  private navStarted;
559
+ /**
560
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
561
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
562
+ */
563
+ private language;
530
564
  /** Initialize the SDK with configuration */
531
565
  init(config: DeepdotsInitParams): void;
532
566
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -581,8 +615,14 @@ declare class DeepdotsPopups {
581
615
  onBackground(): void;
582
616
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
583
617
  previewAnalytics(): AnalyticsEnvelope;
584
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
585
- flushAnalytics(): void;
618
+ /**
619
+ * Envía el lote acumulado de analytics y vacía el buffer.
620
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
621
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
622
+ */
623
+ flushAnalytics(options?: {
624
+ final?: boolean;
625
+ }): void;
586
626
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
587
627
  private flushEngagement;
588
628
  private analyticsIdentity;
package/dist/index.js CHANGED
@@ -3968,6 +3968,7 @@ var AnalyticsManager = class {
3968
3968
  this.device = options.device;
3969
3969
  this.platform = options.platform ?? "web";
3970
3970
  this.maxBatchSize = options.maxBatchSize ?? 20;
3971
+ this.maxBufferedEvents = options.maxBufferedEvents ?? 200;
3971
3972
  this.onFlushNeeded = options.onFlushNeeded;
3972
3973
  }
3973
3974
  /** Actualiza campos de device en runtime (p.ej. country/city tras resolver geo). */
@@ -4028,6 +4029,7 @@ var AnalyticsManager = class {
4028
4029
  timestamp: this.now(),
4029
4030
  params: Object.keys(merged).length ? merged : void 0
4030
4031
  });
4032
+ this.enforceBufferCap();
4031
4033
  if (this.events.length >= this.maxBatchSize) {
4032
4034
  this.onFlushNeeded?.();
4033
4035
  }
@@ -4058,14 +4060,43 @@ var AnalyticsManager = class {
4058
4060
  events: [...this.events]
4059
4061
  };
4060
4062
  }
4061
- /** Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos. */
4062
- flush(identity) {
4063
+ /**
4064
+ * Envía (vía sink) el lote acumulado y vacía el buffer. No-op si no hay eventos.
4065
+ *
4066
+ * Si el sink falla de forma transitoria (promesa rechazada o excepción síncrona) el lote
4067
+ * se **re-encola** al principio del buffer y se reintenta en el siguiente flush: un error
4068
+ * de red o un 5xx no debe hacer desaparecer los eventos.
4069
+ */
4070
+ flush(identity, meta) {
4063
4071
  if (this.events.length === 0) return null;
4072
+ const batch = this.events;
4064
4073
  const payload = this.buildPayload(identity);
4065
- this.sink(payload);
4066
4074
  this.events = [];
4075
+ let result;
4076
+ try {
4077
+ result = meta ? this.sink(payload, meta) : this.sink(payload);
4078
+ } catch {
4079
+ this.requeue(batch);
4080
+ return payload;
4081
+ }
4082
+ if (result && typeof result.then === "function") {
4083
+ result.catch(() => this.requeue(batch));
4084
+ }
4067
4085
  return payload;
4068
4086
  }
4087
+ /**
4088
+ * Devuelve un lote fallido al buffer, delante de lo que haya llegado mientras se enviaba
4089
+ * (orden cronológico). Con el buffer lleno se descartan los eventos más antiguos.
4090
+ */
4091
+ requeue(batch) {
4092
+ this.events = [...batch, ...this.events];
4093
+ this.enforceBufferCap();
4094
+ }
4095
+ /** Techo del buffer: si el envío lleva rato fallando, se sacrifica lo más antiguo. */
4096
+ enforceBufferCap() {
4097
+ const overflow = this.events.length - this.maxBufferedEvents;
4098
+ if (overflow > 0) this.events.splice(0, overflow);
4099
+ }
4069
4100
  };
4070
4101
 
4071
4102
  // src/analytics/feedback-payload.ts
@@ -4128,33 +4159,80 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
4128
4159
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
4129
4160
  };
4130
4161
  }
4162
+ var KEEPALIVE_MAX_BYTES = 6e4;
4163
+ function isRetryableStatus(status) {
4164
+ return status >= 500 || status === 408 || status === 429;
4165
+ }
4166
+ async function safeBodyText(res) {
4167
+ try {
4168
+ return (await res.text()).slice(0, 500);
4169
+ } catch {
4170
+ return "";
4171
+ }
4172
+ }
4131
4173
  function createFeedbackSink(options) {
4132
4174
  let feedbackSessionId;
4133
- return (envelope) => {
4134
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
4175
+ let firstPostInFlight = null;
4176
+ const url = `${options.baseUrl}/sdk/feedback`;
4177
+ const post = async (body, final) => {
4178
+ const json = JSON.stringify(body);
4179
+ const small = json.length <= KEEPALIVE_MAX_BYTES;
4180
+ if (final && options.sendBeaconImpl && small) {
4181
+ const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
4182
+ if (options.sendBeaconImpl(url, payload)) {
4183
+ options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
4184
+ return;
4185
+ }
4186
+ options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
4187
+ }
4135
4188
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
4136
4189
  if (!f) {
4137
4190
  options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
4138
4191
  return;
4139
4192
  }
4140
- options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
4141
- f(`${options.baseUrl}/sdk/feedback`, {
4193
+ const res = await f(url, {
4142
4194
  method: "POST",
4143
4195
  headers: { "Content-Type": "application/json" },
4144
- body: JSON.stringify(body)
4145
- }).then(async (res) => {
4146
- if (res.ok) {
4147
- try {
4148
- const data = await res.json();
4149
- if (data?.sessionId && data.sessionId !== feedbackSessionId) {
4150
- feedbackSessionId = data.sessionId;
4151
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
4152
- options.onSessionId?.(feedbackSessionId);
4153
- }
4154
- } catch {
4155
- }
4196
+ body: json,
4197
+ // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
4198
+ ...small ? { keepalive: true } : {}
4199
+ });
4200
+ if (!res.ok) {
4201
+ const detail = await safeBodyText(res);
4202
+ if (isRetryableStatus(res.status)) {
4203
+ throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
4204
+ }
4205
+ options.log?.(
4206
+ `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
4207
+ detail
4208
+ );
4209
+ return;
4210
+ }
4211
+ try {
4212
+ const data = await res.json();
4213
+ if (data?.sessionId && data.sessionId !== feedbackSessionId) {
4214
+ feedbackSessionId = data.sessionId;
4215
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
4216
+ options.onSessionId?.(feedbackSessionId);
4156
4217
  }
4157
- }).catch((err) => options.log?.("[DeepdotsAnalytics] error enviando feedback", err));
4218
+ } catch {
4219
+ }
4220
+ };
4221
+ return async (envelope, meta) => {
4222
+ const final = meta?.final === true;
4223
+ if (!feedbackSessionId && firstPostInFlight && !final) {
4224
+ await firstPostInFlight;
4225
+ }
4226
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
4227
+ options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
4228
+ const sent = post(body, final);
4229
+ if (!feedbackSessionId) {
4230
+ firstPostInFlight = sent.then(
4231
+ () => void 0,
4232
+ () => void 0
4233
+ );
4234
+ }
4235
+ await sent;
4158
4236
  };
4159
4237
  }
4160
4238
 
@@ -4648,6 +4726,11 @@ var DeepdotsPopups = class {
4648
4726
  this.contact = null;
4649
4727
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
4650
4728
  this.navStarted = false;
4729
+ /**
4730
+ * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
4731
+ * verdad para el context de analytics Y para la segmentación por idioma de los popups.
4732
+ */
4733
+ this.language = void 0;
4651
4734
  }
4652
4735
  /** Initialize the SDK with configuration */
4653
4736
  init(config) {
@@ -4689,19 +4772,22 @@ var DeepdotsPopups = class {
4689
4772
  baseUrl: this.baseUrl,
4690
4773
  keys: config.analytics,
4691
4774
  log: (...a) => this.log(...a),
4775
+ // sendBeacon: único transporte que sobrevive al cierre de la página.
4776
+ sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
4692
4777
  onSessionId: (id) => {
4693
4778
  this.analyticsFeedbackSessionId = id;
4694
4779
  }
4695
4780
  }) : createDryRunSink((...a) => this.logger.log(...a));
4696
4781
  const device = config.device ?? collectDeviceInfo(config.appVersion);
4782
+ this.language = resolveLanguage({
4783
+ explicit: config.language,
4784
+ navigator: typeof navigator !== "undefined" ? navigator : void 0,
4785
+ intl: typeof Intl !== "undefined" ? Intl : void 0
4786
+ });
4697
4787
  this.analytics = new AnalyticsManager({
4698
4788
  sink: analyticsSink,
4699
4789
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
4700
- language: resolveLanguage({
4701
- explicit: config.language,
4702
- navigator: typeof navigator !== "undefined" ? navigator : void 0,
4703
- intl: typeof Intl !== "undefined" ? Intl : void 0
4704
- }),
4790
+ language: this.language,
4705
4791
  platform: config.platform ?? "web",
4706
4792
  device,
4707
4793
  maxBatchSize: ANALYTICS_MAX_BATCH_SIZE,
@@ -4877,10 +4963,14 @@ var DeepdotsPopups = class {
4877
4963
  events: []
4878
4964
  };
4879
4965
  }
4880
- /** Envía (hoy dry-run → console.log) el lote acumulado de analytics y vacía el buffer. */
4881
- flushAnalytics() {
4966
+ /**
4967
+ * Envía el lote acumulado de analytics y vacía el buffer.
4968
+ * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
4969
+ * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
4970
+ */
4971
+ flushAnalytics(options) {
4882
4972
  if (!this.tracking?.isTrackingEnabled()) return;
4883
- this.analytics?.flush(this.analyticsIdentity());
4973
+ this.analytics?.flush(this.analyticsIdentity(), options);
4884
4974
  }
4885
4975
  /** Emite un evento `user_engagement` con el tiempo activo acumulado (#8). */
4886
4976
  flushEngagement() {
@@ -4903,7 +4993,7 @@ var DeepdotsPopups = class {
4903
4993
  this.navObserver?.stop();
4904
4994
  if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
4905
4995
  this.flushEngagement();
4906
- this.flushAnalytics();
4996
+ this.flushAnalytics({ final: true });
4907
4997
  });
4908
4998
  document.addEventListener("visibilitychange", () => {
4909
4999
  if (document.visibilityState === "hidden") {
@@ -5024,14 +5114,16 @@ var DeepdotsPopups = class {
5024
5114
  matchesSegmentsLang(def) {
5025
5115
  const langs = def.segments?.lang;
5026
5116
  if (!langs || langs.length === 0) return true;
5027
- const browserLang = (typeof navigator !== "undefined" ? navigator.language : "").toLowerCase();
5028
- if (!browserLang) {
5029
- this.debug("No navigator.language available for lang comparison", { popupId: def.id, langs });
5117
+ const currentLang = (this.language ?? "").toLowerCase();
5118
+ if (!currentLang) {
5119
+ this.debug("No language available for lang comparison", { popupId: def.id, langs });
5030
5120
  return true;
5031
5121
  }
5032
- const matches = langs.some((lang) => browserLang.startsWith(lang.toLowerCase()));
5122
+ const matches = langs.some(
5123
+ (lang) => typeof lang === "string" && currentLang.startsWith(lang.toLowerCase())
5124
+ );
5033
5125
  if (!matches) {
5034
- this.debug("Lang mismatch for popup", { popupId: def.id, langs, browserLang });
5126
+ this.debug("Lang mismatch for popup", { popupId: def.id, langs, currentLang });
5035
5127
  }
5036
5128
  return matches;
5037
5129
  }