@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.js CHANGED
@@ -3077,6 +3077,11 @@ async function renderPopup(container, surveyId, productId, actions, emit, onClos
3077
3077
  const isDark = style?.theme === "dark";
3078
3078
  const theme = {
3079
3079
  popupBg: isDark ? "#1e1e1e" : "#fff",
3080
+ // El popup fija su propio fondo, así que debe fijar también el esquema de color.
3081
+ // Sin esto los controles de formulario (input/textarea/select) caen al estilo por
3082
+ // defecto del navegador: en un host con `prefers-color-scheme: dark` se pintan
3083
+ // oscuros con texto blanco sobre el fondo claro del popup.
3084
+ colorScheme: isDark ? "dark" : "light",
3080
3085
  textPrimary: isDark ? "#f0f0f0" : "#111",
3081
3086
  closeBtnHoverBg: isDark ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.06)",
3082
3087
  closeBtnHoverColor: isDark ? "#fff" : "#000000"
@@ -3099,6 +3104,7 @@ async function renderPopup(container, surveyId, productId, actions, emit, onClos
3099
3104
  flex-direction: column;
3100
3105
  justify-content: flex-start;
3101
3106
  background: ${theme.popupBg};
3107
+ color-scheme: ${theme.colorScheme};
3102
3108
  border-radius: 8px;
3103
3109
  padding: 24px;
3104
3110
  box-shadow: 0 4px 6px rgba(0,0,0,0.1);
@@ -3550,6 +3556,9 @@ __export(index_exports, {
3550
3556
  CrashReporter: () => CrashReporter,
3551
3557
  DeepdotsPopups: () => DeepdotsPopups,
3552
3558
  InMemoryStorage: () => InMemoryStorage,
3559
+ MAX_TRACKED_MESSAGES: () => MAX_TRACKED_MESSAGES,
3560
+ MESSAGE_CHANNELS: () => MESSAGE_CHANNELS,
3561
+ MessageGuard: () => MessageGuard,
3553
3562
  NoopPopupRenderer: () => NoopPopupRenderer,
3554
3563
  POPUP_TRIGGER_CONDITION_STATUSES: () => POPUP_TRIGGER_CONDITION_STATUSES,
3555
3564
  ReactNativePopupRenderer: () => ReactNativePopupRenderer,
@@ -4017,6 +4026,15 @@ var AnalyticsManager = class {
4017
4026
  this.exitMiniService(name);
4018
4027
  }
4019
4028
  }
4029
+ /**
4030
+ * Olvida lo que pertenecía al usuario anterior (user attributes + métricas) al cambiar de
4031
+ * usuario. NO toca el buffer de eventos: el cierre de sesión ya lo vació con la identidad
4032
+ * vieja, y lo que llegue después es del usuario nuevo.
4033
+ */
4034
+ resetUserScope() {
4035
+ this.attributes = {};
4036
+ this.metrics = {};
4037
+ }
4020
4038
  /** Registra un evento de analítica (modelo GA: nombre + params). */
4021
4039
  track(name, params) {
4022
4040
  const current = this.getMiniService();
@@ -4104,7 +4122,7 @@ function pushKV(list, key, value) {
4104
4122
  if (value === void 0 || value === null || value === "") return;
4105
4123
  list.push({ key, value: [String(value)] });
4106
4124
  }
4107
- function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
4125
+ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId, options) {
4108
4126
  const { context } = envelope;
4109
4127
  const profile = [];
4110
4128
  pushKV(profile, "external-user-id", envelope.userId);
@@ -4155,7 +4173,8 @@ function buildAnalyticsFeedbackBody(envelope, keys, feedbackSessionId) {
4155
4173
  },
4156
4174
  publicKey: keys.publicKey,
4157
4175
  integration: keys.integration,
4158
- completed: false,
4176
+ // Único marcador de cierre acordado con backend: el último lote de la sesión.
4177
+ completed: options?.sessionEnd === true,
4159
4178
  ...feedbackSessionId ? { sessionId: feedbackSessionId } : {}
4160
4179
  };
4161
4180
  }
@@ -4174,7 +4193,7 @@ function createFeedbackSink(options) {
4174
4193
  let feedbackSessionId;
4175
4194
  let firstPostInFlight = null;
4176
4195
  const url = `${options.baseUrl}/sdk/feedback`;
4177
- const post = async (body, final) => {
4196
+ const post = async (body, final, sessionEnd = false) => {
4178
4197
  const json = JSON.stringify(body);
4179
4198
  const small = json.length <= KEEPALIVE_MAX_BYTES;
4180
4199
  if (final && options.sendBeaconImpl && small) {
@@ -4208,6 +4227,7 @@ function createFeedbackSink(options) {
4208
4227
  );
4209
4228
  return;
4210
4229
  }
4230
+ if (sessionEnd) return;
4211
4231
  try {
4212
4232
  const data = await res.json();
4213
4233
  if (data?.sessionId && data.sessionId !== feedbackSessionId) {
@@ -4220,13 +4240,18 @@ function createFeedbackSink(options) {
4220
4240
  };
4221
4241
  return async (envelope, meta) => {
4222
4242
  const final = meta?.final === true;
4243
+ const sessionEnd = meta?.sessionEnd === true;
4223
4244
  if (!feedbackSessionId && firstPostInFlight && !final) {
4224
4245
  await firstPostInFlight;
4225
4246
  }
4226
- const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId);
4247
+ const body = buildAnalyticsFeedbackBody(envelope, options.keys, feedbackSessionId, { sessionEnd });
4227
4248
  options.log?.("[DeepdotsAnalytics] POST /sdk/feedback \u2192", body);
4228
- const sent = post(body, final);
4229
- if (!feedbackSessionId) {
4249
+ const sent = post(body, final, sessionEnd);
4250
+ if (sessionEnd) {
4251
+ feedbackSessionId = void 0;
4252
+ firstPostInFlight = null;
4253
+ options.onSessionReset?.();
4254
+ } else if (!feedbackSessionId) {
4230
4255
  firstPostInFlight = sent.then(
4231
4256
  () => void 0,
4232
4257
  () => void 0
@@ -4665,6 +4690,7 @@ var CrashReporter = class {
4665
4690
  init_logger();
4666
4691
 
4667
4692
  // src/analytics/messaging.ts
4693
+ var MESSAGE_CHANNELS = ["push", "in_app"];
4668
4694
  function buildMessageParams(stage, o) {
4669
4695
  const p = {
4670
4696
  stage,
@@ -4677,6 +4703,57 @@ function buildMessageParams(stage, o) {
4677
4703
  if (o.currency) p.currency = o.currency;
4678
4704
  return { ...p, ...o.params ?? {} };
4679
4705
  }
4706
+ var MAX_TRACKED_MESSAGES = 500;
4707
+ var MessageGuard = class {
4708
+ constructor(maxTrackedMessages = MAX_TRACKED_MESSAGES) {
4709
+ this.maxTrackedMessages = maxTrackedMessages;
4710
+ /** message_id → canal fijado + stages ya emitidos. El orden de inserción marca la eviction. */
4711
+ this.seen = /* @__PURE__ */ new Map();
4712
+ }
4713
+ evaluate(stage, options) {
4714
+ const channel = options.channel;
4715
+ if (!MESSAGE_CHANNELS.includes(channel)) {
4716
+ return {
4717
+ emit: false,
4718
+ reason: "invalid_channel",
4719
+ detail: `channel "${channel}" no v\xE1lido (esperado ${MESSAGE_CHANNELS.join(" | ")})`
4720
+ };
4721
+ }
4722
+ const entry = this.seen.get(options.id);
4723
+ if (!entry) {
4724
+ this.remember(options.id, channel, stage);
4725
+ return { emit: true };
4726
+ }
4727
+ if (entry.channel !== channel) {
4728
+ return {
4729
+ emit: false,
4730
+ reason: "channel_conflict",
4731
+ detail: `message_id "${options.id}" ya se report\xF3 en channel "${entry.channel}"; se descarta "${channel}"`
4732
+ };
4733
+ }
4734
+ if (entry.stages.has(stage)) {
4735
+ return {
4736
+ emit: false,
4737
+ reason: "duplicate_stage",
4738
+ detail: `stage "${stage}" ya emitido para message_id "${options.id}"`
4739
+ };
4740
+ }
4741
+ entry.stages.add(stage);
4742
+ return { emit: true };
4743
+ }
4744
+ /** Olvida todo lo vigilado (nueva sesión). */
4745
+ reset() {
4746
+ this.seen.clear();
4747
+ }
4748
+ remember(id, channel, stage) {
4749
+ this.seen.set(id, { channel, stages: /* @__PURE__ */ new Set([stage]) });
4750
+ while (this.seen.size > this.maxTrackedMessages) {
4751
+ const oldest = this.seen.keys().next();
4752
+ if (oldest.done) break;
4753
+ this.seen.delete(oldest.value);
4754
+ }
4755
+ }
4756
+ };
4680
4757
 
4681
4758
  // src/core/deepdots-popups.ts
4682
4759
  var EXIT_QUEUE_STORAGE_KEY = "__deepdots_exit_popup_queue__";
@@ -4718,14 +4795,24 @@ var DeepdotsPopups = class {
4718
4795
  this.analyticsFlushTimer = void 0;
4719
4796
  /** Crash & error reporting (#14–17). Null hasta init(). */
4720
4797
  this.crashReporter = null;
4798
+ /** Protecciones del funnel de Messaging (#18–22). Vigencia de sesión: se reinicia en init(). */
4799
+ this.messageGuard = new MessageGuard();
4721
4800
  /** Observador de navegación (Fase 2): emite page_view por el canal de analytics. */
4722
4801
  this.navObserver = null;
4723
4802
  /** Tiempo activo (engagement time, #8). */
4724
4803
  this.engagement = null;
4725
4804
  /** Atributos internos del usuario identificado → POST /sdk/popups/contact. Null si no hay userId. */
4726
4805
  this.contact = null;
4806
+ /** Storage resuelto en init() (inyectado por el host o localStorage). Reusado al cambiar de usuario. */
4807
+ this.storage = null;
4727
4808
  /** Marca si ya se inició la navegación manual (setScreen) — para RN sin History API. */
4728
4809
  this.navStarted = false;
4810
+ /**
4811
+ * `true` entre `session_start` y `session_end`. Hace idempotente tanto la apertura como
4812
+ * el cierre (p. ej. `visibilitychange` + `pagehide` seguidos) y permite abrir una sesión
4813
+ * nueva al volver a foreground o al conceder el consentimiento más tarde.
4814
+ */
4815
+ this.sessionOpen = false;
4729
4816
  /**
4730
4817
  * Idioma resuelto en init() (explícito > navigator.language > Intl). Única fuente de
4731
4818
  * verdad para el context de analytics Y para la segmentación por idioma de los popups.
@@ -4737,6 +4824,11 @@ var DeepdotsPopups = class {
4737
4824
  this.logger = config.logger ?? console;
4738
4825
  setLogger(this.logger);
4739
4826
  if (this.initialized) {
4827
+ const nextUserId = config.userId || void 0;
4828
+ if (nextUserId !== this.config?.userId) {
4829
+ this.setUserId(nextUserId);
4830
+ return;
4831
+ }
4740
4832
  this.log("SDK already initialized");
4741
4833
  return;
4742
4834
  }
@@ -4749,6 +4841,7 @@ var DeepdotsPopups = class {
4749
4841
  userId: config.userId || void 0
4750
4842
  };
4751
4843
  const storage = config.storage ?? createDefaultStorage();
4844
+ this.storage = storage;
4752
4845
  this.tracking = new TrackingManager({
4753
4846
  storage,
4754
4847
  clientUserId: this.config.userId,
@@ -4776,6 +4869,9 @@ var DeepdotsPopups = class {
4776
4869
  sendBeaconImpl: typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" ? (url, body) => navigator.sendBeacon(url, body) : void 0,
4777
4870
  onSessionId: (id) => {
4778
4871
  this.analyticsFeedbackSessionId = id;
4872
+ },
4873
+ onSessionReset: () => {
4874
+ this.analyticsFeedbackSessionId = void 0;
4779
4875
  }
4780
4876
  }) : createDryRunSink((...a) => this.logger.log(...a));
4781
4877
  const device = config.device ?? collectDeviceInfo(config.appVersion);
@@ -4784,6 +4880,7 @@ var DeepdotsPopups = class {
4784
4880
  navigator: typeof navigator !== "undefined" ? navigator : void 0,
4785
4881
  intl: typeof Intl !== "undefined" ? Intl : void 0
4786
4882
  });
4883
+ this.messageGuard.reset();
4787
4884
  this.analytics = new AnalyticsManager({
4788
4885
  sink: analyticsSink,
4789
4886
  publicKey: config.analytics?.publicKey ?? this.config.apiKey,
@@ -4819,7 +4916,7 @@ var DeepdotsPopups = class {
4819
4916
  this.engagement = new EngagementTracker();
4820
4917
  this.engagement.resume();
4821
4918
  this.setupAnalyticsFlush();
4822
- this.track("deepdots_session_start", {});
4919
+ this.openSession();
4823
4920
  const pendingCrashes = this.crashReporter.drainPendingCrashes();
4824
4921
  if (this.tracking?.isTrackingEnabled()) {
4825
4922
  for (const rec of pendingCrashes) {
@@ -4851,7 +4948,9 @@ var DeepdotsPopups = class {
4851
4948
  }
4852
4949
  /** Activa/desactiva el tracking (identidad + sesión + analytics). Kill-switch del contrato §7bis. */
4853
4950
  setTrackingEnabled(enabled) {
4951
+ if (!enabled) this.closeSession("tracking_disabled");
4854
4952
  this.tracking?.setTrackingEnabled(enabled);
4953
+ if (enabled) this.openSession();
4855
4954
  this.log("tracking \xB7 setTrackingEnabled:", enabled, "\xB7 session_id:", this.tracking?.getSessionId() ?? null);
4856
4955
  }
4857
4956
  // ───────── Analytics (canal separado del feedback, vinculado por user_id) ─────────
@@ -4921,8 +5020,18 @@ var DeepdotsPopups = class {
4921
5020
  trackFunnelStep(funnel, step, taskId, params) {
4922
5021
  this.track("deepdots_funnel_step", { funnel, step, task_id: taskId, ...params ?? {} });
4923
5022
  }
4924
- /** Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app). No-op si tracking off. */
5023
+ /**
5024
+ * Messaging (#18–22): registra una etapa del funnel de una notificación (push/in-app).
5025
+ * No-op si tracking off. El evento se descarta (con warning) si el `channel` no es válido,
5026
+ * si ese `(message_id, stage)` ya se emitió en la sesión, o si el `message_id` ya se reportó
5027
+ * en otro canal — ver `MessageGuard`.
5028
+ */
4925
5029
  trackMessage(stage, options) {
5030
+ const verdict = this.messageGuard.evaluate(stage, options);
5031
+ if (!verdict.emit) {
5032
+ console.warn(`[DeepdotsPopups] trackMessage descartado (${verdict.reason}): ${verdict.detail}`);
5033
+ return;
5034
+ }
4926
5035
  this.track("deepdots_message", buildMessageParams(stage, options));
4927
5036
  }
4928
5037
  /**
@@ -4938,21 +5047,86 @@ var DeepdotsPopups = class {
4938
5047
  this.navObserver.visit(name);
4939
5048
  }
4940
5049
  }
4941
- /** App a foreground (RN: AppState 'active'): reanuda el engagement time. */
5050
+ /** App a foreground (RN: AppState 'active'): reanuda el engagement time y abre sesión nueva. */
4942
5051
  onForeground() {
4943
5052
  this.engagement?.resume();
5053
+ this.openSession();
4944
5054
  }
4945
5055
  /**
4946
- * App a background (RN: AppState 'background'): cierra pantalla actual (page_view),
4947
- * mini-service (mini_service_exit), emite engagement y hace flush del lote.
5056
+ * App a background (RN: AppState 'background'): FIN DE SESIÓN. Cierra pantalla actual
5057
+ * (page_view), mini-services (mini_service_exit), emite engagement + `session_end` y
5058
+ * envía el último lote con `completed:true`.
5059
+ *
5060
+ * Es la única señal disponible en móvil: el kill de la app (swipe) NO da callback.
4948
5061
  */
4949
5062
  onBackground() {
5063
+ this.closeSession("background");
5064
+ this.engagement?.pause();
5065
+ }
5066
+ /**
5067
+ * Cierre EXPLÍCITO de la sesión por parte del host (logout, fin de flujo…): emite
5068
+ * `session_end` y envía el último lote con `completed:true`.
5069
+ */
5070
+ endSession() {
5071
+ this.closeSession("manual");
5072
+ }
5073
+ /**
5074
+ * Cambio de usuario (login / logout / cambio de cuenta). Cierra la sesión del usuario
5075
+ * anterior (`session_end` con `reason: 'user_change'` + `completed:true`), cambia la
5076
+ * identidad y abre una sesión nueva. Sin `userId` vuelve al id anónimo del SDK.
5077
+ *
5078
+ * Equivale a llamar a `init()` otra vez con otro `userId`.
5079
+ */
5080
+ setUserId(userId) {
5081
+ const next = userId || void 0;
5082
+ if (!this.initialized || !this.config || next === this.config.userId) return;
5083
+ this.closeSession("user_change");
5084
+ const enabled = this.tracking?.isTrackingEnabled() ?? true;
5085
+ const storage = this.storage ?? createDefaultStorage();
5086
+ this.config.userId = next;
5087
+ this.tracking = new TrackingManager({ storage, clientUserId: next, enabled });
5088
+ this.contact = next ? new ContactManager({
5089
+ storage,
5090
+ publicKey: this.config.apiKey ?? "",
5091
+ userId: next,
5092
+ post: (body) => this.postContact(body)
5093
+ }) : null;
5094
+ this.analytics?.resetUserScope();
5095
+ this.messageGuard.reset();
5096
+ this.log("tracking \xB7 user_change \xB7 user_id:", this.tracking.getUserId());
5097
+ this.openSession();
5098
+ }
5099
+ /**
5100
+ * Cierra la sesión actual: vuelca todo lo que quedaba abierto y envía el último lote
5101
+ * marcado con `completed:true`, para que el backend cierre el registro. El `sessionId`
5102
+ * cacheado se olvida → el lote siguiente abre un registro nuevo.
5103
+ *
5104
+ * Idempotente: dos cierres seguidos (p. ej. `visibilitychange` + `pagehide`) no duplican
5105
+ * el `session_end`.
5106
+ */
5107
+ closeSession(reason) {
5108
+ if (!this.sessionOpen) return;
5109
+ if (!this.tracking?.isTrackingEnabled()) return;
5110
+ this.sessionOpen = false;
4950
5111
  this.navObserver?.stop();
4951
5112
  this.navStarted = false;
4952
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
5113
+ this.analytics?.exitAllMiniServices();
4953
5114
  this.flushEngagement();
4954
- this.engagement?.pause();
4955
- this.flushAnalytics();
5115
+ this.track("deepdots_session_end", { reason });
5116
+ this.flushAnalytics({ final: reason === "page_hide", sessionEnd: true });
5117
+ this.analyticsFeedbackSessionId = void 0;
5118
+ this.tracking?.setSessionId(null);
5119
+ this.log("tracking \xB7 session_end:", reason);
5120
+ }
5121
+ /**
5122
+ * Abre sesión (`session_start`) si no hay una abierta y el tracking está activo. Idempotente:
5123
+ * se llama en init(), al volver a foreground y al conceder el consentimiento.
5124
+ */
5125
+ openSession() {
5126
+ if (this.sessionOpen) return;
5127
+ if (!this.tracking?.isTrackingEnabled()) return;
5128
+ this.sessionOpen = true;
5129
+ this.track("deepdots_session_start", {});
4956
5130
  }
4957
5131
  /** Payload que se ENVIARÍA al endpoint de analytics (no envía ni vacía el buffer). */
4958
5132
  previewAnalytics() {
@@ -4990,10 +5164,7 @@ var DeepdotsPopups = class {
4990
5164
  this.analyticsFlushTimer = setInterval(() => this.flushAnalytics(), ANALYTICS_FLUSH_INTERVAL_MS);
4991
5165
  window.addEventListener("pagehide", () => {
4992
5166
  clearInterval(this.analyticsFlushTimer);
4993
- this.navObserver?.stop();
4994
- if (this.tracking?.isTrackingEnabled()) this.analytics?.exitAllMiniServices();
4995
- this.flushEngagement();
4996
- this.flushAnalytics({ final: true });
5167
+ this.closeSession("page_hide");
4997
5168
  });
4998
5169
  document.addEventListener("visibilitychange", () => {
4999
5170
  if (document.visibilityState === "hidden") {
@@ -5671,7 +5842,8 @@ function setupReactNative(sdk, config, deps = {}) {
5671
5842
  if (deps.appState?.addEventListener) {
5672
5843
  subscription = deps.appState.addEventListener("change", (state) => {
5673
5844
  if (state === "active") sdk.onForeground();
5674
- else sdk.onBackground();
5845
+ else if (state === "background") sdk.onBackground();
5846
+ else sdk.flushAnalytics();
5675
5847
  });
5676
5848
  }
5677
5849
  return () => {