@magicfeedback/popup-sdk 1.10.0 → 1.11.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.
@@ -594,11 +594,25 @@ ${customCss}
594
594
  ${PRIMARY_COLOR_JS}
595
595
  ${BUSY_JS}
596
596
  ${REVEAL_JS}
597
+ // Foco en la primera pregunta si es de escribir. Al navegar lo pone @magicfeedback/native
598
+ // (autofocus:'navigation'); la apertura la decide el popup, que es quien sabe cu\xE1ndo se revela.
599
+ // Necesita el popup revelado y el survey cargado, que llegan en cualquier orden, y solo con
600
+ // rat\xF3n o trackpad: en t\xE1ctil sacar\xEDa el teclado sin que el usuario haya tocado nada.
601
+ var ddRevealed=false, ddSurveyLoaded=false, ddOpenFocusDone=false;
602
+ function ddAutofocusOnOpen(){
603
+ if(ddOpenFocusDone || !ddRevealed || !ddSurveyLoaded){ return; }
604
+ ddOpenFocusDone=true;
605
+ var fine=typeof window.matchMedia==='function' && window.matchMedia('(pointer: fine)').matches;
606
+ var f=window.DeepdotsForm;
607
+ if(fine && f && typeof f.focusFirstQuestion==='function'){ f.focusFirstQuestion(); }
608
+ }
597
609
  // El popup se ense\xF1a cuando el survey est\xE1 pintado; \`ready\` deja al host abrir su propio
598
610
  // Modal en ese momento en vez de montar un WebView en blanco.
599
611
  var ddReveal=ddCreateReveal(popup, function(){
600
612
  document.body.classList.add('dd-ready');
601
613
  emitJSON('ready');
614
+ ddRevealed=true;
615
+ ddAutofocusOnOpen();
602
616
  }, ${REVEAL_TIMEOUT_MS});
603
617
 
