@inspira-npm/events 0.1.0 → 0.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/catalog.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as EventProps, c as isEventName, d as EventDefinition, f as EventSource, i as EventName, l as toJsonCatalog, n as Catalog, o as ImpressionEventName, r as CatalogJson, s as catalog, t as BrowserEventName, u as validateEventProps } from "./index-BdIq1xly.js";
1
+ import { a as EventProps, c as isEventName, d as EventDefinition, f as EventSource, i as EventName, l as toJsonCatalog, n as Catalog, o as ImpressionEventName, r as CatalogJson, s as catalog, t as BrowserEventName, u as validateEventProps } from "./index-BShrVaIU.js";
2
2
  export { BrowserEventName, Catalog, CatalogJson, type EventDefinition, EventName, EventProps, type EventSource, ImpressionEventName, catalog, isEventName, toJsonCatalog, validateEventProps };
package/dist/catalog.js CHANGED
@@ -1,30 +1,10 @@
1
- import { n as SDK_VERSION } from "./version-Cqps0u2Y.js";
1
+ import { n as SDK_VERSION } from "./version-DPt-j93d.js";
2
+ import { a as service, i as position, n as itemId, r as placement, t as currency } from "./shared-D2s2QSYg.js";
2
3
  import { z } from "zod";
3
4
  // src/catalog/define.ts
4
5
  function defineEvent(definition) {
5
6
  return definition;
6
7
  }
7
- // src/catalog/shared.ts
8
- /**
9
- * Dónde se mostró algo, como `<página>.<sección>` en minúsculas: `home.resorts-carousel`,
10
- * `hotels-srp.side-rail`, `landing-ufc.hero`. Es la dimensión principal de los reportes:
11
- * mantenla estable (no metas ids ni textos traducidos).
12
- */
13
- const placement = z.string().max(100).regex(/^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/, "placement: <pagina>.<seccion> en minúsculas").describe("Dónde se mostró: <pagina>.<seccion>, p. ej. home.resorts-carousel");
14
- /** Posición 0-based dentro de un carrusel/lista. */
15
- const position = z.int().nonnegative().describe("Posición 0-based en la lista o carrusel");
16
- const itemId = z.string().min(1).max(200);
17
- /** Servicios de búsqueda/reserva de la plataforma. */
18
- const service = z.enum([
19
- "hotels",
20
- "resorts",
21
- "flights",
22
- "cars",
23
- "activities",
24
- "transfers",
25
- "cruises"
26
- ]).describe("Servicio de la plataforma");
27
- const currency = z.string().regex(/^[A-Z]{3}$/, "currency: código ISO 4217 en mayúsculas").describe("Código ISO 4217, p. ej. USD");
28
8
  // src/catalog/events/ads.ts
29
9
  const adProps = {
30
10
  ad_id: z.string().min(1).max(100).describe("`id` del ad tal como lo devuelve el ads engine"),
@@ -104,7 +84,7 @@ const conversionEvents = { conversion: defineEvent({
104
84
  service: service.optional(),
105
85
  value: z.number().nonnegative().optional().describe("Monto de la conversión"),
106
86
  currency: currency.optional(),
107
- click_id: z.uuid().optional().describe("`ipc` capturado en el sitio destino; atribuye la conversión al ad clickeado")
87
+ click_id: z.uuid().optional().describe("`tracker.getClickId()` del front; atribuye la conversión al click (`ad_click` u `outbound_click`) con ese id")
108
88
  })
109
89
  }) };
110
90
  // src/catalog/events/map.ts
@@ -140,6 +120,20 @@ const mapEvents = {
140
120
  })
141
121
  })
142
122
  };
123
+ // src/catalog/events/navigation.ts
124
+ const navigationEvents = { outbound_click: defineEvent({
125
+ domain: "navigation",
126
+ source: "browser",
127
+ since: "0.2.0",
128
+ description: "Click en un link o CTA medido que lleva a otro sitio o página (p. ej. CTA de una landing → sitio principal). `click_id` viaja en la URL de destino (`?ipc=`); `via_click_id` encadena la atribución con el click por el que llegó el visitante.",
129
+ props: z.strictObject({
130
+ placement,
131
+ item_id: itemId.optional().describe("Id del ítem al que lleva el CTA (hotel, resort, oferta…), si lo hay"),
132
+ click_id: z.uuid().describe("Id único del click; también va en la URL de destino como ?ipc="),
133
+ via_click_id: z.uuid().optional().describe("Click id con el que LLEGÓ el visitante (`?ipc=` capturado en este sitio); lo pone el SDK"),
134
+ destination_url: z.string().max(2e3).optional().describe("URL a la que lleva el click")
135
+ })
136
+ }) };
143
137
  // src/catalog/events/page.ts
