@bacano/sdk 1.1.20 → 1.2.1

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.
@@ -1,5 +1,12 @@
1
1
  # Formularios de websites
2
2
 
3
+ Desde SDK 1.2.0, `BacanoForm` instrumenta interacciones mediante el provider
4
+ global de marketing. Para modales montados ocultos, pasar `visible={open}`.
5
+ Renderers personalizados usan `useBacanoFormTracking`. Leer la sección de
6
+ interacciones en [STOREFRONT_MARKETING.md](./STOREFRONT_MARKETING.md) y los
7
+ [ejemplos de migración](./skills/bacano-web/references/interactions.md).
8
+ No añadir un segundo listener ni convertir aperturas en leads.
9
+
3
10
  Los formularios públicos de Bacano son definiciones tipadas y versionadas. El
4
11
  storefront decide dónde se muestran y cómo lucen; Bacano controla su estructura,
5
12
  estado y validaciones. La definición nunca contiene HTML ni JavaScript arbitrario.
@@ -120,8 +120,6 @@ Valores de `contactPlacement`:
120
120
  - `header`
121
121
  - `footer`
122
122
  - `contact_page`
123
- - `checkout`
124
- - `other` únicamente cuando ninguna ubicación canónica describe el contacto
125
123
 
126
124
  El campo es opcional, no acepta textos visibles ni valores libres y el runtime
127
125
  lo ignora cuando el evento no es `contact`. Una ubicación nueva debe añadirse
@@ -276,9 +274,9 @@ Antes de declarar un storefront listo:
276
274
  ## Checklist corto para agentes
277
275
 
278
276
  - [ ] Leí este contrato antes de instrumentar.
279
- - [ ] Uso únicamente `useMarketing().track()`.
277
+ - [ ] Uso el runtime oficial mediante `track`, `useBacanoCta` y los formularios oficiales.
280
278
  - [ ] Uso `product_variant_id` como `itemId`.
281
- - [ ] Emito eventos después del éxito, no antes.
279
+ - [ ] Cada evento representa su hecho: un intento no se presenta como éxito.
282
280
  - [ ] Instrumenté todos los contactos con método y ubicación.
283
281
  - [ ] Los contactos de producto incluyen item, cantidad y valor.
284
282
  - [ ] No emito conversiones autoritativas desde el navegador.
@@ -289,3 +287,104 @@ Antes de declarar un storefront listo:
289
287
  La guía de gobierno, persistencia y eventos autoritativos vive en
290
288
  `docs/websites/MARKETING_MEASUREMENT_STOREFRONTS_CHECKLIST.md` dentro del
291
289
  repositorio Bacano. Este archivo es el contrato distribuible para storefronts.