604
618
  function setLoading(isLoading){
@@ -639,6 +653,9 @@ ${REVEAL_JS}
639
653
  // Surveys multi-idioma: native pide a la API este idioma (el del init o el del
640
654
  // dispositivo); si el survey no lo tiene, la API sirve el idioma por defecto.
641
655
  lang:${initLangJson}||undefined,
656
+ // Foco en la primera pregunta de escribir tras Start, Siguiente o Atr\xE1s. Las versiones
657
+ // de native sin esta opci\xF3n la ignoran.
658
+ autofocus:'navigation',
642
659
  onLoadedEvent:function(args){
643
660
  // Idioma en el que native muestra el survey: manda sobre el del init para que el
644
661
  // chrome no se quede en ingl\xE9s delante de un survey traducido. Native < 2.3 no lo
@@ -698,6 +715,8 @@ ${REVEAL_JS}
698
715
  updateProgress({ progress:form.progress, total:form.total, completed:false, followup:false });
699
716
  emitJSON('loaded');
700
717
  setLoading(false);
718
+ // La primera carga es la apertura; las siguientes (tras Start) las enfoca native.
719
+ if(!ddSurveyLoaded){ ddSurveyLoaded=true; ddAutofocusOnOpen(); }
701
720
  },
702
721
  beforeSubmitEvent:function(){
703
722
  setLoading(true);
@@ -848,6 +867,7 @@ var ReactNativePopupRenderer = class {
848
867
  this.emitReady(surveyId);
849
868
  break;
850
869
  case "loaded":
870
+ break;
851
871
  case "before_submit":
852
872
  case "after_submit":
853
873
  case "back":
@@ -875,7 +895,7 @@ function createReactNativeRenderer() {
875
895
  }
876
896
 
877
897
  // src/ui/preload.ts
878
- var defaultLoader = () => import("./renderPopup-WLOFOCDR.mjs");
898
+ var defaultLoader = () => import("./renderPopup-JF6IB6HL.mjs");
879
899
  var pending = null;
880
900
  function preloadRenderPopup(loader = defaultLoader) {
881
901
  if (pending) return pending;
@@ -948,7 +968,7 @@ var BrowserPopupRenderer = class {
948
968
  if (!this.container) return;
949
969
  this.visible = true;
950
970
  const container = this.container;
951
- void import("./renderPopup-WLOFOCDR.mjs").then(({ renderPopup }) => {
971
+ void import("./renderPopup-JF6IB6HL.mjs").then(({ renderPopup }) => {
952
972
  renderPopup(container, surveyId, productId, actions, emit, onClose, env, userId, style, sessionId, miniService, analyticsFeedbackSessionId, options);
953
973
  });
954
974
  }
@@ -1223,7 +1243,7 @@ async function safeBodyText(res) {
1223
1243
  }
1224
1244
  }
1225
1245
  function createFeedbackSink(options) {
1226
- let feedbackSessionId;
1246
+ let feedbackSessionId = options.initialSessionId || void 0;
1227
1247
  let firstPostInFlight = null;
1228
1248
  let generation = 0;
1229
1249
  const url = `${options.baseUrl}/sdk/feedback`;
@@ -1316,6 +1336,50 @@ function createFeedbackSink(options) {
1316
1336
  };
1317
1337
  }
1318
1338
 
1339
+ // src/analytics/suspended-session.ts
1340
+ var SUSPENDED_SESSION_KEY = "deepdots.analytics.suspended";
1341
+ function getSessionStorage() {
1342
+ try {
1343
+ return typeof window !== "undefined" && window.sessionStorage ? window.sessionStorage : null;
1344
+ } catch {
1345
+ return null;
1346
+ }
1347
+ }
1348
+ function writeSuspendedSession(storage, session) {
1349
+ try {
1350
+ storage.setItem(SUSPENDED_SESSION_KEY, JSON.stringify(session));
1351
+ return true;
1352
+ } catch {
1353
+ return false;
1354
+ }
1355
+ }
1356
+ function clearSuspendedSession(storage) {
1357
+ try {
1358
+ storage.removeItem(SUSPENDED_SESSION_KEY);
1359
+ } catch {
1360
+ }
1361
+ }
1362
+ function takeSuspendedSession(storage, match, now, windowMs) {
1363
+ let raw = null;
1364
+ try {
1365
+ raw = storage.getItem(SUSPENDED_SESSION_KEY);
1366
+ } catch {
1367
+ return null;
1368
+ }
1369
+ if (!raw) return null;
1370
+ clearSuspendedSession(storage);
1371
+ try {
1372
+ const s = JSON.parse(raw);
1373
+ if (typeof s.sessionId !== "string" || !s.sessionId) return null;
1374
+ if (typeof s.at !== "number" || now - s.at < 0 || now - s.at > windowMs) return null;
1375
+ if (s.integration !== match.integration) return null;
1376
+ if ((s.userId ?? null) !== match.userId) return null;
1377
+ return { sessionId: s.sessionId, integration: s.integration, userId: s.userId ?? null, at: s.at };
1378
+ } catch {
1379
+ return null;
1380
+ }
1381
+ }
1382
+
1319
1383
  // src/tracking/navigation-observer.ts
1320
1384
  var UUID_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-/i;
1321
1385
  function screenPath(href) {
@@ -1910,8 +1974,10 @@ var ANALYTICS_HEARTBEAT_MS = 5 * 6e4;
1910
1974
  var ANALYTICS_SESSION_TIMEOUT_MS = 30 * 6e4;
1911
1975
  var ANALYTICS_SERVER_IDLE_MS = 60 * 6e4;
1912
1976
  var ANALYTICS_SESSION_DISCARD_MS = ANALYTICS_SERVER_IDLE_MS - 10 * 6e4;
1977
+ var ANALYTICS_RESUME_WINDOW_MS = 6e4;
1913
1978
  var RESERVED_EVENT_PREFIX = "deepdots_";
1914
1979
  var CUSTOM_EVENT_PREFIX = "deepdots_event_";
1980
+ var MAX_PENDING_TRIGGERS = 20;
1915
1981
  var DeepdotsPopups = class {
1916
1982
  constructor() {
1917
1983
  this.config = null;
@@ -1925,6 +1991,14 @@ var DeepdotsPopups = class {
1925
1991
  // deprecated: mantenido para compatibilidad interna
1926
1992
  this.popupDefinitions = [];
1927
1993
  this.popupsLoaded = false;
1994
+ /**
1995
+ * `true` mientras el `GET /sdk/{apiKey}/popups` de `init()` está en vuelo. En esa ventana los
1996
+ * disparos del host (`triggerEvent`, `triggerSurvey`) no tienen contra qué evaluarse, así que
1997
+ * se guardan en `pendingTriggers` y se reproducen al cargar. Click, scroll, time y exit no
1998
+ * hacen falta: son listeners que `autoLaunch` instala ya con las definiciones cargadas.
1999
+ */
2000
+ this.awaitingPopups = false;
2001
+ this.pendingTriggers = [];
1928
2002
  this.pendingAutoLaunch = false;
1929
2003
  // Estado que alimenta las reglas de redisplay. Copia de trabajo en memoria; la fuente
1930
2004
  // duradera es el storage (`popup-state-store`), rehidratada en `init()` — sin eso, una
@@ -1979,6 +2053,13 @@ var DeepdotsPopups = class {
1979
2053
  this.lastEngagementAt = 0;
1980
2054
  /** Cuándo se ocultó la pestaña por última vez (ms epoch); null si está visible. */
1981
2055
  this.hiddenAt = null;
2056
+ /**
2057
+ * Sesión suspendida por un `pagehide` (ms epoch y `deepdots_user_id`); null si no hay.
2058
+ * `openSession` la reanuda si sigue a tiempo (ver ANALYTICS_RESUME_WINDOW_MS).
2059
+ */
2060
+ this.suspended = null;
2061
+ /** Integración de analytics del init: la sesión suspendida solo la hereda la misma. */
2062
+ this.analyticsIntegration = void 0;
1982
2063
  /**
1983
2064
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
1984
2065
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -2038,10 +2119,17 @@ var DeepdotsPopups = class {
2038
2119
  }
2039
2120
  }
2040
2121
  this.log("tracking \xB7 user_id:", this.tracking.getUserId(), "\xB7 new_user:", this.tracking.isNewUser(), "\xB7 enabled:", this.tracking.isTrackingEnabled());
2122
+ this.analyticsIntegration = config.analytics?.integration;
2123
+ const resumable = this.readResumableSession();
2124
+ if (resumable) {
2125
+ this.analyticsFeedbackSessionId = resumable.sessionId;
2126
+ this.suspended = { at: resumable.at, userId: resumable.userId };
2127
+ }
2041
2128
  const analyticsSink = config.analytics ? createFeedbackSink({
2042
2129
  baseUrl: this.baseUrl,
2043
2130
  keys: config.analytics,
2044
2131
  log: (...a) => this.log(...a),
2132
+ initialSessionId: resumable?.sessionId,
2045
2133
  onSessionId: (id) => {
2046
2134
  this.analyticsFeedbackSessionId = id;
2047
2135
  this.notifyFeedbackSession(id, "open");
@@ -2091,6 +2179,9 @@ var DeepdotsPopups = class {
2091
2179
  this.engagement = new EngagementTracker();
2092
2180
  this.setupAnalyticsFlush();
2093
2181
  this.openSession();
2182
+ if (resumable && this.analyticsFeedbackSessionId === resumable.sessionId) {
2183
+ this.notifyFeedbackSession(resumable.sessionId, "open");
2184
+ }
2094
2185
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
2095
2186
  if (this.tracking?.isTrackingEnabled()) {
2096
2187
  for (const rec of pendingCrashes) {
@@ -2101,8 +2192,10 @@ var DeepdotsPopups = class {
2101
2192
  this.log("SDK initialized", this.config);
2102
2193
  if (this.renderer.init) this.renderer.init();
2103
2194
  this.setupPopupContainer();
2195
+ this.awaitingPopups = Boolean(this.config.apiKey && this.baseUrl);
2104
2196
  this.fetchPopupsFromServer().then((defs) => {
2105
- this.popupDefinitions = this.validatePopupDefinitions(defs);
2197
+ if (!defs) this.dropPendingTriggers();
2198
+ this.popupDefinitions = this.validatePopupDefinitions(defs ?? []);
2106
2199
  this.popupsLoaded = true;
2107
2200
  this.log("Popups loaded from API", this.popupDefinitions);
2108
2201
  if (this.popupDefinitions.length) this.renderer.preload?.();
@@ -2111,6 +2204,7 @@ var DeepdotsPopups = class {
2111
2204
  if (this.pendingAutoLaunch) {
2112
2205
  this.startTriggers();
2113
2206
  }
2207
+ this.replayPendingTriggers();
2114
2208
  });
2115
2209
  }
2116
2210
  /** User id actual (generado por el SDK o provisto por el cliente). Null si tracking off. */
@@ -2339,9 +2433,15 @@ var DeepdotsPopups = class {
2339
2433
  openSession() {
2340
2434
  if (this.sessionOpen) return;
2341
2435
  if (!this.tracking?.isTrackingEnabled()) return;
2436
+ const suspendedMs = this.takeSuspendedSession();
2342
2437
  this.sessionOpen = true;
2343
2438
  this.lastEngagementAt = Date.now();
2344
- this.track("deepdots_session_start", {});
2439
+ if (suspendedMs === null) {
2440
+ this.track("deepdots_session_start", {});
2441
+ } else {
2442
+ this.track("deepdots_session_resume", { suspended_ms: suspendedMs });
2443
+ this.log("tracking \xB7 session resumed after", suspendedMs, "ms:", this.analyticsFeedbackSessionId);
2444
+ }
2345
2445
  this.navObserver?.restart();
2346
2446
  if (this.isDocumentVisible()) this.engagement?.resume();
2347
2447
  }
@@ -2406,6 +2506,75 @@ var DeepdotsPopups = class {
2406
2506
  }
2407
2507
  this.openSession();
2408
2508
  }
2509
+ /**
2510
+ * `pagehide` en web: SUSPENDE la sesión en vez de cerrarla, porque el navegador lo lanza
2511
+ * igual al recargar que al cerrar la pestaña. Vuelca lo pendiente (page_view de la
2512
+ * pantalla, mini-services, engagement) con un `deepdots_session_suspend` y SIN
2513
+ * `completed`, y deja el registro en `sessionStorage` (ver `suspended-session.ts`):
2514
+ * - si la página vuelve a cargar en esta pestaña, `init()` sigue en el mismo registro;
2515
+ * - si la pestaña se cerró, el backend lo cierra a los pocos minutos sin otro lote.
2516
+ *
2517
+ * Devuelve false cuando no se puede suspender — aún no se conoce el registro (el primer
2518
+ * POST sigue en vuelo) o no hay `sessionStorage` — y entonces el caller cierra como antes.
2519
+ */
2520
+ suspendSession() {
2521
+ if (!this.sessionOpen || !this.tracking?.isTrackingEnabled()) return false;
2522
+ const sessionId = this.analyticsFeedbackSessionId;
2523
+ const integration = this.analyticsIntegration;
2524
+ const storage = getSessionStorage();
2525
+ if (!sessionId || !integration || !storage) return false;
2526
+ const at = Date.now();
2527
+ const userId = this.tracking.getUserId();
2528
+ if (!writeSuspendedSession(storage, { sessionId, integration, userId, at })) return false;
2529
+ this.sessionOpen = false;
2530
+ this.suspended = { at, userId };
2531
+ this.navObserver?.stop();
2532
+ this.navStarted = false;
2533
+ this.analytics?.exitAllMiniServices();
2534
+ this.flushEngagement();
2535
+ this.engagement?.pause();
2536
+ this.track("deepdots_session_suspend", { reason: "page_hide" });
2537
+ this.flushAnalytics({ final: true });
2538
+ this.log("tracking \xB7 session suspended:", sessionId);
2539
+ return true;
2540
+ }
2541
+ /**
2542
+ * Recoge la sesión que dejó suspendida la carga anterior de esta pestaña (ver
2543
+ * `suspendSession`). Solo con analytics configurado y el tracking activo: sin ellos no hay
2544
+ * registro en el que seguir.
2545
+ */
2546
+ readResumableSession() {
2547
+ const integration = this.analyticsIntegration;
2548
+ const storage = getSessionStorage();
2549
+ if (!integration || !storage || !this.tracking?.isTrackingEnabled()) return null;
2550
+ return takeSuspendedSession(
2551
+ storage,
2552
+ { integration, userId: this.tracking.getUserId() },
2553
+ Date.now(),
2554
+ ANALYTICS_RESUME_WINDOW_MS
2555
+ );
2556
+ }
2557
+ /**
2558
+ * Consume la sesión suspendida al abrir: los ms que estuvo suspendida si se puede seguir en
2559
+ * ella (mismo usuario, dentro de ANALYTICS_RESUME_WINDOW_MS); null si no había o ya no toca.
2560
+ * En ese caso se olvida su registro sin enviar nada: lo cierra el backend, y la sesión que
2561
+ * se abre ahora va a uno nuevo.
2562
+ */
2563
+ takeSuspendedSession() {
2564
+ const suspended = this.suspended;
2565
+ if (!suspended) return null;
2566
+ this.suspended = null;
2567
+ const storage = getSessionStorage();
2568
+ if (storage) clearSuspendedSession(storage);
2569
+ const suspendedMs = Math.max(0, Date.now() - suspended.at);
2570
+ if (this.analyticsFeedbackSessionId && suspendedMs <= ANALYTICS_RESUME_WINDOW_MS && suspended.userId === (this.tracking?.getUserId() ?? null)) {
2571
+ return suspendedMs;
2572
+ }
2573
+ this.analytics?.discardSession(this.analyticsIdentity());
2574
+ this.analyticsFeedbackSessionId = void 0;
2575
+ this.tracking?.setSessionId(null);
2576
+ return null;
2577
+ }
2409
2578
  /** Olvida la sesión abierta sin enviar nada (ver onDocumentVisible). */
2410
2579
  discardSession() {
2411
2580
  this.sessionOpen = false;
@@ -2454,7 +2623,7 @@ var DeepdotsPopups = class {
2454
2623
  if (typeof document === "undefined" || typeof window === "undefined") return;
2455
2624
  window.addEventListener("pagehide", () => {
2456
2625
  clearInterval(this.analyticsFlushTimer);
2457
- this.closeSession("page_hide");
2626
+ if (!this.suspendSession()) this.closeSession("page_hide");
2458
2627
  });
2459
2628
  window.addEventListener("pageshow", (event) => {
2460
2629
  if (!event.persisted) return;
@@ -2522,6 +2691,7 @@ var DeepdotsPopups = class {
2522
2691
  }
2523
2692
  /** Lógica para evaluar condiciones antes de mostrar una encuesta */
2524
2693
  triggerSurvey(surveyId, popupId) {
2694
+ if (this.bufferIfAwaitingPopups({ kind: "survey", surveyId, popupId })) return;
2525
2695
  const def = this.findPopupDefinition(surveyId, popupId);
2526
2696
  if (!def) {
2527
2697
  this.debug("No popup definition for trigger", { surveyId, popupId });
@@ -2543,6 +2713,10 @@ var DeepdotsPopups = class {
2543
2713
  this.debug("Ignoring empty event trigger name");
2544
2714
  return;
2545
2715
  }
2716
+ if (this.bufferIfAwaitingPopups({ kind: "event", name: normalized })) return;
2717
+ this.evaluateEvent(normalized);
2718
+ }
2719
+ evaluateEvent(normalized) {
2546
2720
  const candidates = this.popupDefinitions.filter((def) => {
2547
2721
  return def.triggers.some((trigger) => {
2548
2722
  const triggerValue = String(trigger.value ?? "").trim();
@@ -2560,6 +2734,40 @@ var DeepdotsPopups = class {
2560
2734
  }
2561
2735
  this.showDefinition(matched);
2562
2736
  }
2737
+ /**
2738
+ * Guarda `trigger` si las definiciones aún no han llegado. Devuelve `true` si lo guardó (el
2739
+ * llamador no debe evaluarlo ahora) y `false` si no hay carga en vuelo (se evalúa ya, como
2740
+ * siempre: tras cargar, tras un fallo o sin apiKey).
2741
+ */
2742
+ bufferIfAwaitingPopups(trigger) {
2743
+ if (!this.awaitingPopups) return false;
2744
+ if (this.pendingTriggers.length >= MAX_PENDING_TRIGGERS) {
2745
+ const dropped = this.pendingTriggers.shift();
2746
+ this.debug("Pending trigger buffer full; dropped oldest", dropped);
2747
+ }
2748
+ this.pendingTriggers.push(trigger);
2749
+ this.debug("Popups not loaded yet; trigger buffered", trigger);
2750
+ return true;
2751
+ }
2752
+ /** La carga falló: sin definiciones no hay nada que reproducir, se descarta todo. */
2753
+ dropPendingTriggers() {
2754
+ const dropped = this.pendingTriggers.length;
2755
+ this.awaitingPopups = false;
2756
+ this.pendingTriggers = [];
2757
+ if (dropped > 0) this.debug("Popups failed to load; dropped pending triggers", dropped);
2758
+ }
2759
+ /** Reproduce en orden por la evaluación normal (segmentos, cooldowns y condiciones de AHORA). */
2760
+ replayPendingTriggers() {
2761
+ this.awaitingPopups = false;
2762
+ const replay = this.pendingTriggers;
2763
+ this.pendingTriggers = [];
2764
+ if (!replay.length) return;
2765
+ this.debug("Replaying triggers received before popups loaded", replay.length);
2766
+ replay.forEach((trigger) => {
2767
+ if (trigger.kind === "event") this.evaluateEvent(trigger.name);
2768
+ else this.triggerSurvey(trigger.surveyId, trigger.popupId);
2769
+ });
2770
+ }
2563
2771
  showDefinition(def) {
2564
2772
  this.log("Showing popup (definition)", def);
2565
2773
  this.surveyToPopupId.set(def.surveyId, def.id);
@@ -2830,7 +3038,7 @@ var DeepdotsPopups = class {
2830
3038
  } catch {
2831
3039
  }
2832
3040
  }
2833
- /** Fetch al servidor para obtener popups */
3041
+ /** Fetch al servidor para obtener popups. `null` si la carga falló (red, no-2xx, payload inválido). */
2834
3042
  async fetchPopupsFromServer() {
2835
3043
  const apiKey = this.config?.apiKey;
2836
3044
  const baseUrl = this.baseUrl;
@@ -2846,19 +3054,19 @@ var DeepdotsPopups = class {
2846
3054
  const response = await fetch(endpoint);
2847
3055
  if (!response.ok) {
2848
3056
  this.log("Failed to fetch popups", response.status, response.statusText);
2849
- return [];
3057
+ return null;
2850
3058
  }
2851
3059
  const raw = await response.text();
2852
3060
  const parsed = JSON.parse(raw);
2853
3061
  if (!Array.isArray(parsed)) {
2854
3062
  this.log("Unexpected popups payload", parsed);
2855
- return [];
3063
+ return null;
2856
3064
  }
2857
3065
  this.debug("Fetched popups payload", parsed);
2858
3066
  return parsed;
2859
3067
  } catch (error) {
2860
3068
  this.log("Error fetching popups", error);
2861
- return [];
3069
+ return null;
2862
3070
  }
2863
3071
  }
2864
3072
  /** Envía los atributos de contact a la API (`POST /sdk/popups/contact`). */
@@ -2978,7 +3186,7 @@ var DeepdotsPopups = class {
2978
3186
  }
2979
3187
  if (!this.popupContainer) return;
2980
3188
  const container = this.popupContainer;
2981
- void import("./renderPopup-WLOFOCDR.mjs").then(({ renderPopup }) => {
3189
+ void import("./renderPopup-JF6IB6HL.mjs").then(({ renderPopup }) => {
2982
3190
  renderPopup(
2983
3191
  container,
2984
3192
  surveyId,
@@ -3282,6 +3490,7 @@ function DeepdotsProvider({ config, children }) {
3282
3490
  style: { flex: 1 },
3283
3491
  originWhitelist: ["*"],
3284
3492
  javaScriptEnabled: true,
3493
+ keyboardDisplayRequiresUserAction: false,
3285
3494
  source: { html: survey.html, baseUrl: "https://sdk.deepdots.com/" },
3286
3495
  onMessage: (e) => rendererRef.current?.handleMessage(e.nativeEvent.data)
3287
3496
  }