@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.
package/dist/index.mjs CHANGED
@@ -361,7 +361,7 @@ var BrowserPopupRenderer = class {
361
361
  if (!this.container) return;
362
362
  this.visible = true;
363
363
  const container = this.container;
364
- void import("./renderPopup-EZKDMLVZ.mjs").then(({ renderPopup }) => {
364
+ void import("./renderPopup-R53C7DB3.mjs").then(({ renderPopup }) => {
365
365
  renderPopup(container, surveyId, productId, actions, emit, onClose, env, userId, style, sessionId, miniService, analyticsFeedbackSessionId);
366
366
  });
367
367
  }
@@ -454,6 +454,15 @@ var AnalyticsManager = class {
454
454
  this.exitMiniService(name);
455
455
  }
456
456
  }
457
+ /**
458
+ * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
459
+ * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
460
+ * vieja, y lo que llegue después es del usuario nuevo.
461
+ */
462
+ resetUserScope() {
463
+ this.attributes = {};
464
+ this.metrics = {};
465
+ }
457
466
  /** Registra un evento de analítica (modelo GA: nombre + params). */
458
467
  track(name, params) {
459
468
  const current = this.getMiniService();
@@ -541,7 +550,7 @@ function pushKV(list, key, value) {
541
550
  if (value === void 0 || value === null || value === "") return;
542
551
  list.push({ key, value: [String(value)] });
543
552
  }
544
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
553
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
545
554
  const { context } = envelope;
546
555
  const profile = [];
547
556
  pushKV(profile, "external-user-id", envelope.userId);
@@ -592,7 +601,8 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
592
601
  },
593
602
  publicKey: keys.publicKey,
594
603
  integration: keys.integration,
595
- completed: false,
604
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
605
+ completed: options?.sessionEnd === true,
596
606
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
597
607
  };
598
608
  }