290
+
291
+ ## Interacciones y formularios — SDK 1.2.0
292
+
293
+ Aplicable también a webs informativas y de servicios. No exige checkout ni
294
+ configurar un proveedor ausente. Esta versión agrega instrumentación; no crea
295
+ un dashboard de embudos dentro de Bacano ni publica configuraciones remotas.
296
+
297
+ | Evento Bacano | GA4 | Meta | Clarity | Propietario y condición |
298
+ | --- | --- | --- | --- | --- |
299
+ | `cta_click` | `cta_click` | personalizado `cta_click` | `cta_click` | CTA pulsado, no hover |
300
+ | `form_open` | `form_open` | personalizado `form_open` | `form_open` | Formulario pasa a estar presentado; no implica inicio |
301
+ | `form_start` | `form_start` | personalizado `form_start` | `form_start` | Primera respuesta introducida/seleccionada por la persona |
302
+ | `form_step_complete` | `form_step_complete` | personalizado `form_step_complete` | `form_step_complete` | Paso validado y avance, una vez por paso/diligenciamiento |
303
+ | `form_submit_attempt` | `form_submit_attempt` | personalizado `form_submit_attempt` | `form_submit_attempt` | Intento de envío final, incluso si la validación lo rechaza |
304
+ | `form_submit_error` | `form_submit_error` | personalizado `form_submit_error` | `form_submit_error` | Fallo de ese intento; categoría segura |
305
+ | `form_submit_success` | `form_submit_success` | personalizado `form_submit_success` | `form_submit_success` | Resultado oficial `accepted: true` y UUID de respuesta |
306
+
307
+ GA4 y Meta reciben solamente `form_key`, `cta_id`, `placement`, `step_key`
308
+ cuando corresponden y `error_category` para errores. Los identificadores son
309
+ técnicos, estables, 1–64 caracteres, patrón `[a-z][a-z0-9_-]*`; no labels,
310
+ respuestas, emails ni teléfonos. `cta_click` exige `ctaId`; los eventos de
311
+ formulario exigen `formKey`; completar un paso exige `stepKey`. Categorías:
312
+ `validation`, `network`, `rejected`, `unavailable`. Parámetros inválidos
313
+ rechazan el evento completo. Clarity recibe solo el nombre, no esos parámetros.
314
+ GA4 recibe además URL saneada, `event_id`, destino explícito y debug según
315
+ configuración. Meta recibe la misma identidad mediante `eventID`, dirigida
316
+ solo al píxel configurado, no a todos los píxeles de la página.
317
+
318
+ `BacanoForm` instrumenta apertura, primera edición, pasos, intento y resultado.
319
+ Un formulario montado dentro de un contenedor oculto necesita `visible={open}`;
320
+ no se intenta adivinar CSS o extraer textos del DOM. Siempre visible significa
321
+ presentado en la página, no una impresión garantizada dentro del viewport.
322
+ Los cambios programáticos de `values` no cuentan como primera edición.
323
+ Un renderer personalizado usa `useBacanoFormTracking` y conserva su propia UI.
324
+ Los CTA usan `useBacanoCta`; no instrumentar el mismo clic también con `track`.
325
+ Ver [ejemplos y migración](./skills/bacano-web/references/interactions.md).
326
+
327
+ ### Persistencia, antispam y conversiones
328
+
329
+ `accepted: true, responseId: null` conserva el mensaje genérico de aceptación,
330
+ pero **no** emite éxito. No revelar el motivo al visitante. Un `onSubmit`
331
+ personalizado que devuelve `void` conserva su comportamiento visual, pero no
332
+ demuestra persistencia para medición; devolver el resultado de
333
+ `client.forms.submitResponse()` cuando se usa ese flujo.
334
+
335
+ Una respuesta guardada no prueba que se haya creado un lead nuevo. Esta
336
+ integración **no emite `generate_lead` ni `Lead`**, tampoco para `CAPTURE_LEAD`:
337
+ la resolución de identidad pertenece al backend y su resultado no forma parte
338
+ del contrato público. Una futura conversión autoritativa debe definirse ahí,
339
+ con identidad compartida si se envía también por navegador; no inferirla de
340
+ este evento de interacción. No duplicar la captura del lead por campaña.
341
+
342
+ ### Deduplicación y atribución
343
+
344
+ - El runtime descarta repeticiones de `(eventName, eventId)` entre sus últimas
345
+ 1.000 identidades. No es una garantía entre recargas ni un acuse del proveedor.
346
+ - CTA: el helper agrupa doble clic del mismo botón dentro de 400 ms, sin
347
+ impedir su acción. Un clic posterior es legítimo y recibe otra identidad.
348
+ - Cerrar/reabrir genera otra apertura; no otro inicio mientras el formulario
349
+ permanezca montado. Desmontar/montar inicia otro diligenciamiento. Un render
350
+ o la repetición de efectos de Strict Mode no equivalen a reabrir.
351
+ - Un envío pendiente no acepta otro simultáneo. Un reintento tras error genera
352
+ otro intento analítico, pero conserva la `idempotencyKey` de persistencia.
353
+ Éxito usa el UUID de respuesta para no duplicarlo dentro del runtime.
354
+ - Se reutiliza la atribución existente: `utm_content` se conserva como
355
+ `content`, junto a las demás UTM. Cada interacción recaptura la ubicación
356
+ usando ese mismo almacenamiento; navegación sin UTM no borra el origen.
357
+ No se copian click IDs de un proveedor a parámetros personalizados de otro.
358
+ - Las respuestas conservan atribución en Bacano; CTA/aperturas se envían a los
359
+ proveedores, no se guardan como una secuencia propia en Bacano. No prometer
360
+ seguimiento completo entre dispositivos, navegadores o bloqueadores.
361
+
362
+ GA4 puede medir formularios automáticamente. Al adoptar este contrato, revisar
363
+ y desactivar **Form interactions** de Enhanced Measurement para que `form_start`
364
+ no tenga dos propietarios. El SDK no cambia esa configuración de la cuenta.
365
+ No tratar el `form_submit` automático como evidencia de guardado.
366
+ [Referencia oficial de GA4](https://support.google.com/analytics/answer/9216061?hl=en).
367
+
368
+ ### Privacidad y diagnóstico
369
+
370
+ El único `BacanoMarketingProvider` acepta `measurementAllowed={false}` cuando
371
+ el gestor de consentimiento del sitio todavía no permite medir. No carga
372
+ scripts, no captura atribución nueva ni emite eventos en ese estado. Este
373
+ interruptor no sustituye el gestor de consentimiento, no borra almacenamiento
374
+ previo ni descarga scripts de terceros que ya estaban cargados. Tampoco
375
+ autoriza publicidad por el hecho de aceptar términos de compra.
376
+
377
+ Config desactivada, dominio distinto, preview o proveedores ausentes son no-op.
378
+ Diagnósticos: `MARKETING_DISABLED`, `MEASUREMENT_NOT_ALLOWED`,
379
+ `DOMAIN_NOT_ALLOWED`, `PROVIDER_NOT_CONFIGURED`, `PREVIEW_DISABLED`,
380
+ `PUBLIC_CONFIG_UNAVAILABLE`, `INVALID_INTERACTION`, `INVALID_EVENT`,
381
+ `SCRIPT_LOAD_FAILED`, `EVENT_DISPATCH_FAILED`, `EVENT_DISPATCHED`.
382
+ Un fallo de descarga no permite distinguir por sí solo bloqueador, CSP o red.
383
+ `EVENT_DISPATCHED` significa únicamente pasado al adaptador; no recepción
384
+ externa. Ni un callback de diagnóstico que falle puede bloquear el formulario.
385
+
386
+ No incluir PII en rutas ni en valores UTM: sanear una URL no identifica toda la
387
+ información personal que el sitio pudiera introducir en su ruta. No evadir
388
+ Core Setup/Protected Data Mode o consentimiento mediante campos alternativos.
389
+ La coherencia entre píxel y CAPI se revisa del lado Bacano; no exponer tokens
390
+ ni inventar un segundo destino público para intentar corregirla en el sitio.
package/index.cjs CHANGED
@@ -2957,6 +2957,9 @@ var ALLOWED_MARKETING_PARAMETERS = /* @__PURE__ */ new Set([
2957
2957
  function sanitizeMarketingUrl(input, baseUrl) {
2958
2958
  try {
2959
2959
  const url = new URL(input, baseUrl);
2960
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "";
2961
+ url.username = "";
2962
+ url.password = "";
2960
2963
  const safeParameters = new URLSearchParams();
2961
2964
  for (const [key, value] of url.searchParams.entries()) {
2962
2965
  if (ALLOWED_MARKETING_PARAMETERS.has(key.toLowerCase()) && !SENSITIVE_PARAMETER_PATTERNS.some((pattern) => pattern.test(key))) {
@@ -5732,50 +5735,65 @@ function createContentModule(graphqlUrl, getHeaders, getContext, storageUrl) {
5732
5735
  };
5733
5736
  }
5734
5737
 
5735
- // libs/sdk/src/marketing/clarity.ts
5736
- var initializedDestinations = /* @__PURE__ */ new Set();
5737
- function clarityWindow() {
5738
- return typeof window === "undefined" ? null : window;
5739
- }
5740
- function prepareClarity(projectId) {
5741
- const target = clarityWindow();
5742
- if (!target || initializedDestinations.has(projectId)) return;
5743
- if (!target.clarity) {
5744
- const clarity = function() {
5745
- clarity.q ??= [];
5746
- clarity.q.push(arguments);
5747
- };
5748
- target.clarity = clarity;
5738
+ // libs/sdk/src/marketing/interactions.ts
5739
+ var WEBSITE_INTERACTION_EVENTS = [
5740
+ "cta_click",
5741
+ "form_open",
5742
+ "form_start",
5743
+ "form_step_complete",
5744
+ "form_submit_attempt",
5745
+ "form_submit_error",
5746
+ "form_submit_success"
5747
+ ];
5748
+ function isInteractionEvent(name) {
5749
+ return WEBSITE_INTERACTION_EVENTS.some((candidate) => candidate === name);
5750
+ }
5751
+ function isInteractionIdentifier(value) {
5752
+ return typeof value === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(value);
5753
+ }
5754
+ function isBrowserMarketingEvent(name) {
5755
+ return isInteractionEvent(name) || [
5756
+ "page_view",
5757
+ "search",
5758
+ "view_item_list",
5759
+ "select_item",
5760
+ "view_item",
5761
+ "add_to_cart",
5762
+ "remove_from_cart",
5763
+ "view_cart",
5764
+ "begin_checkout",
5765
+ "add_shipping_info",
5766
+ "add_payment_info",
5767
+ "contact"
5768
+ ].includes(name);
5769
+ }
5770
+ function interactionParameters(event) {
5771
+ if (!isInteractionEvent(event.eventName)) return {};
5772
+ if (event.eventName === "cta_click" ? !isInteractionIdentifier(event.ctaId) : !isInteractionIdentifier(event.formKey))
5773
+ return null;
5774
+ if (event.eventName === "form_step_complete" && !isInteractionIdentifier(event.stepKey))
5775
+ return null;
5776
+ if (event.eventName === "form_submit_error" && !["validation", "network", "rejected", "unavailable"].includes(
5777
+ event.errorCategory ?? ""
5778
+ ))
5779
+ return null;
5780
+ const params = {};
5781
+ for (const [key, value] of Object.entries({
5782
+ form_key: event.formKey,
5783
+ cta_id: event.ctaId,
5784
+ placement: event.placement,
5785
+ step_key: event.stepKey
5786
+ })) {
5787
+ if (value === void 0) continue;
5788
+ if (!isInteractionIdentifier(value)) return null;
5789
+ params[key] = value;
5749
5790
  }
5750
- initializedDestinations.add(projectId);
5791
+ if (event.eventName === "form_submit_error" && event.errorCategory)
5792
+ params.error_category = event.errorCategory;
5793
+ return params;
5751
5794
  }
5752
- function loadClarityScript(projectId) {
5753
- if (typeof document === "undefined") return;
5754
- const id = `bacano-clarity-${projectId}`;
5755
- if (document.getElementById(id)) return;
5756
- const script = document.createElement("script");
5757
- script.id = id;
5758
- script.async = true;
5759
- script.src = `https://www.clarity.ms/tag/${encodeURIComponent(projectId)}`;
5760
- script.referrerPolicy = "strict-origin-when-cross-origin";
5761
- document.head.appendChild(script);
5762
- }
5763
- function sendClarityEvent(eventName) {
5764
- clarityWindow()?.clarity?.("event", eventName);
5765
- }
5766
-
5767
- // libs/sdk/src/marketing/domain.ts
5768
- function matchesCanonicalMarketingHost(canonicalDomain, currentHostname) {
5769
- if (canonicalDomain === void 0) return true;
5770
- if (!canonicalDomain?.trim()) return false;
5771
- try {
5772
- const configuredHost = new URL(
5773
- canonicalDomain.includes("://") ? canonicalDomain : `https://${canonicalDomain}`
5774
- ).hostname.toLowerCase();
5775
- return currentHostname.toLowerCase() === configuredHost;
5776
- } catch {
5777
- return false;
5778
- }
5795
+ function newMarketingEventId() {
5796
+ return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`;
5779
5797
  }
5780
5798
 
5781
5799
  // libs/sdk/src/marketing/ga4.ts
@@ -5803,19 +5821,20 @@ var WEBSITE_MARKETING_CONTACT_PLACEMENTS = [
5803
5821
  "contact_page"
5804
5822
  ];
5805
5823
 
5806
- // libs/sdk/src/marketing/runtime.ts
5824
+ // libs/sdk/src/marketing/browser-adapters.ts
5807
5825
  var initializedGa4Destinations = /* @__PURE__ */ new Set();
5808
5826
  var initializedMetaDestinations = /* @__PURE__ */ new Set();
5809
5827
  function marketingWindow() {
5810
5828
  return typeof window === "undefined" ? null : window;
5811
5829
  }
5812
- function loadScriptOnce(id, source) {
5830
+ function loadScriptOnce(id, source, onError) {
5813
5831
  if (typeof document === "undefined" || document.getElementById(id)) return;
5814
5832
  const script = document.createElement("script");
5815
5833
  script.id = id;
5816
5834
  script.async = true;
5817
5835
  script.src = source;
5818
5836
  script.referrerPolicy = "strict-origin-when-cross-origin";
5837
+ script.onerror = () => onError?.();
5819
5838
  document.head.appendChild(script);
5820
5839
  }
5821
5840
  function prepareGa4(measurementId) {
@@ -5830,10 +5849,11 @@ function prepareGa4(measurementId) {
5830
5849
  target.gtag("config", measurementId, { send_page_view: false });
5831
5850
  initializedGa4Destinations.add(measurementId);
5832
5851
  }
5833
- function loadGa4Script(measurementId) {
5852
+ function loadGa4Script(measurementId, onError) {
5834
5853
  loadScriptOnce(
5835
5854
  `bacano-ga4-${measurementId}`,
5836
- `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`
5855
+ `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`,
5856
+ onError
5837
5857
  );
5838
5858
  }
5839
5859
  function captureGa4ClientId(measurementId, websiteId, diagnostic) {
@@ -5881,10 +5901,11 @@ function prepareMeta(pixelId) {
5881
5901
  target.fbq("init", pixelId);
5882
5902
  initializedMetaDestinations.add(pixelId);
5883
5903
  }
5884
- function loadMetaScript() {
5904
+ function loadMetaScript(onError) {
5885
5905
  loadScriptOnce(
5886
5906
  "bacano-meta-pixel",
5887
- "https://connect.facebook.net/en_US/fbevents.js"
5907
+ "https://connect.facebook.net/en_US/fbevents.js",
5908
+ onError
5888
5909
  );
5889
5910
  }
5890
5911
  function scheduleScriptLoad(load, strategy) {
@@ -5948,10 +5969,12 @@ function contactPlacementFor(event) {
5948
5969
  }
5949
5970
  return placement;
5950
5971
  }
5951
- function sendGa4(event, diagnosticMode) {
5972
+ function sendGa4(event, diagnosticMode, measurementId) {
5952
5973
  const target = marketingWindow();
5953
5974
  const contactPlacement = contactPlacementFor(event);
5954
5975
  target?.gtag?.("event", event.eventName, {
5976
+ send_to: measurementId,
5977
+ ...interactionParameters(event),
5955
5978
  currency: event.currency,
5956
5979
  value: event.value,
5957
5980
  search_term: event.searchTerm,
@@ -5968,15 +5991,17 @@ function sendGa4(event, diagnosticMode) {
5968
5991
  debug_mode: diagnosticMode ? true : void 0
5969
5992
  });
5970
5993
  }
5971
- function sendMeta(event) {
5994
+ function sendMeta(event, pixelId) {
5972
5995
  const target = marketingWindow();
5973
5996
  const eventName = metaEventName(event.eventName);
5974
5997
  const isStandard = eventName !== event.eventName;
5975
5998
  const contactPlacement = contactPlacementFor(event);
5976
5999
  target?.fbq?.(
5977
- isStandard ? "track" : "trackCustom",
6000
+ isStandard ? "trackSingle" : "trackSingleCustom",
6001
+ pixelId,
5978
6002
  eventName,
5979
6003
  {
6004
+ ...interactionParameters(event),
5980
6005
  currency: event.currency,
5981
6006
  value: event.value,
5982
6007
  search_string: event.searchTerm,
@@ -5993,14 +6018,69 @@ function sendMeta(event) {
5993
6018
  { eventID: event.eventId }
5994
6019
  );
5995
6020
  }
5996
- function newEventId() {
5997
- return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`;
6021
+
6022
+ // libs/sdk/src/marketing/clarity.ts
6023
+ var initializedDestinations = /* @__PURE__ */ new Set();
6024
+ function clarityWindow() {
6025
+ return typeof window === "undefined" ? null : window;
5998
6026
  }
6027
+ function prepareClarity(projectId) {
6028
+ const target = clarityWindow();
6029
+ if (!target || initializedDestinations.has(projectId)) return;
6030
+ if (!target.clarity) {
6031
+ const clarity = function() {
6032
+ clarity.q ??= [];
6033
+ clarity.q.push(arguments);
6034
+ };
6035
+ target.clarity = clarity;
6036
+ }
6037
+ initializedDestinations.add(projectId);
6038
+ }
6039
+ function loadClarityScript(projectId, onError) {
6040
+ if (typeof document === "undefined") return;
6041
+ const id = `bacano-clarity-${projectId}`;
6042
+ if (document.getElementById(id)) return;
6043
+ const script = document.createElement("script");
6044
+ script.id = id;
6045
+ script.async = true;
6046
+ script.src = `https://www.clarity.ms/tag/${encodeURIComponent(projectId)}`;
6047
+ script.referrerPolicy = "strict-origin-when-cross-origin";
6048
+ script.onerror = () => onError?.();
6049
+ document.head.appendChild(script);
6050
+ }
6051
+ function sendClarityEvent(eventName) {
6052
+ clarityWindow()?.clarity?.("event", eventName);
6053
+ }
6054
+
6055
+ // libs/sdk/src/marketing/domain.ts
6056
+ function matchesCanonicalMarketingHost(canonicalDomain, currentHostname) {
6057
+ if (canonicalDomain === void 0) return true;
6058
+ if (!canonicalDomain?.trim()) return false;
6059
+ try {
6060
+ const configuredHost = new URL(
6061
+ canonicalDomain.includes("://") ? canonicalDomain : `https://${canonicalDomain}`
6062
+ ).hostname.toLowerCase();
6063
+ return currentHostname.toLowerCase() === configuredHost;
6064
+ } catch {
6065
+ return false;
6066
+ }
6067
+ }
6068
+
6069
+ // libs/sdk/src/marketing/runtime.ts
5999
6070
  function createWebsiteMarketingRuntime(options) {
6000
6071
  let destroyed = false;
6072
+ const dispatched = /* @__PURE__ */ new Set();
6073
+ const observer = options.diagnostic;
6074
+ const diagnostic = (value) => {
6075
+ try {
6076
+ observer?.(value);
6077
+ } catch {
6078
+ }
6079
+ };
6080
+ options = { ...options, diagnostic };
6001
6081
  let cancelScheduledLoad = () => void 0;
6002
6082
  const target = marketingWindow();
6003
- const enabled = options.config.enabled && Boolean(
6083
+ const enabled = options.config.enabled && options.measurementAllowed !== false && Boolean(
6004
6084
  target && matchesCanonicalMarketingHost(
6005
6085
  options.canonicalDomain,
6006
6086
  target.location.hostname
@@ -6008,25 +6088,59 @@ function createWebsiteMarketingRuntime(options) {
6008
6088
  ) && Boolean(
6009
6089
  options.config.destinations.ga4 || options.config.destinations.meta || options.config.destinations.clarity
6010
6090
  );
6091
+ if (!enabled)
6092
+ diagnostic({
6093
+ level: "debug",
6094
+ code: !options.config.enabled ? "MARKETING_DISABLED" : options.measurementAllowed === false ? "MEASUREMENT_NOT_ALLOWED" : !target ? "SERVER_RENDER" : !matchesCanonicalMarketingHost(
6095
+ options.canonicalDomain,
6096
+ target.location.hostname
6097
+ ) ? "DOMAIN_NOT_ALLOWED" : "PROVIDER_NOT_CONFIGURED",
6098
+ message: "Measurement is inactive; no events are emitted"
6099
+ });
6011
6100
  if (enabled) {
6101
+ const guard = (provider, action) => {
6102
+ try {
6103
+ action();
6104
+ } catch {
6105
+ diagnostic({
6106
+ level: "warning",
6107
+ code: "RUNTIME_INITIALIZATION_FAILED",
6108
+ message: `${provider}: initialization failed`
6109
+ });
6110
+ }
6111
+ };
6112
+ const scriptError = (provider) => () => diagnostic({
6113
+ level: "warning",
6114
+ code: "SCRIPT_LOAD_FAILED",
6115
+ message: `${provider}: script could not load (network, CSP or blocker); external reception is unverified`
6116
+ });
6012
6117
  try {
6013
6118
  if (options.config.destinations.ga4) {
6014
- prepareGa4(options.config.destinations.ga4.measurementId);
6119
+ const { measurementId } = options.config.destinations.ga4;
6120
+ guard("GA4", () => prepareGa4(measurementId));
6015
6121
  }
6016
6122
  if (options.config.destinations.meta) {
6017
- prepareMeta(options.config.destinations.meta.pixelId);
6123
+ const { pixelId } = options.config.destinations.meta;
6124
+ guard("Meta", () => prepareMeta(pixelId));
6018
6125
  }
6019
6126
  if (options.config.destinations.clarity) {
6020
- prepareClarity(options.config.destinations.clarity.projectId);
6127
+ const { projectId } = options.config.destinations.clarity;
6128
+ guard("Clarity", () => prepareClarity(projectId));
6021
6129
  }
6022
6130
  cancelScheduledLoad = scheduleScriptLoad(() => {
6023
6131
  if (destroyed) return;
6024
6132
  if (options.config.destinations.ga4) {
6025
- loadGa4Script(options.config.destinations.ga4.measurementId);
6133
+ const { measurementId } = options.config.destinations.ga4;
6134
+ guard("GA4", () => loadGa4Script(measurementId, scriptError("GA4")));
6026
6135
  }
6027
- if (options.config.destinations.meta) loadMetaScript();
6136
+ if (options.config.destinations.meta)
6137
+ guard("Meta", () => loadMetaScript(scriptError("Meta")));
6028
6138
  if (options.config.destinations.clarity) {
6029
- loadClarityScript(options.config.destinations.clarity.projectId);
6139
+ const { projectId } = options.config.destinations.clarity;
6140
+ guard(
6141
+ "Clarity",
6142
+ () => loadClarityScript(projectId, scriptError("Clarity"))
6143
+ );
6030
6144
  }
6031
6145
  }, options.scriptLoadStrategy);
6032
6146
  captureWebsiteMarketingAttribution(
@@ -6048,11 +6162,11 @@ function createWebsiteMarketingRuntime(options) {
6048
6162
  if (options.config.destinations.meta) {
6049
6163
  refreshWebsiteMarketingMetaContext(options.websiteId);
6050
6164
  }
6051
- } catch (error) {
6165
+ } catch {
6052
6166
  options.diagnostic?.({
6053
6167
  level: "warning",
6054
6168
  code: "RUNTIME_INITIALIZATION_FAILED",
6055
- message: error instanceof Error ? error.message : "Initialization failed"
6169
+ message: "Marketing initialization failed; external reception is unverified"
6056
6170
  });
6057
6171
  }
6058
6172
  }
@@ -6060,41 +6174,96 @@ function createWebsiteMarketingRuntime(options) {
6060
6174
  enabled,
6061
6175
  track(input) {
6062
6176
  if (!enabled || destroyed) return;
6177
+ if (!isBrowserMarketingEvent(input.eventName)) {
6178
+ diagnostic({
6179
+ level: "warning",
6180
+ code: "INVALID_EVENT",
6181
+ message: "Unknown or server-owned event rejected"
6182
+ });
6183
+ return;
6184
+ }
6185
+ try {
6186
+ captureWebsiteMarketingAttribution(
6187
+ options.websiteId,
6188
+ options.config.attributionWindowDays
6189
+ );
6190
+ } catch {
6191
+ }
6063
6192
  const event = {
6064
6193
  ...input,
6065
6194
  schemaVersion: 1,
6066
- eventId: input.eventId ?? newEventId(),
6195
+ eventId: input.eventId ?? newMarketingEventId(),
6067
6196
  occurredAt: input.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
6068
6197
  currency: input.currency ?? options.config.currencyCode,
6069
6198
  pageLocation: sanitizeMarketingUrl(
6070
6199
  input.pageLocation ?? (typeof window === "undefined" ? "" : window.location.href)
6071
6200
  )
6072
6201
  };
6073
- try {
6074
- if (options.config.destinations.ga4) {
6075
- sendGa4(event, options.config.destinations.ga4.diagnosticMode);
6202
+ if (isInteractionEvent(event.eventName)) {
6203
+ if (!interactionParameters(event) || !/^[a-zA-Z0-9_-]{1,100}$/.test(event.eventId)) {
6204
+ diagnostic({
6205
+ level: "warning",
6206
+ code: "INVALID_INTERACTION",
6207
+ message: "Interaction rejected by the public contract",
6208
+ eventName: event.eventName
6209
+ });
6210
+ return;
6076
6211
  }
6077
- if (options.config.destinations.meta) sendMeta(event);
6078
- if (options.config.destinations.clarity) {
6079
- sendClarityEvent(event.eventName);
6212
+ for (const key of [
6213
+ "currency",
6214
+ "value",
6215
+ "searchTerm",
6216
+ "itemListId",
6217
+ "itemListName",
6218
+ "shippingTier",
6219
+ "paymentType",
6220
+ "contactMethod",
6221
+ "contactPlacement",
6222
+ "items",
6223
+ "pageTitle"
6224
+ ])
6225
+ delete event[key];
6226
+ }
6227
+ const identity = `${event.eventName}:${event.eventId}`;
6228
+ if (dispatched.has(identity)) return;
6229
+ dispatched.add(identity);
6230
+ const oldest = dispatched.values().next().value;
6231
+ if (dispatched.size > 1e3 && oldest) dispatched.delete(oldest);
6232
+ const dispatch = (provider, send) => {
6233
+ try {
6234
+ send();
6235
+ diagnostic({
6236
+ level: "debug",
6237
+ code: "EVENT_DISPATCHED",
6238
+ message: `${provider}: passed to adapter; external reception is unverified`,
6239
+ eventName: event.eventName
6240
+ });
6241
+ } catch {
6242
+ diagnostic({
6243
+ level: "warning",
6244
+ code: "EVENT_DISPATCH_FAILED",
6245
+ message: `${provider}: adapter failed`,
6246
+ eventName: event.eventName
6247
+ });
6080
6248
  }
6081
- options.diagnostic?.({
6082
- level: "debug",
6083
- code: "EVENT_DISPATCHED",
6084
- message: "Marketing event dispatched",
6085
- eventName: event.eventName
6086
- });
6087
- } catch (error) {
6088
- options.diagnostic?.({
6089
- level: "warning",
6090
- code: "EVENT_DISPATCH_FAILED",
6091
- message: error instanceof Error ? error.message : "Dispatch failed",
6092
- eventName: event.eventName
6093
- });
6249
+ };
6250
+ if (options.config.destinations.ga4) {
6251
+ const ga4 = options.config.destinations.ga4;
6252
+ dispatch(
6253
+ "GA4",
6254
+ () => sendGa4(event, ga4.diagnosticMode, ga4.measurementId)
6255
+ );
6256
+ }
6257
+ if (options.config.destinations.meta) {
6258
+ const meta = options.config.destinations.meta;
6259
+ dispatch("Meta", () => sendMeta(event, meta.pixelId));
6260
+ }
6261
+ if (options.config.destinations.clarity) {
6262
+ dispatch("Clarity", () => sendClarityEvent(event.eventName));
6094
6263
  }
6095
6264
  },
6096
6265
  captureAttribution() {
6097
- if (!enabled) return null;
6266
+ if (!enabled || destroyed) return null;
6098
6267
  captureWebsiteMarketingAttribution(
6099
6268
  options.websiteId,
6100
6269
  options.config.attributionWindowDays
@@ -6105,7 +6274,7 @@ function createWebsiteMarketingRuntime(options) {
6105
6274
  return getWebsiteMarketingAttribution(options.websiteId);
6106
6275
  },
6107
6276
  getAttribution() {
6108
- if (!enabled) return null;
6277
+ if (!enabled || destroyed) return null;
6109
6278
  if (options.config.destinations.meta) {
6110
6279
  refreshWebsiteMarketingMetaContext(options.websiteId);
6111
6280
  }