@magicfeedback/popup-sdk 1.1.8 → 1.2.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.
@@ -366,7 +366,7 @@ var BrowserPopupRenderer = class {
366
366
  if (!this.container) return;
367
367
  this.visible = true;
368
368
  const container = this.container;
369
- void import("./renderPopup-PS3NJDUR.mjs").then(({ renderPopup }) => {
369
+ void import("./renderPopup-NHWJ7YSI.mjs").then(({ renderPopup }) => {
370
370
  renderPopup(container, surveyId, productId, actions, emit, onClose, env, userId, style, sessionId, miniService, analyticsFeedbackSessionId);
371
371
  });
372
372
  }
@@ -459,6 +459,15 @@ var AnalyticsManager = class {
459
459
  this.exitMiniService(name);
460
460
  }
461
461
  }
462
+ /**
463
+ * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
464
+ * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
465
+ * vieja, y lo que llegue después es del usuario nuevo.
466
+ */
467
+ resetUserScope() {
468
+ this.attributes = {};
469
+ this.metrics = {};
470
+ }
462
471
  /** Registra un evento de analítica (modelo GA: nombre + params). */
463
472
  track(name, params) {
464
473
  const current = this.getMiniService();
@@ -546,7 +555,7 @@ function pushKV(list, key, value) {
546
555
  if (value === void 0 || value === null || value === "") return;
547
556
  list.push({ key, value: [String(value)] });
548
557
  }
549
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
558
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
550
559
  const { context } = envelope;
551
560
  const profile = [];
552
561
  pushKV(profile, "external-user-id", envelope.userId);
@@ -597,7 +606,8 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
597
606
  },
598
607
  publicKey: keys.publicKey,
599
608
  integration: keys.integration,
600
- completed: false,
609
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
610
+ completed: options?.sessionEnd === true,
601
611
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
602
612
  };
603
613
  }
