@magicfeedback/popup-sdk 1.8.0 → 1.8.2

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/dist/index.d.mts CHANGED
@@ -95,7 +95,8 @@ interface AnalyticsEnvelope {
95
95
  interface AnalyticsFlushMeta {
96
96
  /**
97
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.
98
+ * un transporte que sobreviva al unload (`fetch` con `keepalive`) y no puede esperar
99
+ * respuestas.
99
100
  */
100
101
  final?: boolean;
101
102
  /**
@@ -174,9 +175,11 @@ interface FeedbackSinkOptions {
174
175
  /** fetch inyectable (tests / RN). */
175
176
  fetchImpl?: typeof fetch;
176
177
  /**
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`.
178
+ * @deprecated Se ignora. El flush final salía por `sendBeacon`, que va SIEMPRE con
179
+ * credenciales; contra la API (`Access-Control-Allow-Origin: *` + `Allow-Credentials:
180
+ * true`) el preflight fallaba y el lote de cierre, el único con `completed:true`, no
181
+ * llegaba nunca. Ahora sale por `fetch` con `keepalive`. Se mantiene en el tipo para no
182
+ * romper a quien lo pase.
180
183
  */
181
184
  sendBeaconImpl?: (url: string, body: Blob | string) => boolean;
182
185
  /** Callback invocado cuando el backend devuelve un nuevo sessionId (primer POST). */
@@ -194,8 +197,9 @@ interface FeedbackSinkOptions {
194
197
  * Garantías de entrega:
195
198
  * - mientras no se conozca el `sessionId`, los lotes se **serializan** (esperan la primera
196
199
  * 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;
200
+ * - `keepalive` para que el navegador no cancele el POST al navegar fuera ni al cerrar la
201
+ * página, también en el flush final (sin `sendBeacon`: ver `sendBeaconImpl`);
202
+ * - sin credenciales, que es lo único que pasa el CORS de la API (`Allow-Origin: *`);
199
203
  * - la promesa **rechaza** en fallo transitorio (red, 5xx, 408, 429) para que el
200
204
  * `AnalyticsManager` re-encole el lote; un 4xx se loguea y se descarta.
201
205
  */
@@ -801,12 +805,14 @@ declare class DeepdotsPopups {
801
805
  * se llama en init(), al volver a foreground y al conceder el consentimiento.
802
806
  */
803
807
  private openSession;
808
+ /** Web: si la pestaña se ve. Sin `document` (RN) manda AppState vía onForeground/onBackground. */
809
+ private isDocumentVisible;
804
810
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
805
811
  previewAnalytics(): AnalyticsEnvelope;
806
812
  /**
807
813
  * Envía el lote acumulado de analytics y vacía el buffer.
808
- * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
809
- * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
814
+ * `final: true` (cierre de página/app): el lote no espera al primer POST en vuelo, porque
815
+ * el documento se muere; un lote que falle por red o 5xx se re-encola para el siguiente flush.
810
816
  */
811
817
  flushAnalytics(options?: {
812
818
  final?: boolean;
@@ -817,6 +823,7 @@ declare class DeepdotsPopups {
817
823
  private analyticsIdentity;
818
824
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
819
825
  private setupAnalyticsFlush;
826
+ private startAnalyticsFlushTimer;
820
827
  /** Enable auto-launch functionality with configured triggers */
821
828
  autoLaunch(): void;
822
829
  /** Inicia los triggers configurados */
@@ -896,11 +903,18 @@ declare class DeepdotsPopups {
896
903
  private mapPopupTriggerType;
897
904
  private markSurveyProgress;
898
905
  /**
899
- * Rehidrata el estado de redisplay desde el storage. `answeredSurveys` se reconstruye
900
- * desde las entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
906
+ * Fusiona el estado de redisplay del storage con el de memoria: por cada popup/survey gana
907
+ * la entrada MÁS RECIENTE. Fusionar y no sustituir es lo que hace convivir a varias
908
+ * pestañas sobre el mismo `localStorage`: se llama en el `init()`, antes de cada decisión
909
+ * de mostrar y antes de cada escritura. `answeredSurveys` se reconstruye desde las
910
+ * entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
901
911
  */
902
912
  private loadPopupState;
903
- /** Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria. */
913
+ /**
914
+ * Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria.
915
+ * Fusiona antes de escribir: escribir solo la foto en memoria borraba lo que otra pestaña
916
+ * hubiese guardado desde nuestro `init()`.
917
+ */
904
918
  private persistPopupState;
905
919
  private isPopupDefinition;
906
920
  private isPopupTrigger;
package/dist/index.d.ts CHANGED
@@ -95,7 +95,8 @@ interface AnalyticsEnvelope {
95
95
  interface AnalyticsFlushMeta {
96
96
  /**
97
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.
98
+ * un transporte que sobreviva al unload (`fetch` con `keepalive`) y no puede esperar
99
+ * respuestas.
99
100
  */
100
101
  final?: boolean;
101
102
  /**
@@ -174,9 +175,11 @@ interface FeedbackSinkOptions {
174
175
  /** fetch inyectable (tests / RN). */
175
176
  fetchImpl?: typeof fetch;
176
177
  /**
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`.
178
+ * @deprecated Se ignora. El flush final salía por `sendBeacon`, que va SIEMPRE con
179
+ * credenciales; contra la API (`Access-Control-Allow-Origin: *` + `Allow-Credentials:
180
+ * true`) el preflight fallaba y el lote de cierre, el único con `completed:true`, no
181
+ * llegaba nunca. Ahora sale por `fetch` con `keepalive`. Se mantiene en el tipo para no
182
+ * romper a quien lo pase.
180
183
  */
181
184
  sendBeaconImpl?: (url: string, body: Blob | string) => boolean;
182
185
  /** Callback invocado cuando el backend devuelve un nuevo sessionId (primer POST). */
@@ -194,8 +197,9 @@ interface FeedbackSinkOptions {
194
197
  * Garantías de entrega:
195
198
  * - mientras no se conozca el `sessionId`, los lotes se **serializan** (esperan la primera
196
199
  * 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;
200
+ * - `keepalive` para que el navegador no cancele el POST al navegar fuera ni al cerrar la
201
+ * página, también en el flush final (sin `sendBeacon`: ver `sendBeaconImpl`);
202
+ * - sin credenciales, que es lo único que pasa el CORS de la API (`Allow-Origin: *`);
199
203
  * - la promesa **rechaza** en fallo transitorio (red, 5xx, 408, 429) para que el
200
204
  * `AnalyticsManager` re-encole el lote; un 4xx se loguea y se descarta.
201
205
  */
@@ -801,12 +805,14 @@ declare class DeepdotsPopups {
801
805
  * se llama en init(), al volver a foreground y al conceder el consentimiento.
802
806
  */
803
807
  private openSession;
808
+ /** Web: si la pestaña se ve. Sin `document` (RN) manda AppState vía onForeground/onBackground. */
809
+ private isDocumentVisible;
804
810
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
805
811
  previewAnalytics(): AnalyticsEnvelope;
806
812
  /**
807
813
  * Envía el lote acumulado de analytics y vacía el buffer.
808
- * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
809
- * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
814
+ * `final: true` (cierre de página/app): el lote no espera al primer POST en vuelo, porque
815
+ * el documento se muere; un lote que falle por red o 5xx se re-encola para el siguiente flush.
810
816
  */
811
817
  flushAnalytics(options?: {
812
818
  final?: boolean;
@@ -817,6 +823,7 @@ declare class DeepdotsPopups {
817
823
  private analyticsIdentity;
818
824
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
819
825
  private setupAnalyticsFlush;
826
+ private startAnalyticsFlushTimer;
820
827
  /** Enable auto-launch functionality with configured triggers */
821
828
  autoLaunch(): void;
822
829
  /** Inicia los triggers configurados */
@@ -896,11 +903,18 @@ declare class DeepdotsPopups {
896
903
  private mapPopupTriggerType;
897
904
  private markSurveyProgress;
898
905
  /**
899
- * Rehidrata el estado de redisplay desde el storage. `answeredSurveys` se reconstruye
900
- * desde las entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
906
+ * Fusiona el estado de redisplay del storage con el de memoria: por cada popup/survey gana
907
+ * la entrada MÁS RECIENTE. Fusionar y no sustituir es lo que hace convivir a varias
908
+ * pestañas sobre el mismo `localStorage`: se llama en el `init()`, antes de cada decisión
909
+ * de mostrar y antes de cada escritura. `answeredSurveys` se reconstruye desde las
910
+ * entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
901
911
  */
902
912
  private loadPopupState;
903
- /** Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria. */
913
+ /**
914
+ * Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria.
915
+ * Fusiona antes de escribir: escribir solo la foto en memoria borraba lo que otra pestaña
916
+ * hubiese guardado desde nuestro `init()`.
917
+ */
904
918
  private persistPopupState;
905
919
  private isPopupDefinition;
906
920
  private isPopupTrigger;
package/dist/index.js CHANGED
@@ -542,6 +542,29 @@ function ddApplySurveyPrimaryColor(el, style){
542
542
  }
543
543
  });
544
544
 
545
+ // src/ui/busy.ts
546
+ function setSurveyBusy(el, busy) {
547
+ if (!el) return;
548
+ el.style.pointerEvents = busy ? "none" : "";
549
+ el.setAttribute("aria-busy", busy ? "true" : "false");
550
+ if ("inert" in el) {
551
+ el.inert = busy;
552
+ }
553
+ }
554
+ var BUSY_JS;
555
+ var init_busy = __esm({
556
+ "src/ui/busy.ts"() {
557
+ "use strict";
558
+ BUSY_JS = `
559
+ function ddSetSurveyBusy(el, busy){
560
+ if(!el){ return; }
561
+ el.style.pointerEvents = busy ? 'none' : '';
562
+ el.setAttribute('aria-busy', busy ? 'true' : 'false');
563
+ if('inert' in el){ el.inert = busy; }
564
+ }`;
565
+ }
566
+ });
567
+
545
568
  // src/assets/style.css
546
569
  var style_default;
547
570
  var init_style = __esm({
@@ -2431,9 +2454,9 @@ var init_logo = __esm({
2431
2454
  }
2432
2455
  });
2433
2456
 
2434
- // node_modules/@magicfeedback/native/dist/magicfeedback-sdk.browser.js
2457
+ // ../../../../../../../Users/sarias/developer/Deepdots/deepdots-popup-sdk/node_modules/@magicfeedback/native/dist/magicfeedback-sdk.browser.js
2435
2458
  var require_magicfeedback_sdk_browser = __commonJS({
2436
- "node_modules/@magicfeedback/native/dist/magicfeedback-sdk.browser.js"(exports, module2) {
2459
+ "../../../../../../../Users/sarias/developer/Deepdots/deepdots-popup-sdk/node_modules/@magicfeedback/native/dist/magicfeedback-sdk.browser.js"(exports, module2) {
2437
2460
  "use strict";
2438
2461
  !(function(e, t) {
2439
2462
  "object" == typeof exports && "object" == typeof module2 ? module2.exports = t() : "function" == typeof define && define.amd ? define([], t) : "object" == typeof exports ? exports.magicfeedback = t() : e.magicfeedback = t();
@@ -5381,6 +5404,7 @@ async function renderPopup(container, surveyId, productId, actions, emit, onClos
5381
5404
  revealTimer = setTimeout(reveal, REVEAL_TIMEOUT_MS);
5382
5405
  function setLoading(isLoading) {
5383
5406
  spinnerEl.style.display = isLoading ? "flex" : "none";
5407
+ setSurveyBusy(formWrapper, isLoading);
5384
5408
  if (!isLoading) {
5385
5409
  formHost.style.visibility = "visible";
5386
5410
  revealWhenPainted2();
@@ -5536,6 +5560,7 @@ var init_renderPopup = __esm({
5536
5560
  init_font();
5537
5561
  init_logo();
5538
5562
  init_surveyPalette();
5563
+ init_busy();
5539
5564
  init_logger();
5540
5565
  init_labels();
5541
5566
  init_reveal();
@@ -5778,6 +5803,7 @@ init_font();
5778
5803
  init_labels();
5779
5804
  init_reveal();
5780
5805
  init_surveyPalette();
5806
+ init_busy();
5781
5807
  init_style();
5782
5808
  var REVEAL_JS = `
5783
5809
  function ddCreateReveal(popup, onReveal, timeoutMs){
@@ -5997,6 +6023,7 @@ ${customCss}
5997
6023
  var footer=document.getElementById('dd-footer');
5998
6024
  var spinner=document.getElementById('dd-spinner');
5999
6025
  var formHost=document.getElementById('mf');
6026
+ var formWrapper=document.getElementById('dd-form-wrapper');
6000
6027
  var errorHint=document.getElementById('dd-error');
6001
6028
  var backBtn=document.getElementById('dd-back');
6002
6029
  var startBtn=document.getElementById('dd-start');
@@ -6156,6 +6183,7 @@ ${customCss}
6156
6183
  }
6157
6184
 
6158
6185
  ${PRIMARY_COLOR_JS}
6186
+ ${BUSY_JS}
6159
6187
  ${REVEAL_JS}
6160
6188
  // El popup se ense\xF1a cuando el survey est\xE1 pintado; \`ready\` deja al host abrir su propio
6161
6189
  // Modal en ese momento en vez de montar un WebView en blanco.
@@ -6166,6 +6194,11 @@ ${REVEAL_JS}
6166
6194
 
6167
6195
  function setLoading(isLoading){
6168
6196
  spinner.style.display=isLoading?'flex':'none';
6197
+ // El spinner no tapa el survey (28px en posicion absoluta), asi que sin esto el usuario
6198
+ // puede seguir cambiando de opcion mientras se envia la pagina, y ese cambio ya no viaja
6199
+ // con ella. Se bloquea el wrapper, que es el elemento estable: el div de dentro lo
6200
+ // sustituye @magicfeedback/native por su propio contenedor al generar el survey.
6201
+ ddSetSurveyBusy(formWrapper, isLoading);
6169
6202
  if(!isLoading){
6170
6203
  formHost.style.visibility='visible';
6171
6204
  // Fin de la carga, sea por survey cargado o por error. Entre p\xE1ginas es no-op: la
@@ -6771,14 +6804,7 @@ function createFeedbackSink(options) {
6771
6804
  const post = async (body, final, sessionEnd = false) => {
6772
6805
  const json = JSON.stringify(body);
6773
6806
  const small = json.length <= KEEPALIVE_MAX_BYTES;
6774
- if (final && options.sendBeaconImpl && small) {
6775
- const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
6776
- if (options.sendBeaconImpl(url, payload)) {
6777
- options.log?.("[DeepdotsAnalytics] final flush via sendBeacon");
6778
- return;
6779
- }
6780
- options.log?.("[DeepdotsAnalytics] sendBeacon rejected the batch; falling back to fetch");
6781
- }
6807
+ if (final) options.log?.("[DeepdotsAnalytics] final flush via fetch keepalive");
6782
6808
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
6783
6809
  if (!f) {
6784
6810
  options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
@@ -6788,6 +6814,9 @@ function createFeedbackSink(options) {
6788
6814
  method: "POST",
6789
6815
  headers: { "Content-Type": "application/json" },
6790
6816
  body: json,
6817
+ // La API autentica por `publicKey` en el body y responde `Allow-Origin: *`: una
6818
+ // petición con credenciales fallaría el CORS. Explícito para no depender del default.
6819
+ credentials: "omit",
6791
6820
  // keepalive: el POST sobrevive a la navegación mientras el body sea pequeño.
6792
6821
  ...small ? { keepalive: true } : {}
6793
6822
  });
@@ -6880,6 +6909,15 @@ var NavigationObserver = class {
6880
6909
  this.closeCurrent();
6881
6910
  this.currentScreen = null;
6882
6911
  }
6912
+ /**
6913
+ * Retoma el seguimiento en la pantalla actual tras un `stop()`, cuando se abre una sesión
6914
+ * nueva sin navegar (cambio de usuario, vuelta de la bfcache). No-op si nunca se instaló
6915
+ * (navegación manual por `setScreen`, RN) o si ya hay pantalla en curso.
6916
+ */
6917
+ restart() {
6918
+ if (!this.installed || this.currentScreen != null || typeof window === "undefined") return;
6919
+ this.begin(window.location.href);
6920
+ }
6883
6921
  closeCurrent() {
6884
6922
  if (this.currentScreen == null) return;
6885
6923
  const exit = this.now();
@@ -7523,8 +7561,6 @@ var DeepdotsPopups = class {
7523
7561
  baseUrl: this.baseUrl,
7524
7562
  keys: config.analytics,
7525
7563
  log: (...a) => this.log(...a),
7526
- // sendBeacon: único transporte que sobrevive al cierre de la página.
7527
- sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
7528
7564
  onSessionId: (id) => {
7529
7565
  this.analyticsFeedbackSessionId = id;
7530
7566
  },
@@ -7572,7 +7608,6 @@ var DeepdotsPopups = class {
7572
7608
  this.navObserver.onVisit((v) => this.track("deepdots_page_view", { screen: v.screen, duration_seconds: v.durationSeconds }));
7573
7609
  this.navObserver.install();
7574
7610
  this.engagement = new EngagementTracker();
7575
- this.engagement.resume();
7576
7611
  this.setupAnalyticsFlush();
7577
7612
  this.openSession();
7578
7613
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
@@ -7791,6 +7826,7 @@ var DeepdotsPopups = class {
7791
7826
  this.navStarted = false;
7792
7827
  this.analytics?.exitAllMiniServices();
7793
7828
  this.flushEngagement();
7829
+ this.engagement?.pause();
7794
7830
  this.track("deepdots_session_end", { reason });
7795
7831
  this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
7796
7832
  this.analyticsFeedbackSessionId = void 0;
@@ -7806,6 +7842,12 @@ var DeepdotsPopups = class {
7806
7842
  if (!this.tracking?.isTrackingEnabled()) return;
7807
7843
  this.sessionOpen = true;
7808
7844
  this.track("deepdots_session_start", {});
7845
+ this.navObserver?.restart();
7846
+ if (this.isDocumentVisible()) this.engagement?.resume();
7847
+ }
7848
+ /** Web: si la pestaña se ve. Sin `document` (RN) manda AppState vía onForeground/onBackground. */
7849
+ isDocumentVisible() {
7850
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
7809
7851
  }
7810
7852
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
7811
7853
  previewAnalytics() {
@@ -7818,8 +7860,8 @@ var DeepdotsPopups = class {
7818
7860
  }
7819
7861
  /**
7820
7862
  * Envía el lote acumulado de analytics y vacía el buffer.
7821
- * `final: true` (cierre de página/app) cambia el transporte a `sendBeacon`, que sobrevive
7822
- * al unload; un lote que falle por red o 5xx se re-encola para el siguiente flush.
7863
+ * `final: true` (cierre de página/app): el lote no espera al primer POST en vuelo, porque
7864
+ * el documento se muere; un lote que falle por red o 5xx se re-encola para el siguiente flush.
7823
7865
  */
7824
7866
  flushAnalytics(options) {
7825
7867
  if (!this.tracking?.isTrackingEnabled()) return;
@@ -7840,21 +7882,30 @@ var DeepdotsPopups = class {
7840
7882
  /** Flush automático al ocultar/cerrar la página (no perder el lote pendiente). */
7841
7883
  setupAnalyticsFlush() {
7842
7884
  if (typeof document === "undefined" || typeof window === "undefined") return;
7843
- this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
7885
+ this.startAnalyticsFlushTimer();
7844
7886
  window.addEventListener("pagehide", () => {
7845
7887
  clearInterval(this.analyticsFlushTimer);
7846
7888
  this.closeSession("page_hide");
7847
7889
  });
7890
+ window.addEventListener("pageshow", (event) => {
7891
+ if (!event.persisted) return;
7892
+ this.startAnalyticsFlushTimer();
7893
+ this.openSession();
7894
+ });
7848
7895
  document.addEventListener("visibilitychange", () => {
7849
7896
  if (document.visibilityState === "hidden") {
7850
7897
  this.flushEngagement();
7851
7898
  this.engagement?.pause();
7852
7899
  this.flushAnalytics();
7853
7900
  } else if (document.visibilityState === "visible") {
7854
- this.engagement?.resume();
7901
+ if (this.sessionOpen) this.engagement?.resume();
7855
7902
  }
7856
7903
  });
7857
7904
  }
7905
+ startAnalyticsFlushTimer() {
7906
+ clearInterval(this.analyticsFlushTimer);
7907
+ this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
7908
+ }
7858
7909
  /** Enable auto-launch functionality with configured triggers */
7859
7910
  autoLaunch() {
7860
7911
  if (!this.initialized) {
@@ -7948,6 +7999,7 @@ var DeepdotsPopups = class {
7948
7999
  this.emitEvent("popup_shown", def.surveyId, { popupId: def.id });
7949
8000
  }
7950
8001
  shouldShow(def, pathUrl, skipPathCheck = false) {
8002
+ if (this.storage) this.loadPopupState(this.storage);
7951
8003
  if (this.isPathExcluded(def, pathUrl)) {
7952
8004
  return false;
7953
8005
  }
@@ -8474,22 +8526,32 @@ var DeepdotsPopups = class {
8474
8526
  this.persistPopupState();
8475
8527
  }
8476
8528
  /**
8477
- * Rehidrata el estado de redisplay desde el storage. `answeredSurveys` se reconstruye
8478
- * desde las entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
8529
+ * Fusiona el estado de redisplay del storage con el de memoria: por cada popup/survey gana
8530
+ * la entrada MÁS RECIENTE. Fusionar y no sustituir es lo que hace convivir a varias
8531
+ * pestañas sobre el mismo `localStorage`: se llama en el `init()`, antes de cada decisión
8532
+ * de mostrar y antes de cada escritura. `answeredSurveys` se reconstruye desde las
8533
+ * entradas COMPLETED: es exactamente lo que persiste `markSurveyAnswered`.
8479
8534
  */
8480
8535
  loadPopupState(storage) {
8481
8536
  const state = readPopupState(storage);
8482
8537
  Object.entries(state.lastShown).forEach(([popupId, timestamp]) => {
8483
- this.lastShown.set(popupId, timestamp);
8538
+ if (timestamp > (this.lastShown.get(popupId) ?? -Infinity)) this.lastShown.set(popupId, timestamp);
8484
8539
  });
8485
8540
  Object.entries(state.progress).forEach(([surveyId, entry]) => {
8541
+ const current = this.surveyProgress.get(surveyId);
8542
+ if (current && current.timestamp >= entry.timestamp) return;
8486
8543
  this.surveyProgress.set(surveyId, { status: entry.status, timestamp: entry.timestamp });
8487
8544
  if (entry.status === "COMPLETED") this.answeredSurveys.add(surveyId);
8488
8545
  });
8489
8546
  }
8490
- /** Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria. */
8547
+ /**
8548
+ * Vuelca el estado de redisplay al storage. Sin storage (o bloqueado) degrada a memoria.
8549
+ * Fusiona antes de escribir: escribir solo la foto en memoria borraba lo que otra pestaña
8550
+ * hubiese guardado desde nuestro `init()`.
8551
+ */
8491
8552
  persistPopupState() {
8492
8553
  if (!this.storage) return;
8554
+ this.loadPopupState(this.storage);
8493
8555
  writePopupState(this.storage, {
8494
8556
  lastShown: Object.fromEntries(this.lastShown),
8495
8557
  progress: Object.fromEntries(this.surveyProgress)