144
138
  const pageEvents = { page_view: defineEvent({
145
139
  domain: "page",
@@ -216,6 +210,7 @@ const catalog = {
216
210
  ...cardEvents,
217
211
  ...mapEvents,
218
212
  ...searchEvents,
213
+ ...navigationEvents,
219
214
  ...conversionEvents
220
215
  };
221
216
  function isEventName(name) {
package/dist/catalog.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "sdk_version": "0.1.0",
2
+ "sdk_version": "0.3.0",
3
3
  "events": {
4
4
  "page_view": {
5
5
  "description": "Vista de página. Se dispara al cargar y en cada navegación del router (SPA). La URL ya va en el contexto del evento.",
@@ -553,6 +553,52 @@
553
553
  "additionalProperties": false
554
554
  }
555
555
  },
556
+ "outbound_click": {
557
+ "description": "Click en un link o CTA medido que lleva a otro sitio o página (p. ej. CTA de una landing → sitio principal). `click_id` viaja en la URL de destino (`?ipc=`); `via_click_id` encadena la atribución con el click por el que llegó el visitante.",
558
+ "domain": "navigation",
559
+ "source": "browser",
560
+ "since": "0.2.0",
561
+ "schema": {
562
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
563
+ "type": "object",
564
+ "properties": {
565
+ "placement": {
566
+ "type": "string",
567
+ "maxLength": 100,
568
+ "pattern": "^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$",
569
+ "description": "Dónde se mostró: <pagina>.<seccion>, p. ej. home.resorts-carousel"
570
+ },
571
+ "item_id": {
572
+ "description": "Id del ítem al que lleva el CTA (hotel, resort, oferta…), si lo hay",
573
+ "type": "string",
574
+ "minLength": 1,
575
+ "maxLength": 200
576
+ },
577
+ "click_id": {
578
+ "type": "string",
579
+ "format": "uuid",
580
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
581
+ "description": "Id único del click; también va en la URL de destino como ?ipc="
582
+ },
583
+ "via_click_id": {
584
+ "description": "Click id con el que LLEGÓ el visitante (`?ipc=` capturado en este sitio); lo pone el SDK",
585
+ "type": "string",
586
+ "format": "uuid",
587
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
588
+ },
589
+ "destination_url": {
590
+ "description": "URL a la que lleva el click",
591
+ "type": "string",
592
+ "maxLength": 2000
593
+ }
594
+ },
595
+ "required": [
596
+ "placement",
597
+ "click_id"
598
+ ],
599
+ "additionalProperties": false
600
+ }
601
+ },
556
602
  "conversion": {
557
603
  "description": "Conversión confirmada (reserva, lead, registro). La envía un BACKEND server-to-server, nunca el navegador. Idempotente por `conversion_id`.",
558
604
  "domain": "conversions",
@@ -602,7 +648,7 @@
602
648
  "description": "Código ISO 4217, p. ej. USD"
603
649
  },
604
650
  "click_id": {
605
- "description": "`ipc` capturado en el sitio destino; atribuye la conversión al ad clickeado",
651
+ "description": "`tracker.getClickId()` del front; atribuye la conversión al click (`ad_click` u `outbound_click`) con ese id",
606
652
  "type": "string",
607
653
  "format": "uuid",
608
654
  "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
@@ -0,0 +1,31 @@
1
+ /** Opciones de `TrackerConfig.clickRecord`. */
2
+ interface ClickRecordOptions {
3
+ /**
4
+ * Ruta del mismo origen que expone el sitio con `createAdClickRoute`, p. ej. `/api/ad-click`.
5
+ * Una URL de otro origen se ignora (con un aviso en consola): la ruta necesita la cookie de
6
+ * sesión del sitio.
7
+ */
8
+ endpoint: string;
9
+ /**
10
+ * `true`: solo se registra el click con consentimiento de estadísticas `granted`. Default
11
+ * `false`: se registran todos los clicks, con o sin consentimiento (lo pidió negocio; legal
12
+ * puede pedir activarlo).
13
+ */
14
+ requireConsent?: boolean;
15
+ }
16
+ /** Body que el SDK manda a la ruta del sitio por cada `ad_click` (ver docs/protocol.md). */
17
+ interface AdClickRecordPayload {
18
+ /** Versión del payload. */
19
+ v: 1;
20
+ /** El mismo `click_id` del evento `ad_click` (y del `?ipc=`): une el registro con el evento. */
21
+ click_id: string;
22
+ ad_id: string;
23
+ token?: string;
24
+ placement: string;
25
+ position?: number;
26
+ /** Página donde ocurrió el click, saneada igual que `ctx.url` (origin + path + allowlist). */
27
+ page_url: string;
28
+ /** Hora del click según el reloj del cliente (ISO 8601). */
29
+ ts: string;
30
+ }
31
+ export { ClickRecordOptions as n, AdClickRecordPayload as t };
@@ -44,6 +44,13 @@ declare const catalog: {
44
44
  currency: z.ZodOptional<z.ZodString>;
45
45
  click_id: z.ZodOptional<z.ZodUUID>;
46
46
  }, z.core.$strict>, "server">;
47
+ outbound_click: EventDefinition<z.ZodObject<{
48
+ placement: z.ZodString;
49
+ item_id: z.ZodOptional<z.ZodString>;
50
+ click_id: z.ZodUUID;
51
+ via_click_id: z.ZodOptional<z.ZodUUID>;
52
+ destination_url: z.ZodOptional<z.ZodString>;
53
+ }, z.core.$strict>, "browser">;
47
54
  search_performed: EventDefinition<z.ZodObject<{
48
55
  search_id: z.ZodUUID;
49
56
  service: z.ZodEnum<{
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { a as EventProps, i as EventName, o as ImpressionEventName, t as BrowserEventName } from "./index-BdIq1xly.js";
1
+ import { a as EventProps, i as EventName, o as ImpressionEventName, t as BrowserEventName } from "./index-BShrVaIU.js";
2
+ import { n as ClickRecordOptions, t as AdClickRecordPayload } from "./click-record-B8KyALZZ.js";
2
3
  /** Query param que lleva el click id de un ad hasta el sitio destino. */
3
4
  export declare const CLICK_ID_PARAM = "ipc";
4
5
  export declare function isClickId(value: unknown): value is string;
@@ -9,6 +10,17 @@ export declare function isClickId(value: unknown): value is string;
9
10
  export declare function withClickId(url: string, clickId: string, param?: string): string;
10
11
  /** Lee un click id válido de una URL (o null). */
11
12
  export declare function readClickIdFromUrl(href: string, param?: string): string | null;
13
+ /**
14
+ * Copia a `url` los params de atribución de `from` (por defecto, la página actual): `ipc`,
15
+ * `utm_*`, `gclid` y `fbclid`. Los que `url` ya trae no se pisan. Igual que `withClickId`, el
16
+ * resto de la query y el hash quedan intactos (nada se vuelve a codificar) y las URLs relativas
17
+ * siguen siendo relativas. Si no hay nada que copiar, o alguna URL no se puede parsear, devuelve
18
+ * `url` tal cual.
19
+ *
20
+ * Para sitios intermedios (landings) que arman la URL de regreso: sin esto el `?ipc=` y las
21
+ * `utm_*` con las que llegó el visitante se pierden en el siguiente salto.
22
+ */
23
+ export declare function withTrackingParams(url: string, from?: string): string;
12
24
  /**
13
25
  * - `granted`: aceptó cookies de estadísticas → ids persistentes (anon/session/user).
14
26
  * - `denied`: solo necesarias → eventos sin ningún identificador ni storage.
@@ -92,6 +104,15 @@ interface TrackerConfig {
92
104
  * utm_source, utm_medium, utm_campaign, utm_term, utm_content, ipc, gclid, fbclid.
93
105
  */
94
106
  urlParamAllowlist?: readonly string[];
107
+ /**
108
+ * Registro de cada click en un ad (`trackAdClick` / `useAdClick`) en el servidor del sitio,
109
+ * aparte del evento `ad_click`: ruta del mismo origen (p. ej. `/api/ad-click`) creada con
110
+ * `createAdClickRoute` de `@inspira-npm/events/server`, que agrega usuario, IP y organización
111
+ * desde la sesión. Con un string, o sin `requireConsent`, se registran TODOS los clicks, con o
112
+ * sin consentimiento; `{ endpoint, requireConsent: true }` lo limita a `granted`. Ver README,
113
+ * "Registro de clicks (servidor)".
114
+ */
115
+ clickRecord?: string | ClickRecordOptions;
95
116
  /** Loguea en consola los eventos y los rechazos del collector. */
96
117
  debug?: boolean;
97
118
  /** Reemplaza el envío HTTP (tests, entornos especiales). */
@@ -120,6 +141,11 @@ interface BatchPayload {
120
141
  events: EventEnvelope[];
121
142
  }
122
143
  type AdClickProps = Omit<EventProps<"ad_click">, "click_id" | "destination_url">;
144
+ /**
145
+ * Props de `outbound_click` que pasa el sitio: `click_id`, `destination_url` y `via_click_id`
146
+ * los pone el SDK.
147
+ */
148
+ type OutboundClickProps = Omit<EventProps<"outbound_click">, "click_id" | "destination_url" | "via_click_id">;
123
149
  interface Tracker {
124
150
  /**
125
151
  * Encola un evento del catálogo. Devuelve su id, o null si no se encoló: tracker no-op (SSR),
@@ -128,21 +154,35 @@ interface Tracker {
128
154
  track<N extends BrowserEventName>(name: N, props: EventProps<N>): string | null;
129
155
  /** Mide una impresión VISIBLE de `element` (≥50% durante ≥1 s por defecto). Devuelve el cleanup. */
130
156
  observeImpression<N extends ImpressionEventName>(element: Element, name: N, props: EventProps<N>, options?: ImpressionOptions): () => void;
131
- /** Registra `ad_click` con un click id nuevo y devuelve `url` con `?ipc=<clickId>`. */
157
+ /**
158
+ * Registra `ad_click` con un click id nuevo y devuelve `url` con `?ipc=<clickId>`. Con
159
+ * consentimiento, guarda además el click id en este sitio (ver `getClickId`). Con
160
+ * `clickRecord` configurado, manda también el registro del click al servidor del sitio.
161
+ */
132
162
  trackAdClick(props: AdClickProps, url: string): string;
163
+ /**
164
+ * Como `trackAdClick`, para un link o CTA medido hacia otro sitio o página: registra
165
+ * `outbound_click` (con `via_click_id` = el click id con el que llegó el visitante, si hay) y
166
+ * devuelve `url` con `?ipc=<clickId>`.
167
+ */
168
+ trackOutboundClick(props: OutboundClickProps, url: string): string;
133
169
  /**
134
170
  * En el sitio destino: lee `?ipc=` de la URL y, con consentimiento, lo guarda para la conversión.
135
171
  * Si el usuario acepta el banner después, se vuelve a capturar solo (ver CONSENT_CHANGE_EVENT).
136
172
  */
137
173
  captureClickId(): string | null;
138
- /** Click id vigente (URL actual o guardado hace ≤ `maxAgeDays`, default 7) para enviar con la conversión. */
174
+ /**
175
+ * Click id para enviar con la conversión: el `?ipc=` de la URL actual o, si no hay, el más
176
+ * reciente entre el capturado al llegar y el generado en un click de este sitio, guardados hace
177
+ * ≤ `maxAgeDays` (default 7). Sin consentimiento solo cuenta el de la URL.
178
+ */
139
179
  getClickId(options?: {
140
180
  maxAgeDays?: number;
141
181
  }): string | null;
142
182
  /** Usuario logueado. Solo se envía con consentimiento `granted`. */
143
183
  identify(userId: string | number | null): void;
144
184
  setWl(wl: number | null): void;
145
- /** Logout: olvida usuario, ids y click id guardado. */
185
+ /** Logout: olvida usuario, ids y click ids guardados. */
146
186
  reset(): void;
147
187
  /** Envía lo que haya en cola. */
148
188
  flush(): Promise<void>;
@@ -158,5 +198,5 @@ export declare function createTracker(config: TrackerConfig): Tracker;
158
198
  /** UUID v4. `crypto.randomUUID` solo existe en contextos seguros (https/localhost). */
159
199
  declare function uuid(): string;
160
200
  export declare const SDK_NAME = "@inspira-npm/events";
161
- export declare const SDK_VERSION = "0.1.0";
162
- export { type AdClickProps, type BatchPayload, type BrowserEventName, type ConsentState, type EventContext, type EventEnvelope, type EventName, type EventProps, type ImpressionEventName, type ImpressionOptions, type SendResult, type Tracker, type TrackerConfig, type Transport, type TransportOptions, uuid as newId };
201
+ export declare const SDK_VERSION = "0.3.0";
202
+ export { type AdClickProps, type AdClickRecordPayload, type BatchPayload, type BrowserEventName, type ClickRecordOptions, type ConsentState, type EventContext, type EventEnvelope, type EventName, type EventProps, type ImpressionEventName, type ImpressionOptions, type OutboundClickProps, type SendResult, type Tracker, type TrackerConfig, type Transport, type TransportOptions, uuid as newId };
package/dist/index.js CHANGED
@@ -1,4 +1,40 @@
1
- import { n as SDK_VERSION, t as SDK_NAME } from "./version-Cqps0u2Y.js";
1
+ import { n as SDK_VERSION, t as SDK_NAME } from "./version-DPt-j93d.js";
2
+ // src/query.ts
3
+ /**
4
+ * Reescribe la query de `url` editando sus pares CRUDOS (`clave=valor` tal como están en la URL,
5
+ * sin decodificar). Conserva el hash; las URLs relativas siguen siendo relativas. Si la URL no se
6
+ * puede parsear se devuelve igual.
7
+ *
8
+ * Se edita la query cruda a propósito: `searchParams.set` re-serializa TODOS los params
9
+ * (%20 → +, `flag` → `flag=`, `~` → %7E…) y eso rompe URLs firmadas o de afiliados.
10
+ */
11
+ function editRawQuery(url, edit) {
12
+ const isAbsolute = /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//");
13
+ try {
14
+ const parsed = new URL(url, currentHref());
15
+ parsed.search = edit(rawPairs(parsed.search)).join("&");
16
+ return isAbsolute ? parsed.toString() : `${parsed.pathname}${parsed.search}${parsed.hash}`;
17
+ } catch {
18
+ return url;
19
+ }
20
+ }
21
+ /** Pares crudos de una query (`?a=1&b` → `["a=1", "b"]`), sin los vacíos. */
22
+ function rawPairs(search) {
23
+ return search.replace(/^\?/, "").split("&").filter((pair) => pair !== "");
24
+ }
25
+ /** Nombre decodificado de un par crudo (`utm%5Fsource=x` → `utm_source`). */
26
+ function pairKey(pair) {
27
+ const raw = pair.split("=", 1)[0] ?? "";
28
+ try {
29
+ return decodeURIComponent(raw.replace(/\+/g, " "));
30
+ } catch {
31
+ return raw;
32
+ }
33
+ }
34
+ /** Base para resolver URLs relativas: la página actual, o una ficticia en el servidor. */
35
+ function currentHref() {
36
+ return typeof location !== "undefined" ? location.href : "https://placeholder.invalid/";
37
+ }
2
38
  // src/click-id.ts
3
39
  /** Query param que lleva el click id de un ad hasta el sitio destino. */
4
40
  const CLICK_ID_PARAM = "ipc";
@@ -11,17 +47,7 @@ function isClickId(value) {
11
47
  * Las URLs relativas siguen siendo relativas. Si la URL no se puede parsear se devuelve igual.
12
48
  */
13
49
  function withClickId(url, clickId, param = "ipc") {
14
- const isAbsolute = /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//");
15
- try {
16
- const base = typeof location !== "undefined" ? location.href : "https://placeholder.invalid/";
17
- const parsed = new URL(url, base);
18
- const kept = parsed.search.slice(1).split("&").filter((pair) => pair !== "" && pair.split("=", 1)[0] !== param);
19
- kept.push(`${encodeURIComponent(param)}=${encodeURIComponent(clickId)}`);
20
- parsed.search = kept.join("&");
21
- return isAbsolute ? parsed.toString() : `${parsed.pathname}${parsed.search}${parsed.hash}`;
22
- } catch {
23
- return url;
24
- }
50
+ return editRawQuery(url, (pairs) => [...pairs.filter((pair) => pairKey(pair) !== param), `${encodeURIComponent(param)}=${encodeURIComponent(clickId)}`]);
25
51
  }
26
52
  /** Lee un click id válido de una URL (o null). */
27
53
  function readClickIdFromUrl(href, param = "ipc") {
@@ -32,6 +58,39 @@ function readClickIdFromUrl(href, param = "ipc") {
32
58
  return null;
33
59
  }
34
60
  }
61
+ function isTrackingParam(key) {
62
+ return key === "ipc" || key === "gclid" || key === "fbclid" || key.startsWith("utm_");
63
+ }
64
+ /**
65
+ * Copia a `url` los params de atribución de `from` (por defecto, la página actual): `ipc`,
66
+ * `utm_*`, `gclid` y `fbclid`. Los que `url` ya trae no se pisan. Igual que `withClickId`, el
67
+ * resto de la query y el hash quedan intactos (nada se vuelve a codificar) y las URLs relativas
68
+ * siguen siendo relativas. Si no hay nada que copiar, o alguna URL no se puede parsear, devuelve
69
+ * `url` tal cual.
70
+ *
71
+ * Para sitios intermedios (landings) que arman la URL de regreso: sin esto el `?ipc=` y las
72
+ * `utm_*` con las que llegó el visitante se pierden en el siguiente salto.
73
+ */
74
+ function withTrackingParams(url, from) {
75
+ let search;
76
+ try {
77
+ const href = from ?? (typeof location !== "undefined" ? location.href : "");
78
+ if (!href) return url;
79
+ search = new URL(href, currentHref()).search;
80
+ } catch {
81
+ return url;
82
+ }
83
+ const carried = rawPairs(search).filter((pair) => isTrackingParam(pairKey(pair)));
84
+ if (carried.length === 0) return url;
85
+ let added = false;
86
+ const out = editRawQuery(url, (pairs) => {
87
+ const present = new Set(pairs.map(pairKey));
88
+ const missing = carried.filter((pair) => !present.has(pairKey(pair)));
89
+ added = missing.length > 0;
90
+ return [...pairs, ...missing];
91
+ });
92
+ return added ? out : url;
93
+ }
35
94
  // src/consent.ts
36
95
  const DEFAULT_CONSENT_COOKIE = "CookieConsent";
37
96
  /** Evento que dispara el banner de inspira-frontend-v2 al guardar el consentimiento. */
@@ -117,6 +176,50 @@ function collectContext(allowlist) {
117
176
  }
118
177
  };
119
178
  }
179
+ // src/click-record.ts
180
+ const MAX_PAGE_URL_LENGTH = 2e3;
181
+ const CONTENT_TYPE$1 = "text/plain;charset=UTF-8";
182
+ /**
183
+ * Normaliza la opción. Devuelve null si no está configurada o no es del mismo origen que la
184
+ * página (el aviso va siempre a consola: un error de configuración perdería registros en
185
+ * silencio).
186
+ */
187
+ function resolveClickRecord(option) {
188
+ if (!option) return null;
189
+ const { endpoint, requireConsent = false } = typeof option === "string" ? { endpoint: option } : option;
190
+ if (!endpoint) return null;
191
+ try {
192
+ const url = new URL(endpoint, location.href);
193
+ if (url.origin === location.origin) return {
194
+ url: url.href,
195
+ requireConsent
196
+ };
197
+ } catch {}
198
+ console.warn("[inspira-events] clickRecord.endpoint debe ser una ruta del mismo origen; registro de clicks desactivado:", endpoint);
199
+ return null;
200
+ }
201
+ /**
202
+ * Envía el registro sin esperar respuesta: `sendBeacon` (sobrevive a la navegación que dispara
203
+ * el click) o, si el navegador lo rechaza, `fetch` con `keepalive`. Nunca lanza.
204
+ */
205
+ function sendClickRecord(url, payload) {
206
+ const body = JSON.stringify(payload);
207
+ try {
208
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
209
+ if (navigator.sendBeacon(url, new Blob([body], { type: CONTENT_TYPE$1 }))) return;
210
+ }
211
+ } catch {}
212
+ try {
213
+ if (typeof fetch !== "function") return;
214
+ fetch(url, {
215
+ method: "POST",
216
+ body,
217
+ headers: { "Content-Type": CONTENT_TYPE$1 },
218
+ keepalive: true,
219
+ credentials: "same-origin"
220
+ }).catch(() => {});
221
+ } catch {}
222
+ }
120
223
  // src/storage.ts
121
224
  /**
122
225
  * localStorage/sessionStorage que nunca lanza: en Safari privado, con storage bloqueado o
@@ -347,13 +450,22 @@ async function logRejected(response) {
347
450
  // src/tracker.ts
348
451
  const MAX_ATTEMPTS = 3;
349
452
  const CLICK_ID_STORAGE_KEY = "ievt_ipc";
453
+ const OWN_CLICK_ID_STORAGE_KEY = "ievt_ipc_own";
350
454
  const MAX_DESTINATION_URL_LENGTH = 2e3;
351
455
  const DAY_MS = 864e5;
456
+ const DEFAULT_MAX_AGE_DAYS = 7;
457
+ function clickProps(clickId, destination) {
458
+ return {
459
+ click_id: clickId,
460
+ destination_url: destination.slice(0, MAX_DESTINATION_URL_LENGTH)
461
+ };
462
+ }
352
463
  function createNoopTracker() {
353
464
  return {
354
465
  track: () => null,
355
466
  observeImpression: () => () => {},
356
467
  trackAdClick: (_props, url) => url,
468
+ trackOutboundClick: (_props, url) => url,
357
469
  captureClickId: () => null,
358
470
  getClickId: () => null,
359
471
  identify: () => {},
@@ -378,6 +490,7 @@ function createTracker(config) {
378
490
  const url = `${config.endpoint.replace(/\/+$/, "")}/e/v1/batch?k=${encodeURIComponent(config.siteKey)}`;
379
491
  const identity = createIdentity();
380
492
  const clickStore = safeStorage("local");
493
+ const clickRecord = resolveClickRecord(config.clickRecord);
381
494
  const log = (...args) => {
382
495
  if (debug) console.debug("[inspira-events]", ...args);
383
496
  };
@@ -484,22 +597,51 @@ function createTracker(config) {
484
597
  };
485
598
  const forgetStoredIds = () => {
486
599
  identity.clear();
487
- if (clickStore.get(CLICK_ID_STORAGE_KEY) !== null) clickStore.remove(CLICK_ID_STORAGE_KEY);
600
+ for (const key of [CLICK_ID_STORAGE_KEY, OWN_CLICK_ID_STORAGE_KEY]) if (clickStore.get(key) !== null) clickStore.remove(key);
601
+ };
602
+ const readStoredClickId = (key, maxAgeDays) => {
603
+ try {
604
+ const stored = JSON.parse(clickStore.get(key) ?? "null");
605
+ if (stored && isClickId(stored.id) && typeof stored.ts === "number" && Date.now() - stored.ts <= maxAgeDays * DAY_MS) return {
606
+ id: stored.id,
607
+ ts: stored.ts
608
+ };
609
+ } catch {}
610
+ return null;
611
+ };
612
+ const storeClickId = (key, entry) => {
613
+ clickStore.set(key, JSON.stringify(entry));
488
614
  };
489
615
  const captureClickId = () => {
490
616
  const clickId = readClickIdFromUrl(location.href);
491
- if (clickId && consent() === "granted") clickStore.set(CLICK_ID_STORAGE_KEY, JSON.stringify({
617
+ if (clickId && consent() === "granted") {
618
+ const previous = readStoredClickId(CLICK_ID_STORAGE_KEY, Number.POSITIVE_INFINITY);
619
+ const ts = previous?.id === clickId ? previous.ts : Date.now();
620
+ storeClickId(CLICK_ID_STORAGE_KEY, {
621
+ id: clickId,
622
+ ts
623
+ });
624
+ }
625
+ return clickId;
626
+ };
627
+ const arrivedClickId = () => {
628
+ const fromUrl = readClickIdFromUrl(location.href);
629
+ if (fromUrl) return fromUrl;
630
+ if (consent() !== "granted") return null;
631
+ return readStoredClickId(CLICK_ID_STORAGE_KEY, DEFAULT_MAX_AGE_DAYS)?.id ?? null;
632
+ };
633
+ const rememberOwnClickId = (clickId) => {
634
+ if (consent() === "granted") storeClickId(OWN_CLICK_ID_STORAGE_KEY, {
492
635
  id: clickId,
493
636
  ts: Date.now()
494
- }));
495
- return clickId;
637
+ });
496
638
  };
497
639
  const onConsentChange = () => {
498
640
  if (consent() === "granted") captureClickId();
499
641
  else forgetStoredIds();
500
642
  };
501
643
  window.addEventListener(CONSENT_CHANGE_EVENT, onConsentChange);
502
- const track = (name, props) => {
644
+ const emit = (name, props) => {
503
645
  if (closed) return null;
504
646
  let snapshot;
505
647
  try {
@@ -526,7 +668,27 @@ function createTracker(config) {
526
668
  };
527
669
  log("track", event.name, event);
528
670
  enqueue(event);
529
- return event.id;
671
+ return event;
672
+ };
673
+ const track = (name, props) => emit(name, props)?.id ?? null;
674
+ const recordAdClick = (event) => {
675
+ if (!clickRecord) return;
676
+ if (clickRecord.requireConsent && event.consent !== "granted") return;
677
+ try {
678
+ const props = event.props;
679
+ const payload = {
680
+ v: 1,
681
+ click_id: props.click_id,
682
+ ad_id: props.ad_id,
683
+ token: props.token,
684
+ placement: props.placement,
685
+ position: props.position,
686
+ page_url: event.ctx.url.slice(0, MAX_PAGE_URL_LENGTH),
687
+ ts: event.ts
688
+ };
689
+ log("click record", payload);
690
+ sendClickRecord(clickRecord.url, payload);
691
+ } catch {}
530
692
  };
531
693
  return {
532
694
  track,
@@ -536,23 +698,35 @@ function createTracker(config) {
536
698
  },
537
699
  trackAdClick(props, destination) {
538
700
  const clickId = uuid();
539
- track("ad_click", {
701
+ const event = emit("ad_click", {
540
702
  ...props,
541
- click_id: clickId,
542
- destination_url: destination.slice(0, MAX_DESTINATION_URL_LENGTH)
703
+ ...clickProps(clickId, destination)
543
704
  });
705
+ if (event) {
706
+ rememberOwnClickId(clickId);
707
+ recordAdClick(event);
708
+ }
709
+ return withClickId(destination, clickId);
710
+ },
711
+ trackOutboundClick(props, destination) {
712
+ const clickId = uuid();
713
+ const event = {
714
+ ...props,
715
+ ...clickProps(clickId, destination),
716
+ via_click_id: arrivedClickId() ?? void 0
717
+ };
718
+ if (track("outbound_click", event)) rememberOwnClickId(clickId);
544
719
  return withClickId(destination, clickId);
545
720
  },
546
721
  captureClickId,
547
- getClickId({ maxAgeDays = 7 } = {}) {
722
+ getClickId({ maxAgeDays = DEFAULT_MAX_AGE_DAYS } = {}) {
548
723
  const fromUrl = readClickIdFromUrl(location.href);
549
724
  if (fromUrl) return fromUrl;
550
725
  if (consent() !== "granted") return null;
551
- try {
552
- const stored = JSON.parse(clickStore.get(CLICK_ID_STORAGE_KEY) ?? "null");
553
- if (stored && isClickId(stored.id) && typeof stored.ts === "number" && Date.now() - stored.ts <= maxAgeDays * DAY_MS) return stored.id;
554
- } catch {}
555
- return null;
726
+ const arrived = readStoredClickId(CLICK_ID_STORAGE_KEY, maxAgeDays);
727
+ const own = readStoredClickId(OWN_CLICK_ID_STORAGE_KEY, maxAgeDays);
728
+ if (arrived && own) return own.ts >= arrived.ts ? own.id : arrived.id;
729
+ return (own ?? arrived)?.id ?? null;
556
730
  },
557
731
  identify(id) {
558
732
  userId = id === null ? null : String(id);
@@ -576,4 +750,4 @@ function createTracker(config) {
576
750
  }
577
751
  };
578
752
  }
579
- export { CLICK_ID_PARAM, CONSENT_CHANGE_EVENT, DEFAULT_CONSENT_COOKIE, DEFAULT_URL_PARAM_ALLOWLIST, SDK_NAME, SDK_VERSION, cookieConsent, createNoopTracker, createTracker, fetchTransport, isClickId, uuid as newId, parseConsentValue, readClickIdFromUrl, sanitizeUrl, withClickId };
753
+ export { CLICK_ID_PARAM, CONSENT_CHANGE_EVENT, DEFAULT_CONSENT_COOKIE, DEFAULT_URL_PARAM_ALLOWLIST, SDK_NAME, SDK_VERSION, cookieConsent, createNoopTracker, createTracker, fetchTransport, isClickId, uuid as newId, parseConsentValue, readClickIdFromUrl, sanitizeUrl, withClickId, withTrackingParams };
@@ -0,0 +1,141 @@
1
+ import { t as AdClickRecordPayload } from "./click-record-B8KyALZZ.js";
2
+ import { z } from "zod";
3
+ /**
4
+ * IP del cliente desde los headers del request: `cf-connecting-ip`, `true-client-ip`, el primer
5
+ * valor de `x-forwarded-for` y `x-real-ip`, en ese orden. Salta los valores vacíos, inválidos o
6
+ * de relleno (`0`, `::1`, `127.0.0.1`…). Devuelve null si ninguno sirve.
7
+ */
8
+ export declare function resolveClientIp(headers: Headers): string | null;
9
+ /** Datos del usuario logueado, desde la sesión del sitio. Un campo ausente o vacío viaja `null`. */
10
+ interface AdClickUser {
11
+ /** Id del usuario en Inspira. */
12
+ inspira_id?: string | number | null;
13
+ email?: string | null;
14
+ phone?: string | null;
15
+ /** Id del usuario en el sistema del cliente white-label. */
16
+ external_id?: string | number | null;
17
+ loyalty_id?: string | number | null;
18
+ }
19
+ /** Body de `POST {apiBase}/api/v1/ad-clicks` (ver docs/protocol.md). */
20
+ interface AdClickRecordBody {
21
+ click_id: string;
22
+ ad_id: string;
23
+ token?: string;
24
+ placement: string;
25
+ position?: number;
26
+ page_url: string;
27
+ /** `ts` del navegador. */
28
+ client_ts?: string;
29
+ /** Hora del servidor del sitio al recibir el click. */
30
+ clicked_at: string;
31
+ org: number | null;
32
+ ip: string | null;
33
+ user_agent: string | null;
34
+ user: {
35
+ inspira_id: string | null;
36
+ email: string | null;
37
+ phone: string | null;
38
+ external_id: string | null;
39
+ loyalty_id: string | null;
40
+ } | null;
41
+ }
42
+ /** Credenciales y red para hablar con Laravel (`inspira-promotions-laravel`). */
43
+ interface AdClickApiOptions {
44
+ /** URL base de Laravel, p. ej. `https://inspirapromotions.com` (sin `/api`). */
45
+ apiBase: string;
46
+ /** Cliente OAuth2 (client_credentials, scope `ad-clicks:write`) dado de alta en el admin. */
47
+ clientId: string;
48
+ /** Secreto del cliente. Solo servidor: nunca en una variable pública ni en el bundle. */
49
+ clientSecret: string;
50
+ /** Tiempo máximo TOTAL del envío (token incluido). Default 3000 ms. */
51
+ timeoutMs?: number;
52
+ /** Reemplaza `fetch` (tests). Default: el `fetch` global. */
53
+ fetch?: typeof fetch;
54
+ }
55
+ interface RecordAdClickOptions extends AdClickApiOptions {
56
+ /** Body JSON tal como lo mandó el navegador; se valida aquí. */
57
+ payload: unknown;
58
+ /** Usuario logueado, o null/omitido si no hay sesión. */
59
+ user?: AdClickUser | null;
60
+ /** Organización (whitelabel) del tenant, o null. */
61
+ org?: number | null;
62
+ ip?: string | null;
63
+ userAgent?: string | null;
64
+ /** Hora del click en el servidor. Default: ahora. */
65
+ clickedAt?: Date;
66
+ }
67
+ type RecordAdClickFailureReason =
68
+ /** El body del navegador no cumple el contrato (`error` = ZodError). */
69
+ "invalid_payload" |
70
+ /** Falta `apiBase`, `clientId` o `clientSecret`. */
71
+ "not_configured" |
72
+ /** `POST /oauth/token` respondió con error (credenciales o scope). */
73
+ "token_failed" |
74
+ /** 401 aun con un token nuevo. */
75
+ "unauthorized" |
76
+ /** 403: el cliente no tiene el scope `ad-clicks:write`. */
77
+ "forbidden" |
78
+ /** 422: Laravel rechazó el body. */
79
+ "rejected" |
80
+ /** Otro status de error (5xx, 429…). */
81
+ "http_error" | "timeout" | "network_error";
82
+ interface RecordAdClickFailure {
83
+ ok: false;
84
+ reason: RecordAdClickFailureReason;
85
+ /** Status HTTP de la respuesta que falló, si hubo respuesta. */
86
+ httpStatus?: number;
87
+ error?: unknown;
88
+ }
89
+ type RecordAdClickResult = {
90
+ ok: true;
91
+ status: "recorded" | "duplicate";
92
+ httpStatus: number;
93
+ } | RecordAdClickFailure;
94
+ /**
95
+ * Registra un click en Laravel (bajo nivel; `createAdClickRoute` lo usa por dentro): valida el
96
+ * body del navegador, arma el body con usuario, organización, IP y user agent, y lo envía con un
97
+ * token client_credentials cacheado. Nunca lanza.
98
+ */
99
+ export declare function recordAdClick(options: RecordAdClickOptions): Promise<RecordAdClickResult>;
100
+ type MaybePromise<T> = T | Promise<T>;
101
+ interface AdClickRouteErrorInfo {
102
+ /** Dónde falló: la sesión (`get_user`), el tenant (`get_org`) o el envío a Laravel (`record`). */
103
+ stage: "get_user" | "get_org" | "record";
104
+ click_id: string;
105
+ /** Solo en `record`: el resultado del envío. */
106
+ result?: RecordAdClickFailure;
107
+ }
108
+ interface AdClickRouteOptions extends AdClickApiOptions {
109
+ /**
110
+ * Usuario logueado desde la sesión del SITIO (cookie del mismo origen), o null si no hay
111
+ * sesión. Si lanza, el click se registra igual con `user: null` (y se avisa a `onError`).
112
+ */
113
+ getUser: (request: Request) => MaybePromise<AdClickUser | null | undefined>;
114
+ /** Organización (whitelabel) del tenant, o null. Si lanza, `org: null`. */
115
+ getOrg: (request: Request) => MaybePromise<number | null | undefined>;
116
+ /** IP del cliente. Default: `resolveClientIp(request.headers)`. */
117
+ getIp?: (request: Request) => string | null | undefined;
118
+ /**
119
+ * Errores que no llegan al navegador (siempre recibe 204): sesión, tenant y envío a Laravel.
120
+ * Nunca recibe datos del usuario.
121
+ */
122
+ onError?: (error: unknown, info: AdClickRouteErrorInfo) => void;
123
+ /**
124
+ * Para responder sin esperar a Laravel: recibe la promesa del envío y debe mantener viva la
125
+ * función hasta que termine (`after` de `next/server`, `waitUntil` de Vercel o del runtime
126
+ * edge). Sin esto, la ruta espera el envío (como máximo `timeoutMs`) antes de responder.
127
+ */
128
+ waitUntil?: (task: Promise<unknown>) => void;
129
+ /** Tamaño máximo del body. Default 8 KB. */
130
+ maxBodyBytes?: number;
131
+ }
132
+ /**
133
+ * Handler de la ruta del sitio que recibe el registro de clicks del SDK (`clickRecord`) y lo
134
+ * reenvía a Laravel con la identidad de la sesión. Para Next.js: `export const POST =
135
+ * createAdClickRoute({ … })`. Usa solo `Request`/`Response`/`fetch` estándar (Node ≥ 18, edge).
136
+ *
137
+ * Respuestas al navegador: 204 (registro aceptado, aunque Laravel falle: se avisa a `onError`),
138
+ * 400 body inválido, 403 request de otro sitio, 405 método distinto de POST, 413 body grande.
139
+ */
140
+ export declare function createAdClickRoute(options: AdClickRouteOptions): (request: Request) => Promise<Response>;
141
+ export type { AdClickApiOptions, AdClickRecordBody, AdClickRecordPayload, AdClickRouteErrorInfo, AdClickRouteOptions, AdClickUser, RecordAdClickFailure, RecordAdClickFailureReason, RecordAdClickOptions, RecordAdClickResult };
package/dist/server.js ADDED
@@ -0,0 +1,445 @@
1
+ import { r as placement } from "./shared-D2s2QSYg.js";
2
+ import { z } from "zod";
3
+ // src/server/ip.ts
4
+ const IP_HEADERS = [
5
+ "cf-connecting-ip",
6
+ "true-client-ip",
7
+ "x-forwarded-for",
8
+ "x-real-ip"
9
+ ];
10
+ const PLACEHOLDERS = /* @__PURE__ */ new Set([
11
+ "0",
12
+ "0.0.0.0",
13
+ "::",
14
+ "::1",
15
+ "127.0.0.1",
16
+ "::ffff:127.0.0.1"
17
+ ]);
18
+ const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
19
+ const HEX_GROUP = /^[0-9a-f]{1,4}$/;
20
+ function isIPv4(value) {
21
+ return IPV4.test(value);
22
+ }
23
+ function isIPv6(value) {
24
+ if (value.length > 45 || !value.includes(":")) return false;
25
+ let address = value;
26
+ const lastColon = address.lastIndexOf(":");
27
+ const tail = address.slice(lastColon + 1);
28
+ if (tail.includes(".")) {
29
+ if (!isIPv4(tail)) return false;
30
+ address = `${address.slice(0, lastColon + 1)}0:0`;
31
+ }
32
+ const halves = address.split("::");
33
+ if (halves.length > 2) return false;
34
+ const groups = (part) => part ? part.split(":") : [];
35
+ const head = groups(halves[0]);
36
+ const rest = groups(halves[1]);
37
+ if (![...head, ...rest].every((group) => HEX_GROUP.test(group))) return false;
38
+ const count = head.length + rest.length;
39
+ return halves.length === 2 ? count < 8 : count === 8;
40
+ }
41
+ /**
42
+ * Normaliza un valor de header a una IP válida, o null. Acepta `[v6]:puerto` y `v4:puerto`
43
+ * (algunos balanceadores agregan el puerto).
44
+ */
45
+ function normalizeIp(raw) {
46
+ let value = raw.trim().toLowerCase();
47
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(value);
48
+ if (bracketed?.[1]) value = bracketed[1];
49
+ else if (/^[\d.]+:\d+$/.test(value)) value = value.slice(0, value.lastIndexOf(":"));
50
+ if (PLACEHOLDERS.has(value)) return null;
51
+ return isIPv4(value) || isIPv6(value) ? value : null;
52
+ }
53
+ /**
54
+ * IP del cliente desde los headers del request: `cf-connecting-ip`, `true-client-ip`, el primer
55
+ * valor de `x-forwarded-for` y `x-real-ip`, en ese orden. Salta los valores vacíos, inválidos o
56
+ * de relleno (`0`, `::1`, `127.0.0.1`…). Devuelve null si ninguno sirve.
57
+ */
58
+ function resolveClientIp(headers) {
59
+ for (const name of IP_HEADERS) {
60
+ const raw = headers.get(name);
61
+ if (!raw) continue;
62
+ const ip = normalizeIp(name === "x-forwarded-for" ? raw.split(",")[0] ?? "" : raw);
63
+ if (ip) return ip;
64
+ }
65
+ return null;
66
+ }
67
+ // src/server/record.ts
68
+ /**
69
+ * Body del navegador (`AdClickRecordPayload`). Los campos desconocidos se descartan: solo viaja a
70
+ * Laravel lo que este esquema conoce.
71
+ */
72
+ const adClickRecordPayloadSchema = z.object({
73
+ v: z.literal(1),
74
+ click_id: z.uuid(),
75
+ ad_id: z.string().min(1).max(100),
76
+ token: z.string().max(2e3).optional(),
77
+ placement,
78
+ position: z.int().nonnegative().optional(),
79
+ page_url: z.string().min(1).max(2e3).regex(/^https?:\/\//i, "page_url: URL http(s)"),
80
+ ts: z.iso.datetime({ offset: true })
81
+ });
82
+ const SCOPE = "ad-clicks:write";
83
+ const DEFAULT_TIMEOUT_MS = 3e3;
84
+ const MAX_USER_AGENT_LENGTH = 500;
85
+ const DEFAULT_TOKEN_TTL_S = 3600;
86
+ function parseAdClickPayload(input) {
87
+ return adClickRecordPayloadSchema.safeParse(input);
88
+ }
89
+ function text(value) {
90
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
91
+ if (typeof value !== "string") return null;
92
+ const trimmed = value.trim();
93
+ return trimmed === "" ? null : trimmed;
94
+ }
95
+ function normalizeUser(user) {
96
+ if (!user) return null;
97
+ return {
98
+ inspira_id: text(user.inspira_id),
99
+ email: text(user.email),
100
+ phone: text(user.phone),
101
+ external_id: text(user.external_id),
102
+ loyalty_id: text(user.loyalty_id)
103
+ };
104
+ }
105
+ /** Arma el body para Laravel con un payload ya validado y el contexto del servidor. */
106
+ function buildAdClickBody(payload, context) {
107
+ const userAgent = text(context.userAgent)?.slice(0, MAX_USER_AGENT_LENGTH) ?? null;
108
+ return {
109
+ click_id: payload.click_id,
110
+ ad_id: payload.ad_id,
111
+ token: payload.token,
112
+ placement: payload.placement,
113
+ position: payload.position,
114
+ page_url: payload.page_url,
115
+ client_ts: payload.ts,
116
+ clicked_at: (context.clickedAt ?? /* @__PURE__ */ new Date()).toISOString(),
117
+ org: Number.isInteger(context.org) ? context.org : null,
118
+ ip: typeof context.ip === "string" ? normalizeIp(context.ip) : null,
119
+ user_agent: userAgent,
120
+ user: normalizeUser(context.user)
121
+ };
122
+ }
123
+ const tokens = /* @__PURE__ */ new Map();
124
+ const pendingTokens = /* @__PURE__ */ new Map();
125
+ var TokenError = class extends Error {
126
+ constructor(httpStatus) {
127
+ super(`oauth/token respondió ${httpStatus}`);
128
+ this.httpStatus = httpStatus;
129
+ }
130
+ };
131
+ const baseUrl = (apiBase) => apiBase.replace(/\/+$/, "");
132
+ const cacheKey = (options) => `${baseUrl(options.apiBase)}\n${options.clientId}`;
133
+ async function requestToken(options, doFetch, signal) {
134
+ const response = await doFetch(`${baseUrl(options.apiBase)}/oauth/token`, {
135
+ method: "POST",
136
+ headers: {
137
+ "Content-Type": "application/x-www-form-urlencoded",
138
+ Accept: "application/json"
139
+ },
140
+ body: new URLSearchParams({
141
+ grant_type: "client_credentials",
142
+ client_id: options.clientId,
143
+ client_secret: options.clientSecret,
144
+ scope: SCOPE
145
+ }).toString(),
146
+ signal
147
+ });
148
+ if (!response.ok) {
149
+ await discard(response);
150
+ throw new TokenError(response.status);
151
+ }
152
+ const data = await response.json();
153
+ if (typeof data.access_token !== "string" || data.access_token === "") throw new TokenError(response.status);
154
+ const ttl = typeof data.expires_in === "number" && data.expires_in > 0 ? data.expires_in : DEFAULT_TOKEN_TTL_S;
155
+ const margin = Math.min(60, ttl / 10);
156
+ return {
157
+ token: data.access_token,
158
+ refreshAt: Date.now() + (ttl - margin) * 1e3
159
+ };
160
+ }
161
+ function untilAborted(promise, signal) {
162
+ if (signal.aborted) return Promise.reject(signal.reason);
163
+ return new Promise((resolve, reject) => {
164
+ const onAbort = () => reject(signal.reason);
165
+ signal.addEventListener("abort", onAbort, { once: true });
166
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
167
+ });
168
+ }
169
+ async function accessToken(options, doFetch, signal) {
170
+ const key = cacheKey(options);
171
+ const cached = tokens.get(key);
172
+ if (cached && Date.now() < cached.refreshAt) return cached.token;
173
+ let pending = pendingTokens.get(key);
174
+ if (!pending) {
175
+ pending = requestToken(options, doFetch, signal).then((fresh) => {
176
+ tokens.set(key, fresh);
177
+ pendingTokens.delete(key);
178
+ return fresh;
179
+ }, (error) => {
180
+ pendingTokens.delete(key);
181
+ throw error;
182
+ });
183
+ pendingTokens.set(key, pending);
184
+ }
185
+ return (await untilAborted(pending, signal)).token;
186
+ }
187
+ function forgetToken(options, token) {
188
+ const key = cacheKey(options);
189
+ if (tokens.get(key)?.token === token) tokens.delete(key);
190
+ }
191
+ async function discard(response) {
192
+ try {
193
+ await response.text();
194
+ } catch {}
195
+ }
196
+ function failureFor(status) {
197
+ return {
198
+ ok: false,
199
+ reason: status === 401 ? "unauthorized" : status === 403 ? "forbidden" : status === 422 ? "rejected" : "http_error",
200
+ httpStatus: status
201
+ };
202
+ }
203
+ /**
204
+ * Envía un body ya armado a `POST {apiBase}/api/v1/ad-clicks`. Pide (y cachea) el token; ante un
205
+ * 401 lo renueva y reintenta una vez. Nunca lanza: los errores vuelven como `ok: false`.
206
+ */
207
+ async function deliverAdClick(body, options) {
208
+ if (!options.apiBase || !options.clientId || !options.clientSecret) return {
209
+ ok: false,
210
+ reason: "not_configured"
211
+ };
212
+ const doFetch = options.fetch ?? globalThis.fetch;
213
+ const controller = new AbortController();
214
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("ad click record: timeout")), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
215
+ try {
216
+ for (let attempt = 1;; attempt++) {
217
+ const token = await accessToken(options, doFetch, controller.signal);
218
+ const response = await doFetch(`${baseUrl(options.apiBase)}/api/v1/ad-clicks`, {
219
+ method: "POST",
220
+ headers: {
221
+ Authorization: `Bearer ${token}`,
222
+ "Content-Type": "application/json",
223
+ Accept: "application/json"
224
+ },
225
+ body: JSON.stringify(body),
226
+ signal: controller.signal
227
+ });
228
+ if (response.status === 401 && attempt === 1) {
229
+ await discard(response);
230
+ forgetToken(options, token);
231
+ continue;
232
+ }
233
+ if (!response.ok) {
234
+ await discard(response);
235
+ return failureFor(response.status);
236
+ }
237
+ let status;
238
+ try {
239
+ status = (await response.json())?.status;
240
+ } catch {
241
+ status = void 0;
242
+ }
243
+ return {
244
+ ok: true,
245
+ status: status === "duplicate" || status !== "recorded" && response.status === 200 ? "duplicate" : "recorded",
246
+ httpStatus: response.status
247
+ };
248
+ }
249
+ } catch (error) {
250
+ if (controller.signal.aborted) return {
251
+ ok: false,
252
+ reason: "timeout",
253
+ error
254
+ };
255
+ if (error instanceof TokenError) return {
256
+ ok: false,
257
+ reason: "token_failed",
258
+ httpStatus: error.httpStatus,
259
+ error
260
+ };
261
+ return {
262
+ ok: false,
263
+ reason: "network_error",
264
+ error
265
+ };
266
+ } finally {
267
+ clearTimeout(timer);
268
+ }
269
+ }
270
+ /**
271
+ * Registra un click en Laravel (bajo nivel; `createAdClickRoute` lo usa por dentro): valida el
272
+ * body del navegador, arma el body con usuario, organización, IP y user agent, y lo envía con un
273
+ * token client_credentials cacheado. Nunca lanza.
274
+ */
275
+ async function recordAdClick(options) {
276
+ try {
277
+ const parsed = parseAdClickPayload(options.payload);
278
+ if (!parsed.success) return {
279
+ ok: false,
280
+ reason: "invalid_payload",
281
+ error: parsed.error
282
+ };
283
+ return await deliverAdClick(buildAdClickBody(parsed.data, options), options);
284
+ } catch (error) {
285
+ return {
286
+ ok: false,
287
+ reason: "network_error",
288
+ error
289
+ };
290
+ }
291
+ }
292
+ // src/server/route.ts
293
+ const DEFAULT_MAX_BODY_BYTES = 8192;
294
+ const NO_STORE = { "Cache-Control": "no-store" };
295
+ function reply(status, error, headers = {}) {
296
+ if (!error) return new Response(null, {
297
+ status,
298
+ headers: {
299
+ ...NO_STORE,
300
+ ...headers
301
+ }
302
+ });
303
+ return new Response(JSON.stringify({ error }), {
304
+ status,
305
+ headers: {
306
+ ...NO_STORE,
307
+ "Content-Type": "application/json",
308
+ ...headers
309
+ }
310
+ });
311
+ }
312
+ const firstValue = (value) => value?.split(",")[0]?.trim().toLowerCase() || null;
313
+ function requestHosts(request) {
314
+ const hosts = [firstValue(request.headers.get("host"))];
315
+ hosts.push(firstValue(request.headers.get("x-forwarded-host")));
316
+ try {
317
+ hosts.push(new URL(request.url).host.toLowerCase());
318
+ } catch {}
319
+ return hosts.filter((host) => !!host);
320
+ }
321
+ /**
322
+ * Solo requests del propio sitio: la ruta usa la cookie de sesión, así que un POST desde otro
323
+ * sitio (CSRF) registraría clicks a nombre del visitante.
324
+ * - Con `Sec-Fetch-Site` (navegadores actuales; el navegador lo calcula y una página no lo puede
325
+ * falsear) solo se acepta `same-origin`.
326
+ * - Sin él, el `Origin` (si viene) debe tener el mismo host que el request. Sin ninguno de los dos
327
+ * no es un navegador actual: no hay cookie de un visitante que proteger.
328
+ */
329
+ function isSameOrigin(request) {
330
+ const site = request.headers.get("sec-fetch-site");
331
+ if (site !== null) return site === "same-origin";
332
+ const origin = request.headers.get("origin");
333
+ if (origin === null) return true;
334
+ let host;
335
+ try {
336
+ host = new URL(origin).host.toLowerCase();
337
+ } catch {
338
+ return false;
339
+ }
340
+ return host !== "" && requestHosts(request).includes(host);
341
+ }
342
+ var BodyTooLarge = class extends Error {};
343
+ async function readBody(request, max) {
344
+ const declared = Number(request.headers.get("content-length") ?? "");
345
+ if (Number.isFinite(declared) && declared > max) throw new BodyTooLarge();
346
+ if (!request.body) return "";
347
+ const reader = request.body.getReader();
348
+ const chunks = [];
349
+ let size = 0;
350
+ for (;;) {
351
+ const { done, value } = await reader.read();
352
+ if (done) break;
353
+ size += value.byteLength;
354
+ if (size > max) {
355
+ reader.cancel().catch(() => {});
356
+ throw new BodyTooLarge();
357
+ }
358
+ chunks.push(value);
359
+ }
360
+ const bytes = new Uint8Array(size);
361
+ let offset = 0;
362
+ for (const chunk of chunks) {
363
+ bytes.set(chunk, offset);
364
+ offset += chunk.byteLength;
365
+ }
366
+ return new TextDecoder().decode(bytes);
367
+ }
368
+ /**
369
+ * Handler de la ruta del sitio que recibe el registro de clicks del SDK (`clickRecord`) y lo
370
+ * reenvía a Laravel con la identidad de la sesión. Para Next.js: `export const POST =
371
+ * createAdClickRoute({ … })`. Usa solo `Request`/`Response`/`fetch` estándar (Node ≥ 18, edge).
372
+ *
373
+ * Respuestas al navegador: 204 (registro aceptado, aunque Laravel falle: se avisa a `onError`),
374
+ * 400 body inválido, 403 request de otro sitio, 405 método distinto de POST, 413 body grande.
375
+ */
376
+ function createAdClickRoute(options) {
377
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
378
+ const report = (error, info) => {
379
+ try {
380
+ options.onError?.(error, info);
381
+ } catch {}
382
+ };
383
+ return async function adClickRoute(request) {
384
+ if (request.method !== "POST") return reply(405, void 0, { Allow: "POST" });
385
+ if (!isSameOrigin(request)) return reply(403, "cross_site");
386
+ let input;
387
+ try {
388
+ input = JSON.parse(await readBody(request, maxBodyBytes));
389
+ } catch (error) {
390
+ if (error instanceof BodyTooLarge) return reply(413, "payload_too_large");
391
+ return reply(400, "invalid_payload");
392
+ }
393
+ const parsed = parseAdClickPayload(input);
394
+ if (!parsed.success) return reply(400, "invalid_payload");
395
+ const payload = parsed.data;
396
+ const clickId = payload.click_id;
397
+ let user = null;
398
+ try {
399
+ user = await options.getUser(request) ?? null;
400
+ } catch (error) {
401
+ report(error, {
402
+ stage: "get_user",
403
+ click_id: clickId
404
+ });
405
+ }
406
+ let org = null;
407
+ try {
408
+ org = await options.getOrg(request) ?? null;
409
+ } catch (error) {
410
+ report(error, {
411
+ stage: "get_org",
412
+ click_id: clickId
413
+ });
414
+ }
415
+ let ip = null;
416
+ try {
417
+ ip = options.getIp ? options.getIp(request) ?? null : resolveClientIp(request.headers);
418
+ } catch {
419
+ ip = null;
420
+ }
421
+ const task = deliverAdClick(buildAdClickBody(payload, {
422
+ user,
423
+ org,
424
+ ip,
425
+ userAgent: request.headers.get("user-agent")
426
+ }), options).then((result) => {
427
+ if (!result.ok) {
428
+ const error = result.error ?? /* @__PURE__ */ new Error(`ad click record: ${result.reason}`);
429
+ report(error, {
430
+ stage: "record",
431
+ click_id: clickId,
432
+ result
433
+ });
434
+ }
435
+ });
436
+ if (options.waitUntil) try {
437
+ options.waitUntil(task);
438
+ } catch {
439
+ await task;
440
+ }
441
+ else await task;
442
+ return reply(204);
443
+ };
444
+ }
445
+ export { createAdClickRoute, recordAdClick, resolveClientIp };
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ // src/catalog/shared.ts
3
+ /**
4
+ * Dónde se mostró algo, como `<página>.<sección>` en minúsculas: `home.resorts-carousel`,
5
+ * `hotels-srp.side-rail`, `landing-ufc.hero`. Es la dimensión principal de los reportes:
6
+ * mantenla estable (no metas ids ni textos traducidos).
7
+ */
8
+ const placement = z.string().max(100).regex(/^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/, "placement: <pagina>.<seccion> en minúsculas").describe("Dónde se mostró: <pagina>.<seccion>, p. ej. home.resorts-carousel");
9
+ /** Posición 0-based dentro de un carrusel/lista. */
10
+ const position = z.int().nonnegative().describe("Posición 0-based en la lista o carrusel");
11
+ const itemId = z.string().min(1).max(200);
12
+ /** Servicios de búsqueda/reserva de la plataforma. */
13
+ const service = z.enum([
14
+ "hotels",
15
+ "resorts",
16
+ "flights",
17
+ "cars",
18
+ "activities",
19
+ "transfers",
20
+ "cruises"
21
+ ]).describe("Servicio de la plataforma");
22
+ const currency = z.string().regex(/^[A-Z]{3}$/, "currency: código ISO 4217 en mayúsculas").describe("Código ISO 4217, p. ej. USD");
23
+ export { service as a, position as i, itemId as n, placement as r, currency as t };
@@ -1,4 +1,4 @@
1
1
  // src/version.ts
2
2
  const SDK_NAME = "@inspira-npm/events";
3
- const SDK_VERSION = "0.1.0";
3
+ const SDK_VERSION = "0.3.0";
4
4
  export { SDK_VERSION as n, SDK_NAME as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inspira-npm/events",
3
- "version": "0.1.0",
4
- "description": "SDK de tracking de eventos de Inspira (ads, cards, mapas, búsquedas) para navegador.",
3
+ "version": "0.3.0",
4
+ "description": "SDK de tracking de eventos de Inspira (ads, cards, mapas, búsquedas) para navegador, con helpers de servidor en /server.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -18,6 +18,10 @@
18
18
  "types": "./dist/catalog.d.ts",
19
19
  "import": "./dist/catalog.js"
20
20
  },
21
+ "./server": {
22
+ "types": "./dist/server.d.ts",
23
+ "import": "./dist/server.js"
24
+ },
21
25
  "./catalog.json": "./dist/catalog.json",
22
26
  "./package.json": "./package.json"
23
27
  },