@@ -616,7 +626,7 @@ function createFeedbackSink(options) {
616
626
  let feedbackSessionId;
617
627
  let firstPostInFlight = null;
618
628
  const url = `${options.baseUrl}/sdk/feedback`;
619
- const post = async (body, final) => {
629
+ const post = async (body, final, sessionEnd = false) => {
620
630
  const json = JSON.stringify(body);
621
631
  const small = json.length <= KEEPALIVE_MAX_BYTES;
622
632
  if (final && options.sendBeaconImpl && small) {
@@ -650,6 +660,7 @@ function createFeedbackSink(options) {
650
660
  );
651
661
  return;
652
662
  }
663
+ if (sessionEnd) return;
653
664
  try {
654
665
  const data = await res.json();
655
666
  if (data?.sessionId && data.sessionId !== feedbackSessionId) {
@@ -662,13 +673,18 @@ function createFeedbackSink(options) {
662
673
  };
663
674
  return async (envelope, meta) => {
664
675
  const final = meta?.final === true;
676
+ const sessionEnd = meta?.sessionEnd === true;
665
677
  if (!feedbackSessionId && firstPostInFlight && !final) {
666
678
  await firstPostInFlight;
667
679
  }
668
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
680
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
669
681
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
670
- const sent = post(body, final);
671
- if (!feedbackSessionId) {
682
+ const sent = post(body, final, sessionEnd);
683
+ if (sessionEnd) {
684
+ feedbackSessionId = void 0;
685
+ firstPostInFlight = null;
686
+ options.onSessionReset?.();
687
+ } else if (!feedbackSessionId) {
672
688
  firstPostInFlight = sent.then(
673
689
  () => void 0,
674
690
  () => void 0
@@ -1103,6 +1119,7 @@ var CrashReporter = class {
1103
1119
  };
1104
1120
 
1105
1121
  // src/analytics/messaging.ts
1122
+ var MESSAGE_CHANNELS = ["push", "in_app"];
1106
1123
  function buildMessageParams(stage, o) {
1107
1124
  const p = {
1108
1125
  stage,
@@ -1115,6 +1132,57 @@ function buildMessageParams(stage, o) {
1115
1132
  if (o.currency) p.currency = o.currency;
1116
1133
  return { ...p, ...o.params ?? {} };
1117
1134
  }
1135
+ var MAX_TRACKED_MESSAGES = 500;
1136
+ var MessageGuard = class {
1137
+ constructor(maxTrackedMessages = MAX_TRACKED_MESSAGES) {
1138
+ this.maxTrackedMessages = maxTrackedMessages;
1139
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
1140
+ this.seen = /* @__PURE__ */ new Map();
1141
+ }
1142
+ evaluate(stage, options) {
1143
+ const channel = options.channel;
1144
+ if (!MESSAGE_CHANNELS.includes(channel)) {
1145
+ return {
1146
+ emit: false,
1147
+ reason: "invalid_channel",
1148
+ detail: `channel "${channel}" no v\xE1lido (esperado ${MESSAGE_CHANNELS.join(" | ")})`
1149
+ };
1150
+ }
1151
+ const entry = this.seen.get(options.id);
1152
+ if (!entry) {
1153
+ this.remember(options.id, channel, stage);
1154
+ return { emit: true };
1155
+ }
1156
+ if (entry.channel !== channel) {
1157
+ return {
1158
+ emit: false,
1159
+ reason: "channel_conflict",
1160
+ detail: `message_id "${options.id}" ya se report\xF3 en channel "${entry.channel}"; se descarta "${channel}"`
1161
+ };
1162
+ }
1163
+ if (entry.stages.has(stage)) {
1164
+ return {
1165
+ emit: false,
1166
+ reason: "duplicate_stage",
1167
+ detail: `stage "${stage}" ya emitido para message_id "${options.id}"`
1168
+ };
1169
+ }
1170
+ entry.stages.add(stage);
1171
+ return { emit: true };
1172
+ }
1173
+ /** Olvida todo lo vigilado (nueva sesión). */
1174
+ reset() {
1175
+ this.seen.clear();
1176
+ }
1177
+ remember(id, channel, stage) {
1178
+ this.seen.set(id, { channel, stages: /* @__PURE__ */ new Set([stage]) });
1179
+ while (this.seen.size > this.maxTrackedMessages) {
1180
+ const oldest = this.seen.keys().next();
1181
+ if (oldest.done) break;
1182
+ this.seen.delete(oldest.value);
1183
+ }
1184
+ }
1185
+ };
1118
1186
 
1119
1187
  // src/core/deepdots-popups.ts
1120
1188
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -1156,14 +1224,24 @@ var DeepdotsPopups = class {
1156
1224
  this.analyticsFlushTimer = void 0;
1157
1225
  /** Crash & error reporting (#14–17). Null hasta init(). */
1158
1226
  this.crashReporter = null;
1227
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
1228
+ this.messageGuard = new MessageGuard();
1159
1229
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
1160
1230
  this.navObserver = null;
1161
1231
  /** Tiempo activo (engagement time, #8). */
1162
1232
  this.engagement = null;
1163
1233
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
1164
1234
  this.contact = null;
1235
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
1236
+ this.storage = null;
1165
1237
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
1166
1238
  this.navStarted = false;
1239
+ /**
1240
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
1241
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
1242
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
1243
+ */
1244
+ this.sessionOpen = false;
1167
1245
  /**
1168
1246
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
1169
1247
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -1175,6 +1253,11 @@ var DeepdotsPopups = class {
1175
1253
  this.logger = config.logger ?? console;
1176
1254
  setLogger(this.logger);
1177
1255
  if (this.initialized) {
1256
+ const nextUserId = config.userId || void 0;
1257
+ if (nextUserId !== this.config?.userId) {
1258
+ this.setUserId(nextUserId);
1259
+ return;
1260
+ }
1178
1261
  this.log("SDK already initialized");
1179
1262
  return;
1180
1263
  }
@@ -1187,6 +1270,7 @@ var DeepdotsPopups = class {
1187
1270
  userId: config.userId || void 0
1188
1271
  };
1189
1272
  const storage = config.storage ?? createDefaultStorage();
1273
+ this.storage = storage;
1190
1274
  this.tracking = new TrackingManager({
1191
1275
  storage,
1192
1276
  clientUserId: this.config.userId,
@@ -1214,6 +1298,9 @@ var DeepdotsPopups = class {
1214
1298
  sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
1215
1299
  onSessionId: (id) => {
1216
1300
  this.analyticsFeedbackSessionId = id;
1301
+ },
1302
+ onSessionReset: () => {
1303
+ this.analyticsFeedbackSessionId = void 0;
1217
1304
  }
1218
1305
  }) : createDryRunSink((...a) => this.logger.log(...a));
1219
1306
  const device = config.device ?? collectDeviceInfo(config.appVersion);
@@ -1222,6 +1309,7 @@ var DeepdotsPopups = class {
1222
1309
  navigator: typeof navigator !== "undefined" ? navigator : void 0,
1223
1310
  intl: typeof Intl !== "undefined" ? Intl : void 0
1224
1311
  });
1312
+ this.messageGuard.reset();
1225
1313
  this.analytics = new AnalyticsManager({
1226
1314
  sink: analyticsSink,
1227
1315
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
@@ -1257,7 +1345,7 @@ var DeepdotsPopups = class {
1257
1345
  this.engagement = new EngagementTracker();
1258
1346
  this.engagement.resume();
1259
1347
  this.setupAnalyticsFlush();
1260
- this.track("deepdots_session_start", {});
1348
+ this.openSession();
1261
1349
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
1262
1350
  if (this.tracking?.isTrackingEnabled()) {
1263
1351
  for (const rec of pendingCrashes) {
@@ -1289,7 +1377,9 @@ var DeepdotsPopups = class {
1289
1377
  }
1290
1378
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
1291
1379
  setTrackingEnabled(enabled) {
1380
+ if (!enabled) this.closeSession("tracking_disabled");
1292
1381
  this.tracking?.setTrackingEnabled(enabled);
1382
+ if (enabled) this.openSession();
1293
1383
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
1294
1384
  }
1295
1385
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -1359,8 +1449,18 @@ var DeepdotsPopups = class {
1359
1449
  trackFunnelStep(funnel, step, taskId, params) {
1360
1450
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1361
1451
  }
1362
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
1452
+ /**
1453
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
1454
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
1455
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
1456
+ * en otro canal — ver `MessageGuard`.
1457
+ */
1363
1458
  trackMessage(stage, options) {
1459
+ const verdict = this.messageGuard.evaluate(stage, options);
1460
+ if (!verdict.emit) {
1461
+ console.warn(`[DeepdotsPopups] trackMessage descartado (${verdict.reason}): ${verdict.detail}`);
1462
+ return;
1463
+ }
1364
1464
  this.track("deepdots_message", buildMessageParams(stage, options));
1365
1465
  }
1366
1466
  /**
@@ -1376,21 +1476,86 @@ var DeepdotsPopups = class {
1376
1476
  this.navObserver.visit(name);
1377
1477
  }
1378
1478
  }
1379
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
1479
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
1380
1480
  onForeground() {
1381
1481
  this.engagement?.resume();
1482
+ this.openSession();
1382
1483
  }
1383
1484
  /**
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.
1485
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
1486
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
1487
+ * envía el último lote con `completed:true`.
1488
+ *
1489
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
1386
1490
  */
1387
1491
  onBackground() {
1492
+ this.closeSession("background");
1493
+ this.engagement?.pause();
1494
+ }
1495
+ /**
1496
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
1497
+ * `session_end` y envía el último lote con `completed:true`.
1498
+ */
1499
+ endSession() {
1500
+ this.closeSession("manual");
1501
+ }
1502
+ /**
1503
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
1504
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
1505
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
1506
+ *
1507
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
1508
+ */
1509
+ setUserId(userId) {
1510
+ const next = userId || void 0;
1511
+ if (!this.initialized || !this.config || next === this.config.userId) return;
1512
+ this.closeSession("user_change");
1513
+ const enabled = this.tracking?.isTrackingEnabled() ?? true;
1514
+ const storage = this.storage ?? createDefaultStorage();
1515
+ this.config.userId = next;
1516
+ this.tracking = new TrackingManager({ storage, clientUserId: next, enabled });
1517
+ this.contact = next ? new ContactManager({
1518
+ storage,
1519
+ publicKey: this.config.apiKey ?? "",
1520
+ userId: next,
1521
+ post: (body) => this.postContact(body)
1522
+ }) : null;
1523
+ this.analytics?.resetUserScope();
1524
+ this.messageGuard.reset();
1525
+ this.log("tracking \xB7 user_change \xB7 user_id:", this.tracking.getUserId());
1526
+ this.openSession();
1527
+ }
1528
+ /**
1529
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
1530
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
1531
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
1532
+ *
1533
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
1534
+ * el `session_end`.
1535
+ */
1536
+ closeSession(reason) {
1537
+ if (!this.sessionOpen) return;
1538
+ if (!this.tracking?.isTrackingEnabled()) return;
1539
+ this.sessionOpen = false;
1388
1540
  this.navObserver?.stop();
1389
1541
  this.navStarted = false;
1390
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1542
+ this.analytics?.exitAllMiniServices();
1391
1543
  this.flushEngagement();
1392
- this.engagement?.pause();
1393
- this.flushAnalytics();
1544
+ this.track("deepdots_session_end", { reason });
1545
+ this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
1546
+ this.analyticsFeedbackSessionId = void 0;
1547
+ this.tracking?.setSessionId(null);
1548
+ this.log("tracking \xB7 session_end:", reason);
1549
+ }
1550
+ /**
1551
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
1552
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
1553
+ */
1554
+ openSession() {
1555
+ if (this.sessionOpen) return;
1556
+ if (!this.tracking?.isTrackingEnabled()) return;
1557
+ this.sessionOpen = true;
1558
+ this.track("deepdots_session_start", {});
1394
1559
  }
1395
1560
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
1396
1561
  previewAnalytics() {
@@ -1428,10 +1593,7 @@ var DeepdotsPopups = class {
1428
1593
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
1429
1594
  window.addEventListener("pagehide", () => {
1430
1595
  clearInterval(this.analyticsFlushTimer);
1431
- this.navObserver?.stop();
1432
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1433
- this.flushEngagement();
1434
- this.flushAnalytics({ final: true });
1596
+ this.closeSession("page_hide");
1435
1597
  });
1436
1598
  document.addEventListener("visibilitychange", () => {
1437
1599
  if (document.visibilityState === "hidden") {
@@ -1893,7 +2055,7 @@ var DeepdotsPopups = class {
1893
2055
  }
1894
2056
  if (!this.popupContainer) return;
1895
2057
  const container = this.popupContainer;
1896
- void import("./renderPopup-PS3NJDUR.mjs").then(({ renderPopup }) => {
2058
+ void import("./renderPopup-NHWJ7YSI.mjs").then(({ renderPopup }) => {
1897
2059
  renderPopup(
1898
2060
  container,
1899
2061
  surveyId,
@@ -2106,7 +2268,8 @@ function setupReactNative(sdk2, config, deps = {}) {
2106
2268
  if (deps.appState?.addEventListener) {
2107
2269
  subscription = deps.appState.addEventListener("change", (state) => {
2108
2270
  if (state === "active") sdk2.onForeground();
2109
- else sdk2.onBackground();
2271
+ else if (state === "background") sdk2.onBackground();
2272
+ else sdk2.flushAnalytics();
2110
2273
  });
2111
2274
  }
2112
2275
  return () => {