@@ -611,7 +621,7 @@ function createFeedbackSink(options) {
611
621
  let feedbackSessionId;
612
622
  let firstPostInFlight = null;
613
623
  const url = `${options.baseUrl}/sdk/feedback`;
614
- const post = async (body, final) => {
624
+ const post = async (body, final, sessionEnd = false) => {
615
625
  const json = JSON.stringify(body);
616
626
  const small = json.length <= KEEPALIVE_MAX_BYTES;
617
627
  if (final && options.sendBeaconImpl && small) {
@@ -645,6 +655,7 @@ function createFeedbackSink(options) {
645
655
  );
646
656
  return;
647
657
  }
658
+ if (sessionEnd) return;
648
659
  try {
649
660
  const data = await res.json();
650
661
  if (data?.sessionId && data.sessionId !== feedbackSessionId) {
@@ -657,13 +668,18 @@ function createFeedbackSink(options) {
657
668
  };
658
669
  return async (envelope, meta) => {
659
670
  const final = meta?.final === true;
671
+ const sessionEnd = meta?.sessionEnd === true;
660
672
  if (!feedbackSessionId && firstPostInFlight && !final) {
661
673
  await firstPostInFlight;
662
674
  }
663
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
675
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
664
676
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
665
- const sent = post(body, final);
666
- if (!feedbackSessionId) {
677
+ const sent = post(body, final, sessionEnd);
678
+ if (sessionEnd) {
679
+ feedbackSessionId = void 0;
680
+ firstPostInFlight = null;
681
+ options.onSessionReset?.();
682
+ } else if (!feedbackSessionId) {
667
683
  firstPostInFlight = sent.then(
668
684
  () => void 0,
669
685
  () => void 0
@@ -1098,6 +1114,7 @@ var CrashReporter = class {
1098
1114
  };
1099
1115
 
1100
1116
  // src/analytics/messaging.ts
1117
+ var MESSAGE_CHANNELS = ["push", "in_app"];
1101
1118
  function buildMessageParams(stage, o) {
1102
1119
  const p = {
1103
1120
  stage,
@@ -1110,6 +1127,57 @@ function buildMessageParams(stage, o) {
1110
1127
  if (o.currency) p.currency = o.currency;
1111
1128
  return { ...p, ...o.params ?? {} };
1112
1129
  }
1130
+ var MAX_TRACKED_MESSAGES = 500;
1131
+ var MessageGuard = class {
1132
+ constructor(maxTrackedMessages = MAX_TRACKED_MESSAGES) {
1133
+ this.maxTrackedMessages = maxTrackedMessages;
1134
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
1135
+ this.seen = /* @__PURE__ */ new Map();
1136
+ }
1137
+ evaluate(stage, options) {
1138
+ const channel = options.channel;
1139
+ if (!MESSAGE_CHANNELS.includes(channel)) {
1140
+ return {
1141
+ emit: false,
1142
+ reason: "invalid_channel",
1143
+ detail: `channel "${channel}" no v\xE1lido (esperado ${MESSAGE_CHANNELS.join(" | ")})`
1144
+ };
1145
+ }
1146
+ const entry = this.seen.get(options.id);
1147
+ if (!entry) {
1148
+ this.remember(options.id, channel, stage);
1149
+ return { emit: true };
1150
+ }
1151
+ if (entry.channel !== channel) {
1152
+ return {
1153
+ emit: false,
1154
+ reason: "channel_conflict",
1155
+ detail: `message_id "${options.id}" ya se report\xF3 en channel "${entry.channel}"; se descarta "${channel}"`
1156
+ };
1157
+ }
1158
+ if (entry.stages.has(stage)) {
1159
+ return {
1160
+ emit: false,
1161
+ reason: "duplicate_stage",
1162
+ detail: `stage "${stage}" ya emitido para message_id "${options.id}"`
1163
+ };
1164
+ }
1165
+ entry.stages.add(stage);
1166
+ return { emit: true };
1167
+ }
1168
+ /** Olvida todo lo vigilado (nueva sesión). */
1169
+ reset() {
1170
+ this.seen.clear();
1171
+ }
1172
+ remember(id, channel, stage) {
1173
+ this.seen.set(id, { channel, stages: /* @__PURE__ */ new Set([stage]) });
1174
+ while (this.seen.size > this.maxTrackedMessages) {
1175
+ const oldest = this.seen.keys().next();
1176
+ if (oldest.done) break;
1177
+ this.seen.delete(oldest.value);
1178
+ }
1179
+ }
1180
+ };
1113
1181
 
1114
1182
  // src/core/deepdots-popups.ts
1115
1183
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -1151,14 +1219,24 @@ var DeepdotsPopups = class {
1151
1219
  this.analyticsFlushTimer = void 0;
1152
1220
  /** Crash & error reporting (#14–17). Null hasta init(). */
1153
1221
  this.crashReporter = null;
1222
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
1223
+ this.messageGuard = new MessageGuard();
1154
1224
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
1155
1225
  this.navObserver = null;
1156
1226
  /** Tiempo activo (engagement time, #8). */
1157
1227
  this.engagement = null;
1158
1228
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
1159
1229
  this.contact = null;
1230
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
1231
+ this.storage = null;
1160
1232
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
1161
1233
  this.navStarted = false;
1234
+ /**
1235
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
1236
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
1237
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
1238
+ */
1239
+ this.sessionOpen = false;
1162
1240
  /**
1163
1241
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
1164
1242
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -1170,6 +1248,11 @@ var DeepdotsPopups = class {
1170
1248
  this.logger = config.logger ?? console;
1171
1249
  setLogger(this.logger);
1172
1250
  if (this.initialized) {
1251
+ const nextUserId = config.userId || void 0;
1252
+ if (nextUserId !== this.config?.userId) {
1253
+ this.setUserId(nextUserId);
1254
+ return;
1255
+ }
1173
1256
  this.log("SDK already initialized");
1174
1257
  return;
1175
1258
  }
@@ -1182,6 +1265,7 @@ var DeepdotsPopups = class {
1182
1265
  userId: config.userId || void 0
1183
1266
  };
1184
1267
  const storage = config.storage ?? createDefaultStorage();
1268
+ this.storage = storage;
1185
1269
  this.tracking = new TrackingManager({
1186
1270
  storage,
1187
1271
  clientUserId: this.config.userId,
@@ -1209,6 +1293,9 @@ var DeepdotsPopups = class {
1209
1293
  sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
1210
1294
  onSessionId: (id) => {
1211
1295
  this.analyticsFeedbackSessionId = id;
1296
+ },
1297
+ onSessionReset: () => {
1298
+ this.analyticsFeedbackSessionId = void 0;
1212
1299
  }
1213
1300
  }) : createDryRunSink((...a) => this.logger.log(...a));
1214
1301
  const device = config.device ?? collectDeviceInfo(config.appVersion);
@@ -1217,6 +1304,7 @@ var DeepdotsPopups = class {
1217
1304
  navigator: typeof navigator !== "undefined" ? navigator : void 0,
1218
1305
  intl: typeof Intl !== "undefined" ? Intl : void 0
1219
1306
  });
1307
+ this.messageGuard.reset();
1220
1308
  this.analytics = new AnalyticsManager({
1221
1309
  sink: analyticsSink,
1222
1310
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
@@ -1252,7 +1340,7 @@ var DeepdotsPopups = class {
1252
1340
  this.engagement = new EngagementTracker();
1253
1341
  this.engagement.resume();
1254
1342
  this.setupAnalyticsFlush();
1255
- this.track("deepdots_session_start", {});
1343
+ this.openSession();
1256
1344
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
1257
1345
  if (this.tracking?.isTrackingEnabled()) {
1258
1346
  for (const rec of pendingCrashes) {
@@ -1284,7 +1372,9 @@ var DeepdotsPopups = class {
1284
1372
  }
1285
1373
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
1286
1374
  setTrackingEnabled(enabled) {
1375
+ if (!enabled) this.closeSession("tracking_disabled");
1287
1376
  this.tracking?.setTrackingEnabled(enabled);
1377
+ if (enabled) this.openSession();
1288
1378
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
1289
1379
  }
1290
1380
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -1354,8 +1444,18 @@ var DeepdotsPopups = class {
1354
1444
  trackFunnelStep(funnel, step, taskId, params) {
1355
1445
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
1356
1446
  }
1357
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
1447
+ /**
1448
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
1449
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
1450
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
1451
+ * en otro canal — ver `MessageGuard`.
1452
+ */
1358
1453
  trackMessage(stage, options) {
1454
+ const verdict = this.messageGuard.evaluate(stage, options);
1455
+ if (!verdict.emit) {
1456
+ console.warn(`[DeepdotsPopups] trackMessage descartado (${verdict.reason}): ${verdict.detail}`);
1457
+ return;
1458
+ }
1359
1459
  this.track("deepdots_message", buildMessageParams(stage, options));
1360
1460
  }
1361
1461
  /**
@@ -1371,21 +1471,86 @@ var DeepdotsPopups = class {
1371
1471
  this.navObserver.visit(name);
1372
1472
  }
1373
1473
  }
1374
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
1474
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
1375
1475
  onForeground() {
1376
1476
  this.engagement?.resume();
1477
+ this.openSession();
1377
1478
  }
1378
1479
  /**
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.
1480
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
1481
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
1482
+ * envía el último lote con `completed:true`.
1483
+ *
1484
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
1381
1485
  */
1382
1486
  onBackground() {
1487
+ this.closeSession("background");
1488
+ this.engagement?.pause();
1489
+ }
1490
+ /**
1491
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
1492
+ * `session_end` y envía el último lote con `completed:true`.
1493
+ */
1494
+ endSession() {
1495
+ this.closeSession("manual");
1496
+ }
1497
+ /**
1498
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
1499
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
1500
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
1501
+ *
1502
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
1503
+ */
1504
+ setUserId(userId) {
1505
+ const next = userId || void 0;
1506
+ if (!this.initialized || !this.config || next === this.config.userId) return;
1507
+ this.closeSession("user_change");
1508
+ const enabled = this.tracking?.isTrackingEnabled() ?? true;
1509
+ const storage = this.storage ?? createDefaultStorage();
1510
+ this.config.userId = next;
1511
+ this.tracking = new TrackingManager({ storage, clientUserId: next, enabled });
1512
+ this.contact = next ? new ContactManager({
1513
+ storage,
1514
+ publicKey: this.config.apiKey ?? "",
1515
+ userId: next,
1516
+ post: (body) => this.postContact(body)
1517
+ }) : null;
1518
+ this.analytics?.resetUserScope();
1519
+ this.messageGuard.reset();
1520
+ this.log("tracking \xB7 user_change \xB7 user_id:", this.tracking.getUserId());
1521
+ this.openSession();
1522
+ }
1523
+ /**
1524
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
1525
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
1526
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
1527
+ *
1528
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
1529
+ * el `session_end`.
1530
+ */
1531
+ closeSession(reason) {
1532
+ if (!this.sessionOpen) return;
1533
+ if (!this.tracking?.isTrackingEnabled()) return;
1534
+ this.sessionOpen = false;
1383
1535
  this.navObserver?.stop();
1384
1536
  this.navStarted = false;
1385
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1537
+ this.analytics?.exitAllMiniServices();
1386
1538
  this.flushEngagement();
1387
- this.engagement?.pause();
1388
- this.flushAnalytics();
1539
+ this.track("deepdots_session_end", { reason });
1540
+ this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
1541
+ this.analyticsFeedbackSessionId = void 0;
1542
+ this.tracking?.setSessionId(null);
1543
+ this.log("tracking \xB7 session_end:", reason);
1544
+ }
1545
+ /**
1546
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
1547
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
1548
+ */
1549
+ openSession() {
1550
+ if (this.sessionOpen) return;
1551
+ if (!this.tracking?.isTrackingEnabled()) return;
1552
+ this.sessionOpen = true;
1553
+ this.track("deepdots_session_start", {});
1389
1554
  }
1390
1555
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
1391
1556
  previewAnalytics() {
@@ -1423,10 +1588,7 @@ var DeepdotsPopups = class {
1423
1588
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
1424
1589
  window.addEventListener("pagehide", () => {
1425
1590
  clearInterval(this.analyticsFlushTimer);
1426
- this.navObserver?.stop();
1427
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
1428
- this.flushEngagement();
1429
- this.flushAnalytics({ final: true });
1591
+ this.closeSession("page_hide");
1430
1592
  });
1431
1593
  document.addEventListener("visibilitychange", () => {
1432
1594
  if (document.visibilityState === "hidden") {
@@ -1888,7 +2050,7 @@ var DeepdotsPopups = class {
1888
2050
  }
1889
2051
  if (!this.popupContainer) return;
1890
2052
  const container = this.popupContainer;
1891
- void import("./renderPopup-EZKDMLVZ.mjs").then(({ renderPopup }) => {
2053
+ void import("./renderPopup-R53C7DB3.mjs").then(({ renderPopup }) => {
1892
2054
  renderPopup(
1893
2055
  container,
1894
2056
  surveyId,
@@ -2101,7 +2263,8 @@ function setupReactNative(sdk, config, deps = {}) {
2101
2263
  if (deps.appState?.addEventListener) {
2102
2264
  subscription = deps.appState.addEventListener("change", (state) => {
2103
2265
  if (state === "active") sdk.onForeground();
2104
- else sdk.onBackground();
2266
+ else if (state === "background") sdk.onBackground();
2267
+ else sdk.flushAnalytics();
2105
2268
  });
2106
2269
  }
2107
2270
  return () => {
@@ -2114,6 +2277,9 @@ export {
2114
2277
  CrashReporter,
2115
2278
  DeepdotsPopups,
2116
2279
  InMemoryStorage,
2280
+ MAX_TRACKED_MESSAGES,
2281
+ MESSAGE_CHANNELS,
2282
+ MessageGuard,
2117
2283
  NoopPopupRenderer,
2118
2284
  POPUP_TRIGGER_CONDITION_STATUSES,
2119
2285
  ReactNativePopupRenderer,