@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.
package/dist/index.mjs CHANGED
@@ -7,8 +7,9 @@ import {
7
7
  buildSurveyIdentity,
8
8
  createDefaultStorage,
9
9
  sdkWarn,
10
- setLogger
11
- } from "./chunk-XOTM5CHX.mjs";
10
+ setLogger,
11
+ style_default
12
+ } from "./chunk-XFA2Y6CK.mjs";
12
13
 
13
14
  // src/types/index.ts
14
15
  var POPUP_TRIGGER_CONDITION_STATUSES = [
@@ -195,6 +196,15 @@ function resolveEnvironment(nodeEnv) {
195
196
  }
196
197
 
197
198
  // src/ui/surveyHtml.ts
199
+ var POSITION_MAP = {
200
+ center: { justifyContent: "center", alignItems: "center", padding: "0", background: "rgba(0,0,0,0.5)" },
201
+ bottom: { justifyContent: "center", alignItems: "flex-end", padding: "16px", background: "transparent" },
202
+ "bottom-right": { justifyContent: "flex-end", alignItems: "flex-end", padding: "16px", background: "transparent" },
203
+ "bottom-left": { justifyContent: "flex-start", alignItems: "flex-end", padding: "16px", background: "transparent" },
204
+ top: { justifyContent: "center", alignItems: "flex-start", padding: "16px", background: "transparent" },
205
+ "top-right": { justifyContent: "flex-end", alignItems: "flex-start", padding: "16px", background: "transparent" },
206
+ "top-left": { justifyContent: "flex-start", alignItems: "flex-start", padding: "16px", background: "transparent" }
207
+ };
198
208
  function buildSurveyHtml(opts) {
199
209
  const env = opts.env === "development" ? "dev" : "prod";
200
210
  const version = opts.version ?? "2.2.4";
@@ -205,17 +215,141 @@ function buildSurveyHtml(opts) {
205
215
  const metaJson = JSON.stringify(opts.metadata ?? []);
206
216
  const fontFaceCss = opts.font ? buildFontFaceCss(opts.font.family, opts.font.url) : "";
207
217
  const fontFamilyValue = opts.font ? buildFontFamilyValue(opts.font.family) : "-apple-system,system-ui,sans-serif";
218
+ const popupClass = opts.font?.family ? "deepdots-popup deepdots-has-font" : "deepdots-popup";
219
+ const isDark = opts.theme === "dark";
220
+ const popupBg = isDark ? "#1e1e1e" : "#fff";
221
+ const colorScheme = isDark ? "dark" : "light";
222
+ const textPrimary = isDark ? "#f0f0f0" : "#111";
223
+ const pos = POSITION_MAP[opts.position ?? "center"] ?? POSITION_MAP.center;
224
+ const backLabel = JSON.stringify(opts.actions?.back?.label ?? "Back");
225
+ const startLabel = JSON.stringify(opts.actions?.start?.label ?? "Start survey");
226
+ const completeLabel = JSON.stringify(opts.actions?.complete?.label ?? "Complete survey");
227
+ const submitLabel = JSON.stringify(opts.actions?.accept?.label ?? "Send");
208
228
  return `<!DOCTYPE html><html><head>
209
229
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
210
- <meta name="color-scheme" content="light"/>
211
- <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>
230
+ <meta name="color-scheme" content="${colorScheme}"/>
231
+ <style>
232
+ ${style_default}
233
+ ${fontFaceCss}
234
+ html,body{margin:0;padding:0;height:100%;font-family:${fontFamilyValue}}
235
+ body{display:flex;justify-content:${pos.justifyContent};align-items:${pos.alignItems};background:${pos.background};padding:${pos.padding};box-sizing:border-box}
236
+ .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}
237
+ .deepdots-popup-header{display:flex;justify-content:flex-end;align-items:center;width:100%}
238
+ #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}
239
+ .deepdots-popup-container-content{display:flex;flex-direction:column;padding:0 20px 12px 20px;max-height:80vh;overflow:hidden;box-sizing:border-box}
240
+ .deepdots-popup-main{display:flex;flex-direction:column;width:100%;max-height:80vh;overflow-y:auto;overflow-x:hidden;position:relative}
241
+ #dd-form-wrapper{width:100%;flex:1 1 auto}
242
+ #mf{width:100%;box-sizing:border-box;visibility:hidden}
243
+ .deepdots-popup-main *{max-width:100%;box-sizing:border-box}
244
+ #dd-logo{max-height:40px;max-width:100%;object-fit:contain}
245
+ .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}
246
+ .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}
247
+ .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}
248
+ #dd-back{background:transparent;color:#333;border:1px solid #999}
249
+ #dd-start,#dd-complete,#dd-submit{background:#1E293B;color:#fff;border:none}
250
+ #dd-submit:disabled,#dd-start:disabled,#dd-back:disabled,#dd-complete:disabled{opacity:0.6;cursor:not-allowed}
251
+ .mf-spinner{display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
252
+ .mf-spinner-circle{width:28px;height:28px;border:3px solid #e0e6ed;border-top-color:#1E293B;border-radius:50%;animation:ddspin 0.9s linear infinite}
253
+ @keyframes ddspin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
254
+ @media (max-width:640px){
255
+ .deepdots-popup{width:calc(100% - 24px);max-width:calc(100% - 24px);border-radius:12px}
256
+ .deepdots-popup-footer{flex-direction:column-reverse;gap:12px}
257
+ .dd-nav-btn{width:100%}
258
+ .magicfeedback-checkbox-container,.magicfeedback-radio-container{margin:4px 0;padding:4px 8px}
259
+ .magicfeedback-checkbox label,.magicfeedback-radio-container label{font-size:15px}
260
+ .deepdots-popup input[type="radio"]{margin:6px 0 6px 8px}
261
+ .deepdots-popup input[type="checkbox"]{width:16px;height:16px}
262
+ }
263
+ </style>
212
264
  </head><body>
213
- <div id="mf"></div>
265
+ <div class="${popupClass}" id="dd-popup">
266
+ <div class="deepdots-popup-header">
267
+ <button type="button" id="dd-close" aria-label="Close popup">
268
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
269
+ <path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
270
+ </svg>
271
+ </button>
272
+ </div>
273
+ <div class="deepdots-popup-container-content">
274
+ <div class="deepdots-popup-main" id="dd-main">
275
+ <div id="dd-form-wrapper">
276
+ <div class="mf-spinner" id="dd-spinner" role="status" aria-label="Loading survey"><div class="mf-spinner-circle"></div></div>
277
+ <div id="mf"></div>
278
+ </div>
279
+ <div class="deepdots-error-hint" id="dd-error" role="alert" aria-live="polite"></div>
280
+ <div class="deepdots-popup-footer" id="dd-footer">
281
+ <button type="button" class="dd-nav-btn" id="dd-submit">Send</button>
282
+ <button type="button" class="dd-nav-btn" id="dd-back">Back</button>
283
+ <button type="button" class="dd-nav-btn" id="dd-complete">Complete survey</button>
284
+ <button type="button" class="dd-nav-btn" id="dd-start">Start survey</button>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ </div>
214
289
  <script>
215
290
  (function(){
216
291
  function emit(s){ try{ if(window.ReactNativeWebView){ window.ReactNativeWebView.postMessage(s); } }catch(e){} }
217
292
  function emitJSON(n,p){ try{ emit(JSON.stringify({name:n,payload:p||{}})); }catch(e){} }
218
293
  window.DeepdotsClose = function(){ emitJSON('popup_close'); };
294
+
295
+ var popup=document.getElementById('dd-popup');
296
+ var main=document.getElementById('dd-main');
297
+ var footer=document.getElementById('dd-footer');
298
+ var spinner=document.getElementById('dd-spinner');
299
+ var formHost=document.getElementById('mf');
300
+ var errorHint=document.getElementById('dd-error');
301
+ var backBtn=document.getElementById('dd-back');
302
+ var startBtn=document.getElementById('dd-start');
303
+ var completeBtn=document.getElementById('dd-complete');
304
+ var submitBtn=document.getElementById('dd-submit');
305
+
306
+ backBtn.textContent=${backLabel};
307
+ startBtn.textContent=${startLabel};
308
+ completeBtn.textContent=${completeLabel};
309
+ submitBtn.textContent=${submitLabel};
310
+
311
+ document.getElementById('dd-close').onclick=function(){ emitJSON('popup_close'); };
312
+ backBtn.onclick=function(){ if(window.DeepdotsForm && window.DeepdotsForm.back){ window.DeepdotsForm.back(); } };
313
+ startBtn.onclick=function(){ if(window.DeepdotsForm && window.DeepdotsForm.startForm){ window.DeepdotsForm.startForm(); } };
314
+ completeBtn.onclick=function(){ emitJSON('popup_close'); };
315
+ var surveyCompletedEmitted=false;
316
+ submitBtn.onclick=function(){
317
+ if(surveyCompletedEmitted) return;
318
+ if(window.DeepdotsForm && window.DeepdotsForm.send){ window.DeepdotsForm.send(); }
319
+ };
320
+
321
+ function updateButtons(state){
322
+ backBtn.style.display='none';
323
+ startBtn.style.display='none';
324
+ submitBtn.style.display='none';
325
+ completeBtn.style.display='none';
326
+ backBtn.style.width='';
327
+ startBtn.style.width='';
328
+ submitBtn.style.width='';
329
+ completeBtn.style.width='';
330
+ if(state==='start'){
331
+ startBtn.style.display='inline-flex';
332
+ startBtn.style.width='100%';
333
+ } else if(state==='in_progress_first'){
334
+ submitBtn.style.display='inline-flex';
335
+ } else if(state==='in_progress_next'){
336
+ backBtn.style.display='inline-flex';
337
+ submitBtn.style.display='inline-flex';
338
+ } else if(state==='completed'){
339
+ completeBtn.style.display='inline-flex';
340
+ completeBtn.style.width='100%';
341
+ }
342
+ }
343
+
344
+ function setLoading(isLoading){
345
+ spinner.style.display=isLoading?'flex':'none';
346
+ if(!isLoading){ formHost.style.visibility='visible'; }
347
+ footer.style.display=isLoading?'none':'flex';
348
+ backBtn.disabled=isLoading; startBtn.disabled=isLoading; completeBtn.disabled=isLoading; submitBtn.disabled=isLoading;
349
+ if(isLoading){ updateButtons(null); }
350
+ }
351
+ setLoading(true);
352
+
219
353
  var initialized=false;
220
354
  function initMF(){
221
355
  if(initialized || !window.magicfeedback) return initialized;
@@ -224,23 +358,85 @@ function buildSurveyHtml(opts) {
224
358
  window.magicfeedback.init({ debug:false, env:'${env}' });
225
359
  var form = window.magicfeedback.form(${sid}, ${pid}, ${profileJson}, ${metaJson});
226
360
  window.DeepdotsForm = form;
361
+ var stylesInjected=false;
227
362
  form.generate('mf', {
228
363
  addButton:false,
229
364
  getMetaData:true,
230
- onLoadedEvent:function(){ emitJSON('loaded'); },
231
- beforeSubmitEvent:function(){ emitJSON('before_submit'); },
365
+ onLoadedEvent:function(args){
366
+ var s=args && args.formData ? args.formData.style : null;
367
+ if(s && !stylesInjected){
368
+ stylesInjected=true;
369
+ if(s.boxBackgroundColor){ popup.style.background=s.boxBackgroundColor; }
370
+ if(s.contentAlign){ main.style.justifyContent = s.contentAlign==='center' ? 'center' : 'flex-start'; }
371
+ if(s.buttonPrimaryColor){
372
+ submitBtn.style.background=s.buttonPrimaryColor; submitBtn.style.border='none'; submitBtn.style.color='#fff';
373
+ startBtn.style.background=s.buttonPrimaryColor; startBtn.style.border='none'; startBtn.style.color='#fff';
374
+ completeBtn.style.background=s.buttonPrimaryColor; completeBtn.style.border='none'; completeBtn.style.color='#fff';
375
+ }
376
+ if(s.buttonSecondaryColor){
377
+ backBtn.style.background='#fff'; backBtn.style.color=s.buttonSecondaryColor; backBtn.style.border='1px solid '+s.buttonSecondaryColor;
378
+ }
379
+ if(s.logo && !document.getElementById('dd-logo')){
380
+ var logoImg=document.createElement('img');
381
+ logoImg.id='dd-logo'; logoImg.src=s.logo; logoImg.alt='Logo';
382
+ if(s.logoSize==='small'){ logoImg.style.maxHeight='30px'; }
383
+ else if(s.logoSize==='medium'){ logoImg.style.maxHeight='50px'; }
384
+ else if(s.logoSize==='large'){ logoImg.style.maxHeight='70px'; }
385
+ if(s.logoPosition==='left'){ logoImg.style.margin='0 16px 20px 0'; logoImg.style.display='block'; logoImg.style.marginLeft='0'; }
386
+ else if(s.logoPosition==='right'){ logoImg.style.margin='0 0 20px 16px'; logoImg.style.display='block'; logoImg.style.marginLeft='auto'; }
387
+ else if(s.logoPosition==='center'){ logoImg.style.margin='0 auto 20px auto'; logoImg.style.display='block'; }
388
+ main.insertBefore(logoImg, document.getElementById('dd-form-wrapper'));
389
+ }
390
+ if(s.startMessage && s.startMessage!==''){ updateButtons('start'); }
391
+ else { updateButtons('in_progress_first'); }
392
+ } else {
393
+ updateButtons('in_progress_first');
394
+ }
395
+ emitJSON('loaded');
396
+ setLoading(false);
397
+ },
398
+ beforeSubmitEvent:function(){
399
+ setLoading(true);
400
+ emitJSON('before_submit');
401
+ },
232
402
  afterSubmitEvent:function(p){
233
- try{ if(p && p.completed){ emitJSON('survey_completed', p); } else { emitJSON('after_submit', p); } }
234
- catch(e){ emitJSON('after_submit'); }
403
+ var error=p && p.error ? (typeof p.error==='string' ? p.error : String(p.error)) : '';
404
+ if(error){
405
+ setLoading(false);
406
+ if(error.toLowerCase().indexOf('no response')!==-1){
407
+ errorHint.textContent='Please answer the required question to continue.';
408
+ errorHint.style.display='block';
409
+ updateButtons('in_progress_next');
410
+ return;
411
+ }
412
+ errorHint.textContent='An error occurred while submitting. Please try again or close the popup.';
413
+ errorHint.style.display='block';
414
+ return;
415
+ }
416
+ errorHint.style.display='none';
417
+ setLoading(false);
418
+ if(p && p.completed){
419
+ surveyCompletedEmitted=true;
420
+ updateButtons('completed');
421
+ emitJSON('survey_completed', p);
422
+ return;
423
+ }
424
+ if(p && p.total>1 && p.progress>0 && p.progress<p.total){ updateButtons('in_progress_next'); }
425
+ else { updateButtons('in_progress_first'); }
426
+ emitJSON('after_submit', p);
235
427
  },
236
- onBackEvent:function(p){ emitJSON('back', p); }
237
- }).catch(function(e){ emit('error:init'); });
238
- }catch(e){ emit('error:exception'); }
428
+ onBackEvent:function(p){
429
+ if(p && p.progress===0){ updateButtons('in_progress_first'); }
430
+ else { updateButtons('in_progress_next'); }
431
+ emitJSON('back', p);
432
+ }
433
+ }).catch(function(e){ setLoading(false); emit('error:init'); });
434
+ }catch(e){ setLoading(false); emit('error:exception'); }
239
435
  return true;
240
436
  }
241
437
  var s=document.createElement('script'); s.src=${JSON.stringify(cdn)}; s.async=true;
242
438
  s.onload=function(){ initMF(); };
243
- s.onerror=function(){ emit('error:load'); };
439
+ s.onerror=function(){ setLoading(false); emit('error:load'); };
244
440
  document.head.appendChild(s);
245
441
  var tries=0, t=setInterval(function(){ if(initMF()||++tries>40){ clearInterval(t); } },150);
246
442
  })();
@@ -259,18 +455,28 @@ var ReactNativePopupRenderer = class {
259
455
  }
260
456
  init() {
261
457
  }
262
- show(surveyId, productId, _actions, emit, onClose, env = "production", userId, style, sessionId, miniService, analyticsFeedbackSessionId) {
458
+ show(surveyId, productId, actions, emit, onClose, env = "production", userId, style, sessionId, miniService, analyticsFeedbackSessionId) {
263
459
  this.emitFn = emit;
264
460
  this.onCloseFn = onClose;
265
461
  this.currentSurveyId = surveyId;
266
462
  this.partialEmitted = false;
267
463
  const { profile, metadata } = buildSurveyIdentity(userId ?? null, sessionId ?? null, miniService ?? null, analyticsFeedbackSessionId ?? null);
268
- const html = buildSurveyHtml({ surveyId, productId, env, profile, metadata, font: style?.font });
464
+ const html = buildSurveyHtml({
465
+ surveyId,
466
+ productId,
467
+ env,
468
+ profile,
469
+ metadata,
470
+ font: style?.font,
471
+ theme: style?.theme,
472
+ position: style?.position,
473
+ actions
474
+ });
269
475
  if (this.options.onShow) {
270
476
  this.options.onShow({ surveyId, productId, html });
271
477
  } else {
272
478
  sdkWarn(
273
- "[Deepdots] ReactNativePopupRenderer sin onShow: pasa { onShow } a new ReactNativePopupRenderer({...}) para montar el WebView del survey."
479
+ "[Deepdots] ReactNativePopupRenderer has no onShow: pass { onShow } to new ReactNativePopupRenderer({...}) to mount the survey WebView."
274
480
  );
275
481
  }
276
482
  }
@@ -361,7 +567,7 @@ var BrowserPopupRenderer = class {
361
567
  if (!this.container) return;
362
568
  this.visible = true;
363
569
  const container = this.container;
364
- void import("./renderPopup-EZKDMLVZ.mjs").then(({ renderPopup }) => {
570
+ void import("./renderPopup-NJK7Z46E.mjs").then(({ renderPopup }) => {
365
571
  renderPopup(container, surveyId, productId, actions, emit, onClose, env, userId, style, sessionId, miniService, analyticsFeedbackSessionId);
366
572
  });
367
573
  }
@@ -385,7 +591,7 @@ function createDefaultRenderer() {
385
591
  // src/analytics/analytics-manager.ts
386
592
  function createDryRunSink(log = console.log) {
387
593
  return (payload) => log(
388
- "[DeepdotsAnalytics] (dry-run \xB7 NO enviado \xB7 sin init.analytics) POST /sdk/feedback \u2192",
594
+ "[DeepdotsAnalytics] (dry-run \xB7 NOT sent \xB7 no init.analytics) POST /sdk/feedback \u2192",
389
595
  JSON.stringify(payload, null, 2)
390
596
  );
391
597
  }
@@ -454,6 +660,15 @@ var AnalyticsManager = class {
454
660
  this.exitMiniService(name);
455
661
  }
456
662
  }
663
+ /**
664
+ * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
665
+ * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
666
+ * vieja, y lo que llegue después es del usuario nuevo.
667
+ */
668
+ resetUserScope() {
669
+ this.attributes = {};
670
+ this.metrics = {};
671
+ }
457
672
  /** Registra un evento de analítica (modelo GA: nombre + params). */
458
673
  track(name, params) {
459
674
  const current = this.getMiniService();
@@ -541,7 +756,7 @@ function pushKV(list, key, value) {
541
756
  if (value === void 0 || value === null || value === "") return;
542
757
  list.push({ key, value: [String(value)] });
543
758
  }
544
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
759
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
545
760
  const { context } = envelope;
546
761
  const profile = [];
547
762
  pushKV(profile, "external-user-id", envelope.userId);
@@ -592,7 +807,8 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
592
807
  },
593
808
  publicKey: keys.publicKey,
594
809
  integration: keys.integration,
595
- completed: false,
810
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
811
+ completed: options?.sessionEnd === true,
596
812
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
597
813
  };
598
814
  }
@@ -611,20 +827,20 @@ function createFeedbackSink(options) {
611
827
  let feedbackSessionId;
612
828
  let firstPostInFlight = null;
613
829
  const url = `${options.baseUrl}/sdk/feedback`;
614
- const post = async (body, final) => {
830
+ const post = async (body, final, sessionEnd = false) => {
615
831
  const json = JSON.stringify(body);
616
832
  const small = json.length <= KEEPALIVE_MAX_BYTES;
617
833
  if (final && options.sendBeaconImpl && small) {
618
834
  const payload = typeof Blob !== "undefined" ? new Blob([json], { type: "application/json" }) : json;
619
835
  if (options.sendBeaconImpl(url, payload)) {
620
- options.log?.("[DeepdotsAnalytics] flush final v\xEDa sendBeacon");
836
+ options.log?.("[DeepdotsAnalytics] final flush via sendBeacon");
621
837
  return;
622
838
  }
623
- options.log?.("[DeepdotsAnalytics] sendBeacon rechaz\xF3 el lote; fallback a fetch");
839
+ options.log?.("[DeepdotsAnalytics] sendBeacon rejected the batch; falling back to fetch");
624
840
  }
625
841
  const f = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch : void 0);
626
842
  if (!f) {
627
- options.log?.("[DeepdotsAnalytics] no fetch disponible; payload no enviado", body);
843
+ options.log?.("[DeepdotsAnalytics] no fetch available; payload not sent", body);
628
844
  return;
629
845
  }
630
846
  const res = await f(url, {
@@ -637,19 +853,20 @@ function createFeedbackSink(options) {
637
853
  if (!res.ok) {
638
854
  const detail = await safeBodyText(res);
639
855
  if (isRetryableStatus(res.status)) {
640
- throw new Error(`POST /sdk/feedback ${res.status} (reintentable): ${detail}`);
856
+ throw new Error(`POST /sdk/feedback ${res.status} (retryable): ${detail}`);
641
857
  }
642
858
  options.log?.(
643
- `[DeepdotsAnalytics] POST /sdk/feedback rechazado con ${res.status}; lote DESCARTADO:`,
859
+ `[DeepdotsAnalytics] POST /sdk/feedback rejected with ${res.status}; batch DISCARDED:`,
644
860
  detail
645
861
  );
646
862
  return;
647
863
  }
864
+ if (sessionEnd) return;
648
865
  try {
649
866
  const data = await res.json();
650
867
  if (data?.sessionId && data.sessionId !== feedbackSessionId) {
651
868
  feedbackSessionId = data.sessionId;
652
- options.log?.("[DeepdotsAnalytics] feedbackSessionId cacheado:", feedbackSessionId);
869
+ options.log?.("[DeepdotsAnalytics] feedbackSessionId cached:", feedbackSessionId);
653
870
  options.onSessionId?.(feedbackSessionId);
654
871
  }
655
872
  } catch {
@@ -657,13 +874,18 @@ function createFeedbackSink(options) {
657
874
  };
658
875
  return async (envelope, meta) => {
659
876
  const final = meta?.final === true;
877
+ const sessionEnd = meta?.sessionEnd === true;
660
878
  if (!feedbackSessionId && firstPostInFlight && !final) {
661
879
  await firstPostInFlight;
662
880
  }
663
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
881
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
664
882
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
665
- const sent = post(body, final);
666
- if (!feedbackSessionId) {
883
+ const sent = post(body, final, sessionEnd);
884
+ if (sessionEnd) {
885
+ feedbackSessionId = void 0;
886
+ firstPostInFlight = null;
887
+ options.onSessionReset?.();
888
+ } else if (!feedbackSessionId) {
667
889
  firstPostInFlight = sent.then(
668
890
  () => void 0,
669
891
  () => void 0
@@ -1098,6 +1320,7 @@ var CrashReporter = class {
1098
1320
  };
1099
1321
 
1100
1322
  // src/analytics/messaging.ts
1323
+ var MESSAGE_CHANNELS = ["push", "in_app"];
1101
1324
  function buildMessageParams(stage, o) {
1102
1325
  const p = {
1103
1326
  stage,
@@ -1110,6 +1333,57 @@ function buildMessageParams(stage, o) {
1110
1333
  if (o.currency) p.currency = o.currency;
1111
1334
  return { ...p, ...o.params ?? {} };
1112
1335
  }
1336
+ var MAX_TRACKED_MESSAGES = 500;
1337
+ var MessageGuard = class {
1338
+ constructor(maxTrackedMessages = MAX_TRACKED_MESSAGES) {
1339
+ this.maxTrackedMessages = maxTrackedMessages;
1340
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
1341
+ this.seen = /* @__PURE__ */ new Map();
1342
+ }
1343
+ evaluate(stage, options) {
1344
+ const channel = options.channel;
1345
+ if (!MESSAGE_CHANNELS.includes(channel)) {
1346
+ return {
1347
+ emit: false,
1348
+ reason: "invalid_channel",
1349
+ detail: `invalid channel "${channel}" (expected ${MESSAGE_CHANNELS.join(" | ")})`
1350
+ };
1351
+ }
1352
+ const entry = this.seen.get(options.id);
1353
+ if (!entry) {
1354
+ this.remember(options.id, channel, stage);
1355
+ return { emit: true };
1356
+ }
1357
+ if (entry.channel !== channel) {
1358
+ return {
1359
+ emit: false,
1360
+ reason: "channel_conflict",
1361
+ detail: `message_id "${options.id}" was already reported on channel "${entry.channel}"; discarding "${channel}"`
1362
+ };
1363
+ }
1364
+ if (entry.stages.has(stage)) {
1365
+ return {
1366
+ emit: false,
1367
+ reason: "duplicate_stage",
1368
+ detail: `stage "${stage}" was already sent for message_id "${options.id}"`
1369
+ };
1370
+ }
1371
+ entry.stages.add(stage);
1372
+ return { emit: true };
1373
+ }
1374
+ /** Olvida todo lo vigilado (nueva sesión). */
1375
+ reset() {
1376
+ this.seen.clear();
1377
+ }
1378
+ remember(id, channel, stage) {
1379
+ this.seen.set(id, { channel, stages: /* @__PURE__ */ new Set([stage]) });
1380
+ while (this.seen.size > this.maxTrackedMessages) {
1381
+ const oldest = this.seen.keys().next();
1382
+ if (oldest.done) break;
1383
+ this.seen.delete(oldest.value);
1384
+ }
1385
+ }
1386
+ };
1113
1387
 
1114
1388
  // src/core/deepdots-popups.ts
1115
1389
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -1151,14 +1425,24 @@ var DeepdotsPopups = class {
1151
1425
  this.analyticsFlushTimer = void 0;
1152
1426
  /** Crash & error reporting (#14–17). Null hasta init(). */
1153
1427
  this.crashReporter = null;
1428
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
1429
+ this.messageGuard = new MessageGuard();
1154
1430
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
1155
1431
  this.navObserver = null;
1156
1432
  /** Tiempo activo (engagement time, #8). */
1157
1433
  this.engagement = null;
1158
1434
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
1159
1435
  this.contact = null;
1436
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
1437
+ this.storage = null;
1160
1438
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
1161
1439
  this.navStarted = false;
1440
+ /**
1441
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
1442
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
1443
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
1444
+ */
1445
+ this.sessionOpen = false;
1162
1446
  /**
1163
1447
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
1164
1448
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -1170,6 +1454,11 @@ var DeepdotsPopups = class {
1170
1454
  this.logger = config.logger ?? console;
1171
1455
  setLogger(this.logger);
1172
1456
  if (this.initialized) {
1457
+ const nextUserId = config.userId || void 0;
1458
+ if (nextUserId !== this.config?.userId) {
1459
+ this.setUserId(nextUserId);
1460
+ return;
1461
+ }
1173
1462
  this.log("SDK already initialized");
1174
1463
  return;
1175
1464
  }
@@ -1182,6 +1471,7 @@ var DeepdotsPopups = class {
1182
1471
  userId: config.userId || void 0
1183
1472
  };
1184
1473
  const storage = config.storage ?? createDefaultStorage();
1474
+ this.storage = storage;
1185
1475
  this.tracking = new TrackingManager({
1186
1476
  storage,
1187
1477
  clientUserId: this.config.userId,
@@ -1209,6 +1499,9 @@ var DeepdotsPopups = class {
1209
1499
  sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
1210
1500
  onSessionId: (id) => {
1211
1501
  this.analyticsFeedbackSessionId = id;
1502
+ },
1503
+ onSessionReset: () => {
1504
+ this.analyticsFeedbackSessionId = void 0;
1212
1505
  }
1213
1506
  }) : createDryRunSink((...a) => this.logger.log(...a));
1214
1507
  const device = config.device ?? collectDeviceInfo(config.appVersion);
@@ -1217,6 +1510,7 @@ var DeepdotsPopups = class {
1217
1510
  navigator: typeof navigator !== "undefined" ? navigator : void 0,
1218
1511
  intl: typeof Intl !== "undefined" ? Intl : void 0
1219
1512
  });
1513
+ this.messageGuard.reset();
1220
1514
  this.analytics = new AnalyticsManager({
1221
1515
  sink: analyticsSink,
1222
1516
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
@@ -1252,7 +1546,7 @@ var DeepdotsPopups = class {
1252
1546
  this.engagement = new EngagementTracker();
1253
1547
  this.engagement.resume();
1254
1548
  this.setupAnalyticsFlush();
1255
- this.track("deepdots_session_start", {});
1549
+ this.openSession();
1256
1550
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
1257
1551
  if (this.tracking?.isTrackingEnabled()) {
1258
1552
  for (const rec of pendingCrashes) {
@@ -1284,7 +1578,9 @@ var DeepdotsPopups = class {
1284
1578
  }
1285
1579
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
1286
1580
  setTrackingEnabled(enabled) {
1581
+ if (!enabled) this.closeSession("tracking_disabled");
1287
1582
  this.tracking?.setTrackingEnabled(enabled);
1583
+ if (enabled) this.openSession();
1288
1584
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
1289
1585
  }
1290
1586
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -1354,8 +1650,18 @@ var DeepdotsPopups = class {
1354
1650
  trackFunnelStep(funnel, step, taskId, params) {
1355
1651
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1356
1652
  }
1357
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
1653
+ /**
1654
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
1655
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
1656
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
1657
+ * en otro canal — ver `MessageGuard`.
1658
+ */
1358
1659
  trackMessage(stage, options) {
1660
+ const verdict = this.messageGuard.evaluate(stage, options);
1661
+ if (!verdict.emit) {
1662
+ console.warn(`[DeepdotsPopups] trackMessage discarded (${verdict.reason}): ${verdict.detail}`);
1663
+ return;
1664
+ }
1359
1665
  this.track("deepdots_message", buildMessageParams(stage, options));
1360
1666
  }
1361
1667
  /**
@@ -1371,21 +1677,86 @@ var DeepdotsPopups = class {
1371
1677
  this.navObserver.visit(name);
1372
1678
  }
1373
1679
  }
1374
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
1680
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
1375
1681
  onForeground() {
1376
1682
  this.engagement?.resume();
1683
+ this.openSession();
1377
1684
  }
1378
1685
  /**
1379
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
1380
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
1686
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
1687
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
1688
+ * envía el último lote con `completed:true`.
1689
+ *
1690
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
1381
1691
  */
1382
1692
  onBackground() {
1693
+ this.closeSession("background");
1694
+ this.engagement?.pause();
1695
+ }
1696
+ /**
1697
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
1698
+ * `session_end` y envía el último lote con `completed:true`.
1699
+ */
1700
+ endSession() {
1701
+ this.closeSession("manual");
1702
+ }
1703
+ /**
1704
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
1705
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
1706
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
1707
+ *
1708
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
1709
+ */
1710
+ setUserId(userId) {
1711
+ const next = userId || void 0;
1712
+ if (!this.initialized || !this.config || next === this.config.userId) return;
1713
+ this.closeSession("user_change");
1714
+ const enabled = this.tracking?.isTrackingEnabled() ?? true;
1715
+ const storage = this.storage ?? createDefaultStorage();
1716
+ this.config.userId = next;
1717
+ this.tracking = new TrackingManager({ storage, clientUserId: next, enabled });
1718
+ this.contact = next ? new ContactManager({
1719
+ storage,
1720
+ publicKey: this.config.apiKey ?? "",
1721
+ userId: next,
1722
+ post: (body) => this.postContact(body)
1723
+ }) : null;
1724
+ this.analytics?.resetUserScope();
1725
+ this.messageGuard.reset();
1726
+ this.log("tracking \xB7 user_change \xB7 user_id:", this.tracking.getUserId());
1727
+ this.openSession();
1728
+ }
1729
+ /**
1730
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
1731
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
1732
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
1733
+ *
1734
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
1735
+ * el `session_end`.
1736
+ */
1737
+ closeSession(reason) {
1738
+ if (!this.sessionOpen) return;
1739
+ if (!this.tracking?.isTrackingEnabled()) return;
1740
+ this.sessionOpen = false;
1383
1741
  this.navObserver?.stop();
1384
1742
  this.navStarted = false;
1385
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1743
+ this.analytics?.exitAllMiniServices();
1386
1744
  this.flushEngagement();
1387
- this.engagement?.pause();
1388
- this.flushAnalytics();
1745
+ this.track("deepdots_session_end", { reason });
1746
+ this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
1747
+ this.analyticsFeedbackSessionId = void 0;
1748
+ this.tracking?.setSessionId(null);
1749
+ this.log("tracking \xB7 session_end:", reason);
1750
+ }
1751
+ /**
1752
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
1753
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
1754
+ */
1755
+ openSession() {
1756
+ if (this.sessionOpen) return;
1757
+ if (!this.tracking?.isTrackingEnabled()) return;
1758
+ this.sessionOpen = true;
1759
+ this.track("deepdots_session_start", {});
1389
1760
  }
1390
1761
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
1391
1762
  previewAnalytics() {
@@ -1423,10 +1794,7 @@ var DeepdotsPopups = class {
1423
1794
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
1424
1795
  window.addEventListener("pagehide", () => {
1425
1796
  clearInterval(this.analyticsFlushTimer);
1426
- this.navObserver?.stop();
1427
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1428
- this.flushEngagement();
1429
- this.flushAnalytics({ final: true });
1797
+ this.closeSession("page_hide");
1430
1798
  });
1431
1799
  document.addEventListener("visibilitychange", () => {
1432
1800
  if (document.visibilityState === "hidden") {
@@ -1888,7 +2256,7 @@ var DeepdotsPopups = class {
1888
2256
  }
1889
2257
  if (!this.popupContainer) return;
1890
2258
  const container = this.popupContainer;
1891
- void import("./renderPopup-EZKDMLVZ.mjs").then(({ renderPopup }) => {
2259
+ void import("./renderPopup-NJK7Z46E.mjs").then(({ renderPopup }) => {
1892
2260
  renderPopup(
1893
2261
  container,
1894
2262
  surveyId,
@@ -2101,7 +2469,8 @@ function setupReactNative(sdk, config, deps = {}) {
2101
2469
  if (deps.appState?.addEventListener) {
2102
2470
  subscription = deps.appState.addEventListener("change", (state) => {
2103
2471
  if (state === "active") sdk.onForeground();
2104
- else sdk.onBackground();
2472
+ else if (state === "background") sdk.onBackground();
2473
+ else sdk.flushAnalytics();
2105
2474
  });
2106
2475
  }
2107
2476
  return () => {
@@ -2114,6 +2483,9 @@ export {
2114
2483
  CrashReporter,
2115
2484
  DeepdotsPopups,
2116
2485
  InMemoryStorage,
2486
+ MAX_TRACKED_MESSAGES,
2487
+ MESSAGE_CHANNELS,
2488
+ MessageGuard,
2117
2489
  NoopPopupRenderer,
2118
2490
  POPUP_TRIGGER_CONDITION_STATUSES,
2119
2491
  ReactNativePopupRenderer,