@magicfeedback/popup-sdk 1.1.8 → 1.3.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.
@@ -7,8 +7,9 @@ import {
7
7
  buildSurveyIdentity,
8
8
  createDefaultStorage,
9
9
  sdkWarn,
10
- setLogger
11
- } from "./chunk-4UXOEIAB.mjs";
10
+ setLogger,
11
+ style_default
12
+ } from "./chunk-R4B3K2ZW.mjs";
12
13
 
13
14
  // src/react-native/index.tsx
14
15
  import { createContext, useContext, useEffect, useRef, useState } from "react";
@@ -200,6 +201,15 @@ function resolveEnvironment(nodeEnv) {
200
201
  }
201
202
 
202
203
  // src/ui/surveyHtml.ts
204
+ var POSITION_MAP = {
205
+ center: { justifyContent: "center", alignItems: "center", padding: "0", background: "rgba(0,0,0,0.5)" },
206
+ bottom: { justifyContent: "center", alignItems: "flex-end", padding: "16px", background: "transparent" },
207
+ "bottom-right": { justifyContent: "flex-end", alignItems: "flex-end", padding: "16px", background: "transparent" },
208
+ "bottom-left": { justifyContent: "flex-start", alignItems: "flex-end", padding: "16px", background: "transparent" },
209
+ top: { justifyContent: "center", alignItems: "flex-start", padding: "16px", background: "transparent" },
210
+ "top-right": { justifyContent: "flex-end", alignItems: "flex-start", padding: "16px", background: "transparent" },
211
+ "top-left": { justifyContent: "flex-start", alignItems: "flex-start", padding: "16px", background: "transparent" }
212
+ };
203
213
  function buildSurveyHtml(opts) {
204
214
  const env = opts.env === "development" ? "dev" : "prod";
205
215
  const version = opts.version ?? "2.2.4";
@@ -210,17 +220,141 @@ function buildSurveyHtml(opts) {
210
220
  const metaJson = JSON.stringify(opts.metadata ?? []);
211
221
  const fontFaceCss = opts.font ? buildFontFaceCss(opts.font.family, opts.font.url) : "";
212
222
  const fontFamilyValue = opts.font ? buildFontFamilyValue(opts.font.family) : "-apple-system,system-ui,sans-serif";
223
+ const popupClass = opts.font?.family ? "deepdots-popup deepdots-has-font" : "deepdots-popup";
224
+ const isDark = opts.theme === "dark";
225
+ const popupBg = isDark ? "#1e1e1e" : "#fff";
226
+ const colorScheme = isDark ? "dark" : "light";
227
+ const textPrimary = isDark ? "#f0f0f0" : "#111";
228
+ const pos = POSITION_MAP[opts.position ?? "center"] ?? POSITION_MAP.center;
229
+ const backLabel = JSON.stringify(opts.actions?.back?.label ?? "Back");
230
+ const startLabel = JSON.stringify(opts.actions?.start?.label ?? "Start survey");
231
+ const completeLabel = JSON.stringify(opts.actions?.complete?.label ?? "Complete survey");
232
+ const submitLabel = JSON.stringify(opts.actions?.accept?.label ?? "Send");
213
233
  return `<!DOCTYPE html><html><head>
214
234
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
215
- <meta name="color-scheme" content="light"/>
216
- <style>${fontFaceCss}html,body{margin:0;padding:0;background:transparent;font-family:${fontFamilyValue}}#mf{width:100%;box-sizing:border-box}#mf *{max-width:100%;box-sizing:border-box}</style>
235
+ <meta name="color-scheme" content="${colorScheme}"/>
236
+ <style>
237
+ ${style_default}
238
+ ${fontFaceCss}
239
+ html,body{margin:0;padding:0;height:100%;font-family:${fontFamilyValue}}
240
+ body{display:flex;justify-content:${pos.justifyContent};align-items:${pos.alignItems};background:${pos.background};padding:${pos.padding};box-sizing:border-box}
241
+ .deepdots-popup{position:relative;display:flex;flex-direction:column;justify-content:flex-start;background:${popupBg};color-scheme:${colorScheme};border-radius:8px;padding:24px;box-shadow:0 4px 6px rgba(0,0,0,0.1);max-width:600px;width:90%;min-height:200px;box-sizing:border-box}
242
+ .deepdots-popup-header{display:flex;justify-content:flex-end;align-items:center;width:100%}
243
+ #dd-close{background:transparent;border:none;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:8px;cursor:pointer;color:${textPrimary};padding:4px}
244
+ .deepdots-popup-container-content{display:flex;flex-direction:column;padding:0 20px 12px 20px;max-height:80vh;overflow:hidden;box-sizing:border-box}
245
+ .deepdots-popup-main{display:flex;flex-direction:column;width:100%;max-height:80vh;overflow-y:auto;overflow-x:hidden;position:relative}
246
+ #dd-form-wrapper{width:100%;flex:1 1 auto}
247
+ #mf{width:100%;box-sizing:border-box;visibility:hidden}
248
+ .deepdots-popup-main *{max-width:100%;box-sizing:border-box}
249
+ #dd-logo{max-height:40px;max-width:100%;object-fit:contain}
250
+ .deepdots-error-hint{display:none;margin:12px 0 0 0;padding:10px 12px;border-radius:6px;background:#FEF3C7;color:#92400E;border:1px solid #FCD34D;font-size:13px}
251
+ .deepdots-popup-footer{display:flex;flex-direction:row-reverse;justify-content:space-between;align-items:center;gap:8px;margin-top:auto;width:100%;padding-top:16px}
252
+ .dd-nav-btn{display:none;border:none;padding:12px 24px;border-radius:4px;cursor:pointer;font-size:14px;box-shadow:0 2px 4px rgba(0,0,0,0.1);align-items:center;justify-content:center;text-align:center}
253
+ #dd-back{background:transparent;color:#333;border:1px solid #999}
254
+ #dd-start,#dd-complete,#dd-submit{background:#1E293B;color:#fff;border:none}
255
+ #dd-submit:disabled,#dd-start:disabled,#dd-back:disabled,#dd-complete:disabled{opacity:0.6;cursor:not-allowed}
256
+ .mf-spinner{display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
257
+ .mf-spinner-circle{width:28px;height:28px;border:3px solid #e0e6ed;border-top-color:#1E293B;border-radius:50%;animation:ddspin 0.9s linear infinite}
258
+ @keyframes ddspin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
259
+ @media (max-width:640px){
260
+ .deepdots-popup{width:calc(100% - 24px);max-width:calc(100% - 24px);border-radius:12px}
261
+ .deepdots-popup-footer{flex-direction:column-reverse;gap:12px}
262
+ .dd-nav-btn{width:100%}
263
+ .magicfeedback-checkbox-container,.magicfeedback-radio-container{margin:4px 0;padding:4px 8px}
264
+ .magicfeedback-checkbox label,.magicfeedback-radio-container label{font-size:15px}
265
+ .deepdots-popup input[type="radio"]{margin:6px 0 6px 8px}
266
+ .deepdots-popup input[type="checkbox"]{width:16px;height:16px}
267
+ }
268
+ </style>
217
269
  </head><body>
218
- <div id="mf"></div>
270
+ <div class="${popupClass}" id="dd-popup">
271
+ <div class="deepdots-popup-header">
272
+ <button type="button" id="dd-close" aria-label="Close popup">
273
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
274
+ <path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
275
+ </svg>
276
+ </button>
277
+ </div>
278
+ <div class="deepdots-popup-container-content">
279
+ <div class="deepdots-popup-main" id="dd-main">
280
+ <div id="dd-form-wrapper">
281
+ <div class="mf-spinner" id="dd-spinner" role="status" aria-label="Loading survey"><div class="mf-spinner-circle"></div></div>
282
+ <div id="mf"></div>
283
+ </div>
284
+ <div class="deepdots-error-hint" id="dd-error" role="alert" aria-live="polite"></div>
285
+ <div class="deepdots-popup-footer" id="dd-footer">
286
+ <button type="button" class="dd-nav-btn" id="dd-submit">Send</button>
287
+ <button type="button" class="dd-nav-btn" id="dd-back">Back</button>
288
+ <button type="button" class="dd-nav-btn" id="dd-complete">Complete survey</button>
289
+ <button type="button" class="dd-nav-btn" id="dd-start">Start survey</button>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </div>
219
294
  <script>
220
295
  (function(){
221
296
  function emit(s){ try{ if(window.ReactNativeWebView){ window.ReactNativeWebView.postMessage(s); } }catch(e){} }
222
297
  function emitJSON(n,p){ try{ emit(JSON.stringify({name:n,payload:p||{}})); }catch(e){} }
223
298
  window.DeepdotsClose = function(){ emitJSON('popup_close'); };
299
+
300
+ var popup=document.getElementById('dd-popup');
301
+ var main=document.getElementById('dd-main');
302
+ var footer=document.getElementById('dd-footer');
303
+ var spinner=document.getElementById('dd-spinner');
304
+ var formHost=document.getElementById('mf');
305
+ var errorHint=document.getElementById('dd-error');
306
+ var backBtn=document.getElementById('dd-back');
307
+ var startBtn=document.getElementById('dd-start');
308
+ var completeBtn=document.getElementById('dd-complete');
309
+ var submitBtn=document.getElementById('dd-submit');
310
+
311
+ backBtn.textContent=${backLabel};
312
+ startBtn.textContent=${startLabel};
313
+ completeBtn.textContent=${completeLabel};
314
+ submitBtn.textContent=${submitLabel};
315
+
316
+ document.getElementById('dd-close').onclick=function(){ emitJSON('popup_close'); };
317
+ backBtn.onclick=function(){ if(window.DeepdotsForm && window.DeepdotsForm.back){ window.DeepdotsForm.back(); } };
318
+ startBtn.onclick=function(){ if(window.DeepdotsForm && window.DeepdotsForm.startForm){ window.DeepdotsForm.startForm(); } };
319
+ completeBtn.onclick=function(){ emitJSON('popup_close'); };
320
+ var surveyCompletedEmitted=false;
321
+ submitBtn.onclick=function(){
322
+ if(surveyCompletedEmitted) return;
323
+ if(window.DeepdotsForm && window.DeepdotsForm.send){ window.DeepdotsForm.send(); }
324
+ };
325
+
326
+ function updateButtons(state){
327
+ backBtn.style.display='none';
328
+ startBtn.style.display='none';
329
+ submitBtn.style.display='none';
330
+ completeBtn.style.display='none';
331
+ backBtn.style.width='';
332
+ startBtn.style.width='';
333
+ submitBtn.style.width='';
334
+ completeBtn.style.width='';
335
+ if(state==='start'){
336
+ startBtn.style.display='inline-flex';
337
+ startBtn.style.width='100%';
338
+ } else if(state==='in_progress_first'){
339
+ submitBtn.style.display='inline-flex';
340
+ } else if(state==='in_progress_next'){
341
+ backBtn.style.display='inline-flex';
342
+ submitBtn.style.display='inline-flex';
343
+ } else if(state==='completed'){
344
+ completeBtn.style.display='inline-flex';
345
+ completeBtn.style.width='100%';
346
+ }
347
+ }
348
+
349
+ function setLoading(isLoading){
350
+ spinner.style.display=isLoading?'flex':'none';
351
+ if(!isLoading){ formHost.style.visibility='visible'; }
352
+ footer.style.display=isLoading?'none':'flex';
353
+ backBtn.disabled=isLoading; startBtn.disabled=isLoading; completeBtn.disabled=isLoading; submitBtn.disabled=isLoading;
354
+ if(isLoading){ updateButtons(null); }
355
+ }
356
+ setLoading(true);
357
+
224
358
  var initialized=false;
225
359
  function initMF(){
226
360
  if(initialized || !window.magicfeedback) return initialized;
@@ -229,23 +363,85 @@ function buildSurveyHtml(opts) {
229
363
  window.magicfeedback.init({ debug:false, env:'${env}' });
230
364
  var form = window.magicfeedback.form(${sid}, ${pid}, ${profileJson}, ${metaJson});
231
365
  window.DeepdotsForm = form;
366
+ var stylesInjected=false;
232
367
  form.generate('mf', {
233
368
  addButton:false,
234
369
  getMetaData:true,
235
- onLoadedEvent:function(){ emitJSON('loaded'); },
236
- beforeSubmitEvent:function(){ emitJSON('before_submit'); },
370
+ onLoadedEvent:function(args){
371
+ var s=args && args.formData ? args.formData.style : null;
372
+ if(s && !stylesInjected){
373
+ stylesInjected=true;
374
+ if(s.boxBackgroundColor){ popup.style.background=s.boxBackgroundColor; }
375
+ if(s.contentAlign){ main.style.justifyContent = s.contentAlign==='center' ? 'center' : 'flex-start'; }
376
+ if(s.buttonPrimaryColor){
377
+ submitBtn.style.background=s.buttonPrimaryColor; submitBtn.style.border='none'; submitBtn.style.color='#fff';
378
+ startBtn.style.background=s.buttonPrimaryColor; startBtn.style.border='none'; startBtn.style.color='#fff';
379
+ completeBtn.style.background=s.buttonPrimaryColor; completeBtn.style.border='none'; completeBtn.style.color='#fff';
380
+ }
381
+ if(s.buttonSecondaryColor){
382
+ backBtn.style.background='#fff'; backBtn.style.color=s.buttonSecondaryColor; backBtn.style.border='1px solid '+s.buttonSecondaryColor;
383
+ }
384
+ if(s.logo && !document.getElementById('dd-logo')){
385
+ var logoImg=document.createElement('img');
386
+ logoImg.id='dd-logo'; logoImg.src=s.logo; logoImg.alt='Logo';
387
+ if(s.logoSize==='small'){ logoImg.style.maxHeight='30px'; }
388
+ else if(s.logoSize==='medium'){ logoImg.style.maxHeight='50px'; }
389
+ else if(s.logoSize==='large'){ logoImg.style.maxHeight='70px'; }
390
+ if(s.logoPosition==='left'){ logoImg.style.margin='0 16px 20px 0'; logoImg.style.display='block'; logoImg.style.marginLeft='0'; }
391
+ else if(s.logoPosition==='right'){ logoImg.style.margin='0 0 20px 16px'; logoImg.style.display='block'; logoImg.style.marginLeft='auto'; }
392
+ else if(s.logoPosition==='center'){ logoImg.style.margin='0 auto 20px auto'; logoImg.style.display='block'; }
393
+ main.insertBefore(logoImg, document.getElementById('dd-form-wrapper'));
394
+ }
395
+ if(s.startMessage && s.startMessage!==''){ updateButtons('start'); }
396
+ else { updateButtons('in_progress_first'); }
397
+ } else {
398
+ updateButtons('in_progress_first');
399
+ }
400
+ emitJSON('loaded');
401
+ setLoading(false);
402
+ },
403
+ beforeSubmitEvent:function(){
404
+ setLoading(true);
405
+ emitJSON('before_submit');
406
+ },
237
407
  afterSubmitEvent:function(p){
238
- try{ if(p && p.completed){ emitJSON('survey_completed', p); } else { emitJSON('after_submit', p); } }
239
- catch(e){ emitJSON('after_submit'); }
408
+ var error=p && p.error ? (typeof p.error==='string' ? p.error : String(p.error)) : '';
409
+ if(error){
410
+ setLoading(false);
411
+ if(error.toLowerCase().indexOf('no response')!==-1){
412
+ errorHint.textContent='Please answer the required question to continue.';
413
+ errorHint.style.display='block';
414
+ updateButtons('in_progress_next');
415
+ return;
416
+ }
417
+ errorHint.textContent='An error occurred while submitting. Please try again or close the popup.';
418
+ errorHint.style.display='block';
419
+ return;
420
+ }
421
+ errorHint.style.display='none';
422
+ setLoading(false);
423
+ if(p && p.completed){
424
+ surveyCompletedEmitted=true;
425
+ updateButtons('completed');
426
+ emitJSON('survey_completed', p);
427
+ return;
428
+ }
429
+ if(p && p.total>1 && p.progress>0 && p.progress<p.total){ updateButtons('in_progress_next'); }
430
+ else { updateButtons('in_progress_first'); }
431
+ emitJSON('after_submit', p);
240
432
  },
241
- onBackEvent:function(p){ emitJSON('back', p); }
242
- }).catch(function(e){ emit('error:init'); });
243
- }catch(e){ emit('error:exception'); }
433
+ onBackEvent:function(p){
434
+ if(p && p.progress===0){ updateButtons('in_progress_first'); }
435
+ else { updateButtons('in_progress_next'); }
436
+ emitJSON('back', p);
437
+ }
438
+ }).catch(function(e){ setLoading(false); emit('error:init'); });
439
+ }catch(e){ setLoading(false); emit('error:exception'); }
244
440
  return true;
245
441
  }
246
442
  var s=document.createElement('script'); s.src=${JSON.stringify(cdn)}; s.async=true;
247
443
  s.onload=function(){ initMF(); };
248
- s.onerror=function(){ emit('error:load'); };
444
+ s.onerror=function(){ setLoading(false); emit('error:load'); };
249
445
  document.head.appendChild(s);
250
446
  var tries=0, t=setInterval(function(){ if(initMF()||++tries>40){ clearInterval(t); } },150);
251
447
  })();
@@ -264,18 +460,28 @@ var ReactNativePopupRenderer = class {
264
460
  }
265
461
  init() {
266
462
  }
267
- show(surveyId, productId, _actions, emit, onClose, env = "production", userId, style, sessionId, miniService, analyticsFeedbackSessionId) {
463
+ show(surveyId, productId, actions, emit, onClose, env = "production", userId, style, sessionId, miniService, analyticsFeedbackSessionId) {
268
464
  this.emitFn = emit;
269
465
  this.onCloseFn = onClose;
270
466
  this.currentSurveyId = surveyId;
271
467
  this.partialEmitted = false;
272
468
  const { profile, metadata } = buildSurveyIdentity(userId ?? null, sessionId ?? null, miniService ?? null, analyticsFeedbackSessionId ?? null);
273
- const html = buildSurveyHtml({ surveyId, productId, env, profile, metadata, font: style?.font });
469
+ const html = buildSurveyHtml({
470
+ surveyId,
471
+ productId,
472
+ env,
473
+ profile,
474
+ metadata,
475
+ font: style?.font,
476
+ theme: style?.theme,
477
+ position: style?.position,
478
+ actions
479
+ });
274
480
  if (this.options.onShow) {
275
481
  this.options.onShow({ surveyId, productId, html });
276
482
  } else {
277
483
  sdkWarn(
278
- "[Deepdots] ReactNativePopupRenderer sin onShow: pasa { onShow } a new ReactNativePopupRenderer({...}) para montar el WebView del survey."
484
+ "[Deepdots] ReactNativePopupRenderer has no onShow: pass { onShow } to new ReactNativePopupRenderer({...}) to mount the survey WebView."
279
485
  );
280
486
  }
281
487
  }
@@ -366,7 +572,7 @@ var BrowserPopupRenderer = class {
366
572
  if (!this.container) return;
367
573
  this.visible = true;
368
574
  const container = this.container;
369
- void import("./renderPopup-PS3NJDUR.mjs").then(({ renderPopup }) => {
575
+ void import("./renderPopup-ZYXOUBCP.mjs").then(({ renderPopup }) => {
370
576
  renderPopup(container, surveyId, productId, actions, emit, onClose, env, userId, style, sessionId, miniService, analyticsFeedbackSessionId);
371
577
  });
372
578
  }
@@ -390,7 +596,7 @@ function createDefaultRenderer() {
390
596
  // src/analytics/analytics-manager.ts
391
597
  function createDryRunSink(log = console.log) {
392
598
  return (payload) => log(
393
- "[DeepdotsAnalytics] (dry-run \xB7 NO enviado \xB7 sin init.analytics) POST /sdk/feedback \u2192",
599
+ "[DeepdotsAnalytics] (dry-run \xB7 NOT sent \xB7 no init.analytics) POST /sdk/feedback \u2192",
394
600
  JSON.stringify(payload, null, 2)
395
601
  );
396
602
  }
@@ -459,6 +665,15 @@ var AnalyticsManager = class {
459
665
  this.exitMiniService(name);
460
666
  }
461
667
  }
668
+ /**
669
+ * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
670
+ * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
671
+ * vieja, y lo que llegue después es del usuario nuevo.
672
+ */
673
+ resetUserScope() {
674
+ this.attributes = {};
675
+ this.metrics = {};
676
+ }
462
677
  /** Registra un evento de analítica (modelo GA: nombre + params). */
463
678
  track(name, params) {
464
679
  const current = this.getMiniService();
@@ -546,7 +761,7 @@ function pushKV(list, key, value) {
546
761
  if (value === void 0 || value === null || value === "") return;
547
762
  list.push({ key, value: [String(value)] });
548
763
  }
549
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
764
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
550
765
  const { context } = envelope;
551
766
  const profile = [];
552
767
  pushKV(profile, "external-user-id", envelope.userId);
@@ -597,7 +812,8 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
597
812
  },
598
813
  publicKey: keys.publicKey,
599
814
  integration: keys.integration,
600
- completed: false,
815
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
816
+ completed: options?.sessionEnd === true,
601
817
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
602
818
  };
603
819
  }
@@ -616,20 +832,20 @@ function createFeedbackSink(options) {
616
832
  let feedbackSessionId;
617
833
  let firstPostInFlight = null;
618
834
  const url = `${options.baseUrl}/sdk/feedback`;
619
- const post = async (body, final) => {
835
+ const post = async (body, final, sessionEnd = false) => {
620
836
  const json = JSON.stringify(body);
621
837
  const small = json.length <= KEEPALIVE_MAX_BYTES;
622
838
  if (final && options.sendBeaconImpl && small) {
623
839
  const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
624
840
  if (options.sendBeaconImpl(url, payload)) {
625
- options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
841
+ options.log?.("[DeepdotsAnalytics] final flush via sendBeacon");
626
842
  return;
627
843
  }
628
- options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
844
+ options.log?.("[DeepdotsAnalytics] sendBeacon rejected the batch; falling back to fetch");
629
845
  }
630
846
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
631
847
  if (!f) {
632
- options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
848
+ options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
633
849
  return;
634
850
  }
635
851
  const res = await f(url, {
@@ -642,19 +858,20 @@ function createFeedbackSink(options) {
642
858
  if (!res.ok) {
643
859
  const detail = await safeBodyText(res);
644
860
  if (isRetryableStatus(res.status)) {
645
- throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
861
+ throw new Error(`POST /sdk/feedback ${res.status} (retryable): ${detail}`);
646
862
  }
647
863
  options.log?.(
648
- `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
864
+ `[DeepdotsAnalytics] POST /sdk/feedback rejected with ${res.status}; batch DISCARDED:`,
649
865
  detail
650
866
  );
651
867
  return;
652
868
  }
