@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 +1 -1
- package/dist/catalog.js +18 -23
- package/dist/catalog.json +48 -2
- package/dist/click-record-B8KyALZZ.d.ts +31 -0
- package/dist/{index-BdIq1xly.d.ts → index-BShrVaIU.d.ts} +7 -0
- package/dist/index.d.ts +46 -6
- package/dist/index.js +202 -28
- package/dist/server.d.ts +141 -0
- package/dist/server.js +445 -0
- package/dist/shared-D2s2QSYg.js +23 -0
- package/dist/{version-Cqps0u2Y.js → version-DPt-j93d.js} +1 -1
- package/package.json +6 -2
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-
|
|
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-
|
|
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("`
|
|
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.
|
|
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": "`
|
|
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-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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-
|
|
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
|
-
|
|
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(
|
|
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")
|
|
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
|
|
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
|
|
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
|
-
|
|
701
|
+
const event = emit("ad_click", {
|
|
540
702
|
...props,
|
|
541
|
-
|
|
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 =
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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 };
|
package/dist/server.d.ts
ADDED
|
@@ -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 };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inspira-npm/events",
|
|
3
|
-
"version": "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
|
},
|