869
+ if (sessionEnd) return;
653
870
  try {
654
871
  const data = await res.json();
655
872
  if (data?.sessionId && data.sessionId !== feedbackSessionId) {
656
873
  feedbackSessionId = data.sessionId;
657
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
874
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cached:", feedbackSessionId);
658
875
  options.onSessionId?.(feedbackSessionId);
659
876
  }
660
877
  } catch {
@@ -662,13 +879,18 @@ function createFeedbackSink(options) {
662
879
  };
663
880
  return async (envelope, meta) => {
664
881
  const final = meta?.final === true;
882
+ const sessionEnd = meta?.sessionEnd === true;
665
883
  if (!feedbackSessionId && firstPostInFlight && !final) {
666
884
  await firstPostInFlight;
667
885
  }
668
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
886
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
669
887
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
670
- const sent = post(body, final);
671
- if (!feedbackSessionId) {
888
+ const sent = post(body, final, sessionEnd);
889
+ if (sessionEnd) {
890
+ feedbackSessionId = void 0;
891
+ firstPostInFlight = null;
892
+ options.onSessionReset?.();
893
+ } else if (!feedbackSessionId) {
672
894
  firstPostInFlight = sent.then(
673
895
  () => void 0,
674
896
  () => void 0
@@ -1103,6 +1325,7 @@ var CrashReporter = class {
1103
1325
  };
1104
1326
 
1105
1327
  // src/analytics/messaging.ts
1328
+ var MESSAGE_CHANNELS = ["push", "in_app"];
1106
1329
  function buildMessageParams(stage, o) {
1107
1330
  const p = {
1108
1331
  stage,
@@ -1115,6 +1338,57 @@ function buildMessageParams(stage, o) {
1115
1338
  if (o.currency) p.currency = o.currency;
1116
1339
  return { ...p, ...o.params ?? {} };
1117
1340
  }
1341
+ var MAX_TRACKED_MESSAGES = 500;
1342
+ var MessageGuard = class {
1343
+ constructor(maxTrackedMessages = MAX_TRACKED_MESSAGES) {
1344
+ this.maxTrackedMessages = maxTrackedMessages;
1345
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
1346
+ this.seen = /* @__PURE__ */ new Map();
1347
+ }
1348
+ evaluate(stage, options) {
1349
+ const channel = options.channel;
1350
+ if (!MESSAGE_CHANNELS.includes(channel)) {
1351
+ return {
1352
+ emit: false,
1353
+ reason: "invalid_channel",
1354
+ detail: `invalid channel "${channel}" (expected ${MESSAGE_CHANNELS.join(" | ")})`
1355
+ };
1356
+ }
1357
+ const entry = this.seen.get(options.id);
1358
+ if (!entry) {
1359
+ this.remember(options.id, channel, stage);
1360
+ return { emit: true };
1361
+ }
1362
+ if (entry.channel !== channel) {
1363
+ return {
1364
+ emit: false,
1365
+ reason: "channel_conflict",
1366
+ detail: `message_id "${options.id}" was already reported on channel "${entry.channel}"; discarding "${channel}"`
1367
+ };
1368
+ }
1369
+ if (entry.stages.has(stage)) {
1370
+ return {
1371
+ emit: false,
1372
+ reason: "duplicate_stage",
1373
+ detail: `stage "${stage}" was already sent for message_id "${options.id}"`
1374
+ };
1375
+ }
1376
+ entry.stages.add(stage);
1377
+ return { emit: true };
1378
+ }
1379
+ /** Olvida todo lo vigilado (nueva sesión). */
1380
+ reset() {
1381
+ this.seen.clear();
1382
+ }
1383
+ remember(id, channel, stage) {
1384
+ this.seen.set(id, { channel, stages: /* @__PURE__ */ new Set([stage]) });
1385
+ while (this.seen.size > this.maxTrackedMessages) {
1386
+ const oldest = this.seen.keys().next();
1387
+ if (oldest.done) break;
1388
+ this.seen.delete(oldest.value);
1389
+ }
1390
+ }
1391
+ };
1118
1392
 
1119
1393
  // src/core/deepdots-popups.ts
1120
1394
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -1156,14 +1430,24 @@ var DeepdotsPopups = class {
1156
1430
  this.analyticsFlushTimer = void 0;
1157
1431
  /** Crash & error reporting (#14–17). Null hasta init(). */
1158
1432
  this.crashReporter = null;
1433
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
1434
+ this.messageGuard = new MessageGuard();
1159
1435
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
1160
1436
  this.navObserver = null;
1161
1437
  /** Tiempo activo (engagement time, #8). */
1162
1438
  this.engagement = null;
1163
1439
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
1164
1440
  this.contact = null;
1441
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
1442
+ this.storage = null;
1165
1443
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
1166
1444
  this.navStarted = false;
1445
+ /**
1446
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
1447
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
1448
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
1449
+ */
1450
+ this.sessionOpen = false;
1167
1451
  /**
1168
1452
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
1169
1453
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -1175,6 +1459,11 @@ var DeepdotsPopups = class {
1175
1459
  this.logger = config.logger ?? console;
1176
1460
  setLogger(this.logger);
1177
1461
  if (this.initialized) {
1462
+ const nextUserId = config.userId || void 0;
1463
+ if (nextUserId !== this.config?.userId) {
1464
+ this.setUserId(nextUserId);
1465
+ return;
1466
+ }
1178
1467
  this.log("SDK already initialized");
1179
1468
  return;
1180
1469
  }
@@ -1187,6 +1476,7 @@ var DeepdotsPopups = class {
1187
1476
  userId: config.userId || void 0
1188
1477
  };
1189
1478
  const storage = config.storage ?? createDefaultStorage();
1479
+ this.storage = storage;
1190
1480
  this.tracking = new TrackingManager({
1191
1481
  storage,
1192
1482
  clientUserId: this.config.userId,
@@ -1214,6 +1504,9 @@ var DeepdotsPopups = class {
1214
1504
  sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
1215
1505
  onSessionId: (id) => {
1216
1506
  this.analyticsFeedbackSessionId = id;
1507
+ },
1508
+ onSessionReset: () => {
1509
+ this.analyticsFeedbackSessionId = void 0;
1217
1510
  }
1218
1511
  }) : createDryRunSink((...a) => this.logger.log(...a));
1219
1512
  const device = config.device ?? collectDeviceInfo(config.appVersion);
@@ -1222,6 +1515,7 @@ var DeepdotsPopups = class {
1222
1515
  navigator: typeof navigator !== "undefined" ? navigator : void 0,
1223
1516
  intl: typeof Intl !== "undefined" ? Intl : void 0
1224
1517
  });
1518
+ this.messageGuard.reset();
1225
1519
  this.analytics = new AnalyticsManager({
1226
1520
  sink: analyticsSink,
1227
1521
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
@@ -1257,7 +1551,7 @@ var DeepdotsPopups = class {
1257
1551
  this.engagement = new EngagementTracker();
1258
1552
  this.engagement.resume();
1259
1553
  this.setupAnalyticsFlush();
1260
- this.track("deepdots_session_start", {});
1554
+ this.openSession();
1261
1555
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
1262
1556
  if (this.tracking?.isTrackingEnabled()) {
1263
1557
  for (const rec of pendingCrashes) {
@@ -1289,7 +1583,9 @@ var DeepdotsPopups = class {
1289
1583
  }
1290
1584
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
1291
1585
  setTrackingEnabled(enabled) {
1586
+ if (!enabled) this.closeSession("tracking_disabled");
1292
1587
  this.tracking?.setTrackingEnabled(enabled);
1588
+ if (enabled) this.openSession();
1293
1589
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
1294
1590
  }
1295
1591
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -1359,8 +1655,18 @@ var DeepdotsPopups = class {
1359
1655
  trackFunnelStep(funnel, step, taskId, params) {
1360
1656
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1361
1657
  }
1362
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
1658
+ /**
1659
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
1660
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
1661
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
1662
+ * en otro canal — ver `MessageGuard`.
1663
+ */
1363
1664
  trackMessage(stage, options) {
1665
+ const verdict = this.messageGuard.evaluate(stage, options);
1666
+ if (!verdict.emit) {
1667
+ console.warn(`[DeepdotsPopups] trackMessage discarded (${verdict.reason}): ${verdict.detail}`);
1668
+ return;
1669
+ }
1364
1670
  this.track("deepdots_message", buildMessageParams(stage, options));
1365
1671
  }
1366
1672
  /**
@@ -1376,21 +1682,86 @@ var DeepdotsPopups = class {
1376
1682
  this.navObserver.visit(name);
1377
1683
  }
1378
1684
  }
1379
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
1685
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
1380
1686
  onForeground() {
1381
1687
  this.engagement?.resume();
1688
+ this.openSession();
1382
1689
  }
1383
1690
  /**
1384
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
1385
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
1691
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
1692
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
1693
+ * envía el último lote con `completed:true`.
1694
+ *
1695
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
1386
1696
  */
1387
1697
  onBackground() {
1698
+ this.closeSession("background");
1699
+ this.engagement?.pause();
1700
+ }
1701
+ /**
1702
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
1703
+ * `session_end` y envía el último lote con `completed:true`.
1704
+ */
1705
+ endSession() {
1706
+ this.closeSession("manual");
1707
+ }
1708
+ /**
1709
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
1710
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
1711
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
1712
+ *
1713
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
1714
+ */
1715
+ setUserId(userId) {
1716
+ const next = userId || void 0;
1717
+ if (!this.initialized || !this.config || next === this.config.userId) return;
1718
+ this.closeSession("user_change");
1719
+ const enabled = this.tracking?.isTrackingEnabled() ?? true;
1720
+ const storage = this.storage ?? createDefaultStorage();
1721
+ this.config.userId = next;
1722
+ this.tracking = new TrackingManager({ storage, clientUserId: next, enabled });
1723
+ this.contact = next ? new ContactManager({
1724
+ storage,
1725
+ publicKey: this.config.apiKey ?? "",
1726
+ userId: next,
1727
+ post: (body) => this.postContact(body)
1728
+ }) : null;
1729
+ this.analytics?.resetUserScope();
1730
+ this.messageGuard.reset();
1731
+ this.log("tracking \xB7 user_change \xB7 user_id:", this.tracking.getUserId());
1732
+ this.openSession();
1733
+ }
1734
+ /**
1735
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
1736
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
1737
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
1738
+ *
1739
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
1740
+ * el `session_end`.
1741
+ */
1742
+ closeSession(reason) {
1743
+ if (!this.sessionOpen) return;
1744
+ if (!this.tracking?.isTrackingEnabled()) return;
1745
+ this.sessionOpen = false;
1388
1746
  this.navObserver?.stop();
1389
1747
  this.navStarted = false;
1390
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1748
+ this.analytics?.exitAllMiniServices();
1391
1749
  this.flushEngagement();
1392
- this.engagement?.pause();
1393
- this.flushAnalytics();
1750
+ this.track("deepdots_session_end", { reason });
1751
+ this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
1752
+ this.analyticsFeedbackSessionId = void 0;
1753
+ this.tracking?.setSessionId(null);
1754
+ this.log("tracking \xB7 session_end:", reason);
1755
+ }
1756
+ /**
1757
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
1758
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
1759
+ */
1760
+ openSession() {
1761
+ if (this.sessionOpen) return;
1762
+ if (!this.tracking?.isTrackingEnabled()) return;
1763
+ this.sessionOpen = true;
1764
+ this.track("deepdots_session_start", {});
1394
1765
  }
1395
1766
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
1396
1767
  previewAnalytics() {
@@ -1428,10 +1799,7 @@ var DeepdotsPopups = class {
1428
1799
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
1429
1800
  window.addEventListener("pagehide", () => {
1430
1801
  clearInterval(this.analyticsFlushTimer);
1431
- this.navObserver?.stop();
1432
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1433
- this.flushEngagement();
1434
- this.flushAnalytics({ final: true });
1802
+ this.closeSession("page_hide");
1435
1803
  });
1436
1804
  document.addEventListener("visibilitychange", () => {
1437
1805
  if (document.visibilityState === "hidden") {
@@ -1893,7 +2261,7 @@ var DeepdotsPopups = class {
1893
2261
  }
1894
2262
  if (!this.popupContainer) return;
1895
2263
  const container = this.popupContainer;
1896
- void import("./renderPopup-PS3NJDUR.mjs").then(({ renderPopup }) => {
2264
+ void import("./renderPopup-ZYXOUBCP.mjs").then(({ renderPopup }) => {
1897
2265
  renderPopup(
1898
2266
  container,
1899
2267
  surveyId,
@@ -2106,7 +2474,8 @@ function setupReactNative(sdk2, config, deps = {}) {
2106
2474
  if (deps.appState?.addEventListener) {
2107
2475
  subscription = deps.appState.addEventListener("change", (state) => {
2108
2476
  if (state === "active") sdk2.onForeground();
2109
- else sdk2.onBackground();
2477
+ else if (state === "background") sdk2.onBackground();
2478
+ else sdk2.flushAnalytics();
2110
2479
  });
2111
2480
  }
2112
2481
  return () => {
@@ -2152,9 +2521,10 @@ function DeepdotsProvider({ config, children }) {
2152
2521
  survey ? /* @__PURE__ */ jsx(Modal, { visible: true, transparent: true, animationType: "slide", children: /* @__PURE__ */ jsx(View, { style: { flex: 1 }, children: /* @__PURE__ */ jsx(
2153
2522
  WebView,
2154
2523
  {
2524
+ style: { flex: 1 },
2155
2525
  originWhitelist: ["*"],
2156
2526
  javaScriptEnabled: true,
2157
- source: { html: survey.html },
2527
+ source: { html: survey.html, baseUrl: "https://sdk.deepdots.com/" },
2158
2528
  onMessage: (e) => rendererRef.current?.handleMessage(e.nativeEvent.data)
2159
2529
  }
2160
2530
  ) }) }) : null