@optiqstudio/track 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/browser.d.ts +92 -0
- package/dist/hash.d.ts +17 -0
- package/dist/index.cjs +413 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +390 -0
- package/dist/server.cjs +136 -0
- package/dist/server.d.ts +82 -0
- package/dist/server.js +113 -0
- package/dist/t.js +2 -0
- package/package.json +51 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// src/hash.ts
|
|
2
|
+
function sha256(s) {
|
|
3
|
+
const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
|
|
4
|
+
if (!c || !c.subtle || typeof TextEncoder === "undefined") return Promise.resolve(null);
|
|
5
|
+
return c.subtle.digest("SHA-256", new TextEncoder().encode(s)).then(
|
|
6
|
+
(buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""),
|
|
7
|
+
() => null
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
async function personOf(p) {
|
|
11
|
+
if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) {
|
|
12
|
+
const hash = await sha256(p.email.trim().toLowerCase());
|
|
13
|
+
return hash ? { hash, kind: "email" } : null;
|
|
14
|
+
}
|
|
15
|
+
if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
|
|
16
|
+
const digits = String(p.phone).replace(/[^0-9]/g, "");
|
|
17
|
+
const hash = digits.length >= 7 ? await sha256(digits) : null;
|
|
18
|
+
return hash ? { hash, kind: "phone" } : null;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function hashPerson(p) {
|
|
23
|
+
if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
|
|
24
|
+
if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
|
|
25
|
+
const digits = String(p.phone).replace(/[^0-9]/g, "");
|
|
26
|
+
if (digits.length >= 7) return sha256(digits);
|
|
27
|
+
}
|
|
28
|
+
return Promise.resolve(null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/browser.ts
|
|
32
|
+
var COLLECTOR = "https://optiq-collect-951694748196.europe-west2.run.app/e";
|
|
33
|
+
var DAY = 864e5;
|
|
34
|
+
var IDLE = 30 * 6e4;
|
|
35
|
+
var hasWindow = () => typeof window !== "undefined" && typeof document !== "undefined";
|
|
36
|
+
function get(k) {
|
|
37
|
+
try {
|
|
38
|
+
return window.localStorage.getItem(`optiq_${k}`);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function put(k, v) {
|
|
44
|
+
try {
|
|
45
|
+
if (v === null) window.localStorage.removeItem(`optiq_${k}`);
|
|
46
|
+
else window.localStorage.setItem(`optiq_${k}`, v);
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function json(k) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(get(k) || "{}") || {};
|
|
53
|
+
} catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function rid(n) {
|
|
58
|
+
const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
59
|
+
const a = new Uint8Array(n);
|
|
60
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(a);
|
|
61
|
+
let out = "";
|
|
62
|
+
for (let i = 0; i < n; i++) out += abc.charAt((a[i] || Math.floor(Math.random() * 256)) % 36);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
var Tracker = class {
|
|
66
|
+
constructor(o) {
|
|
67
|
+
this.queue = [];
|
|
68
|
+
this.timer = null;
|
|
69
|
+
this.lastPath = null;
|
|
70
|
+
this.wired = false;
|
|
71
|
+
this.key = o.key;
|
|
72
|
+
this.endpoint = o.endpoint || COLLECTOR;
|
|
73
|
+
this.consentState = o.consent || get("consent") || "unknown";
|
|
74
|
+
const q = new URLSearchParams(window.location.search);
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
this.touch = json("touch");
|
|
77
|
+
const osr = q.get("osr");
|
|
78
|
+
if (osr && /^[A-Za-z0-9]{4,16}$/.test(osr)) {
|
|
79
|
+
const at = new Date(now).toISOString();
|
|
80
|
+
if (!this.touch.first || now - Date.parse(this.touch.first.at) > 90 * DAY) this.touch.first = { ref: osr, at };
|
|
81
|
+
this.touch.last = { ref: osr, at };
|
|
82
|
+
put("touch", JSON.stringify(this.touch));
|
|
83
|
+
}
|
|
84
|
+
const urlUtm = {};
|
|
85
|
+
for (const k of ["source", "medium", "campaign", "content", "term"]) {
|
|
86
|
+
const v = q.get(`utm_${k}`);
|
|
87
|
+
if (v) urlUtm[k] = v.slice(0, 200);
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(urlUtm).length) put("utm", JSON.stringify(urlUtm));
|
|
90
|
+
this.utm = Object.keys(urlUtm).length ? urlUtm : json("utm");
|
|
91
|
+
const urlClicks = {};
|
|
92
|
+
for (const k of ["gclid", "fbclid", "ttclid", "wbraid", "gbraid"]) {
|
|
93
|
+
const v = q.get(k);
|
|
94
|
+
if (v) urlClicks[k] = v.slice(0, 500);
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(urlClicks).length) put("clicks", JSON.stringify(urlClicks));
|
|
97
|
+
this.clicks = Object.keys(urlClicks).length ? urlClicks : json("clicks");
|
|
98
|
+
const kept = this.consentState === "denied" ? {} : json("visit");
|
|
99
|
+
const arrivedWith = `${q.get("utm_campaign") || ""}|${osr || ""}`;
|
|
100
|
+
this.visit = kept.id && now - (kept.last || 0) <= IDLE && (arrivedWith === "|" || arrivedWith === kept.from) ? kept : {
|
|
101
|
+
id: rid(20),
|
|
102
|
+
started: new Date(now).toISOString(),
|
|
103
|
+
from: arrivedWith,
|
|
104
|
+
landing: window.location.pathname,
|
|
105
|
+
referrer: document.referrer ? document.referrer.split("?")[0].slice(0, 300) : null,
|
|
106
|
+
utm: urlUtm,
|
|
107
|
+
click: q.get("gclid") ? "gclid" : q.get("fbclid") ? "fbclid" : q.get("ttclid") ? "ttclid" : null,
|
|
108
|
+
ref: osr && /^[A-Za-z0-9]{4,16}$/.test(osr) ? osr : null
|
|
109
|
+
};
|
|
110
|
+
this.person = get("ph");
|
|
111
|
+
this.personKind = get("pk") || null;
|
|
112
|
+
if (o.auto !== false) this.wire();
|
|
113
|
+
}
|
|
114
|
+
visitor() {
|
|
115
|
+
if (this.consentState === "denied") return null;
|
|
116
|
+
let v = get("vid");
|
|
117
|
+
if (!v) {
|
|
118
|
+
v = rid(20);
|
|
119
|
+
put("vid", v);
|
|
120
|
+
}
|
|
121
|
+
return v;
|
|
122
|
+
}
|
|
123
|
+
context() {
|
|
124
|
+
let tz = null;
|
|
125
|
+
try {
|
|
126
|
+
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
|
127
|
+
} catch {
|
|
128
|
+
tz = null;
|
|
129
|
+
}
|
|
130
|
+
const v = this.visit;
|
|
131
|
+
return {
|
|
132
|
+
id: v.id,
|
|
133
|
+
started_at: v.started,
|
|
134
|
+
landing: v.landing,
|
|
135
|
+
referrer: v.referrer,
|
|
136
|
+
utm: v.utm,
|
|
137
|
+
click: v.click,
|
|
138
|
+
ref: v.ref,
|
|
139
|
+
lang: (navigator.language || "").slice(0, 20) || null,
|
|
140
|
+
tz,
|
|
141
|
+
screen: window.screen ? `${window.screen.width}x${window.screen.height}` : null
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Sends what waits now (it also goes by itself within half a second, and when the page is hidden). */
|
|
145
|
+
flush() {
|
|
146
|
+
if (this.timer) clearTimeout(this.timer);
|
|
147
|
+
this.timer = null;
|
|
148
|
+
while (this.queue.length) {
|
|
149
|
+
const body = JSON.stringify({ key: this.key, visit: this.context(), events: this.queue.splice(0, 50) });
|
|
150
|
+
let sent = false;
|
|
151
|
+
try {
|
|
152
|
+
sent = !!(navigator.sendBeacon && navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" })));
|
|
153
|
+
} catch {
|
|
154
|
+
sent = false;
|
|
155
|
+
}
|
|
156
|
+
if (!sent) {
|
|
157
|
+
try {
|
|
158
|
+
void fetch(this.endpoint, { method: "POST", body, keepalive: true, mode: "cors", headers: { "Content-Type": "text/plain" } }).catch(() => void 0);
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Something a visitor did: a standard name (lead, sign_up, purchase, first_deposit, ...) or one of your own. */
|
|
165
|
+
track(name, props = {}) {
|
|
166
|
+
const rest = {};
|
|
167
|
+
for (const [k, v] of Object.entries(props)) if (!["value", "currency", "email", "phone", "items"].includes(k) && v !== void 0 && !Array.isArray(v)) rest[k] = v;
|
|
168
|
+
const fresh = Date.now();
|
|
169
|
+
this.visit.last = fresh;
|
|
170
|
+
if (this.consentState !== "denied") put("visit", JSON.stringify(this.visit));
|
|
171
|
+
const last = this.touch.last && fresh - Date.parse(this.touch.last.at) <= 7 * DAY ? this.touch.last : null;
|
|
172
|
+
const denied = this.consentState === "denied";
|
|
173
|
+
const ev = {
|
|
174
|
+
event_id: rid(18),
|
|
175
|
+
name: String(name || "").toLowerCase(),
|
|
176
|
+
occurred_at: new Date(fresh).toISOString(),
|
|
177
|
+
visitor_id: this.visitor(),
|
|
178
|
+
page_url: window.location.origin + window.location.pathname,
|
|
179
|
+
referrer: document.referrer ? document.referrer.split("?")[0].slice(0, 300) : null,
|
|
180
|
+
utm: this.utm,
|
|
181
|
+
click_ids: denied ? {} : { ...this.clicks, ...platformCookies() },
|
|
182
|
+
first_ref: this.touch.first ? this.touch.first.ref : null,
|
|
183
|
+
first_ref_at: this.touch.first ? this.touch.first.at : null,
|
|
184
|
+
last_ref: last ? last.ref : null,
|
|
185
|
+
last_ref_at: last ? last.at : null,
|
|
186
|
+
value: props.value === void 0 ? null : Number(props.value),
|
|
187
|
+
currency: props.currency || null,
|
|
188
|
+
properties: rest,
|
|
189
|
+
consent: this.consentState,
|
|
190
|
+
...Array.isArray(props.items) && props.items.length ? { items: props.items.slice(0, 50) } : {}
|
|
191
|
+
};
|
|
192
|
+
void personOf(denied ? null : props).then((h) => {
|
|
193
|
+
if (h) {
|
|
194
|
+
this.person = h.hash;
|
|
195
|
+
this.personKind = h.kind;
|
|
196
|
+
put("ph", h.hash);
|
|
197
|
+
put("pk", h.kind);
|
|
198
|
+
}
|
|
199
|
+
ev.person_hash = denied ? null : this.person;
|
|
200
|
+
ev.person_kind = denied || !this.person ? null : this.personKind;
|
|
201
|
+
this.queue.push(ev);
|
|
202
|
+
if (!this.timer) this.timer = setTimeout(() => this.flush(), 400);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** Who the visitor is, from now on: their email or phone, hashed here. */
|
|
206
|
+
identify(p) {
|
|
207
|
+
void personOf(p).then((h) => {
|
|
208
|
+
if (h && this.consentState !== "denied") {
|
|
209
|
+
this.person = h.hash;
|
|
210
|
+
this.personKind = h.kind;
|
|
211
|
+
put("ph", h.hash);
|
|
212
|
+
put("pk", h.kind);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/** The visitor's choice. Denied: no visitor id, person, click ids or stored visit; pages are still counted. */
|
|
217
|
+
consent(state) {
|
|
218
|
+
this.consentState = state === "granted" || state === "denied" ? state : "unknown";
|
|
219
|
+
put("consent", this.consentState);
|
|
220
|
+
if (this.consentState === "denied") {
|
|
221
|
+
for (const k of ["vid", "ph", "pk", "clicks", "visit"]) put(k, null);
|
|
222
|
+
this.person = null;
|
|
223
|
+
this.personKind = null;
|
|
224
|
+
this.clicks = {};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** A page view (called by itself in auto mode, on load and on every move of a single-page site). */
|
|
228
|
+
page() {
|
|
229
|
+
const path = window.location.pathname + window.location.search;
|
|
230
|
+
if (path === this.lastPath) return;
|
|
231
|
+
this.lastPath = path;
|
|
232
|
+
this.track("page_view");
|
|
233
|
+
if (this.wired) setTimeout(() => this.shopSignals(path), 1200);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* What a shop's page says by itself: a product looked at (the schema.org Product the page publishes for Google, with
|
|
237
|
+
* its id, name, category, brand and price) and a search (?q=, ?s=, ?search=, ?query=, ?keyword=). Once per page.
|
|
238
|
+
*/
|
|
239
|
+
shopSignals(path) {
|
|
240
|
+
if (path !== window.location.pathname + window.location.search) return;
|
|
241
|
+
const q = new URLSearchParams(window.location.search);
|
|
242
|
+
const query = ["q", "s", "search", "query", "keyword"].map((k) => q.get(k)).find((v) => v && v.trim());
|
|
243
|
+
if (query) this.track("search", { query: query.trim().slice(0, 200) });
|
|
244
|
+
const product = pageProduct();
|
|
245
|
+
if (product) this.track("view_content", { items: [product.item], value: product.item.price, currency: product.currency });
|
|
246
|
+
}
|
|
247
|
+
wire() {
|
|
248
|
+
if (this.wired) return;
|
|
249
|
+
this.wired = true;
|
|
250
|
+
this.page();
|
|
251
|
+
for (const m of ["pushState", "replaceState"]) {
|
|
252
|
+
const orig = history[m];
|
|
253
|
+
if (typeof orig !== "function") continue;
|
|
254
|
+
history[m] = function(...args) {
|
|
255
|
+
const r = orig.apply(this, args);
|
|
256
|
+
setTimeout(() => tracker?.page(), 0);
|
|
257
|
+
return r;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
window.addEventListener("popstate", () => this.page());
|
|
261
|
+
const begun = typeof WeakSet === "function" ? /* @__PURE__ */ new WeakSet() : null;
|
|
262
|
+
document.addEventListener("focusin", (e) => {
|
|
263
|
+
const form = e.target?.closest?.("form");
|
|
264
|
+
if (!form || !begun || begun.has(form)) return;
|
|
265
|
+
begun.add(form);
|
|
266
|
+
this.track("form_start", { form: (form.getAttribute("name") || form.id || "").slice(0, 60) || void 0 });
|
|
267
|
+
});
|
|
268
|
+
document.addEventListener("submit", (e) => {
|
|
269
|
+
const form = e.target;
|
|
270
|
+
if (!form || !form.getAttribute || form.getAttribute("data-optiq") !== "lead") return;
|
|
271
|
+
const email = form.querySelector('input[type="email"]');
|
|
272
|
+
const phone = form.querySelector('input[type="tel"]');
|
|
273
|
+
this.track("lead", {
|
|
274
|
+
form: (form.getAttribute("name") || form.id || "").slice(0, 60) || void 0,
|
|
275
|
+
email: email ? email.value : void 0,
|
|
276
|
+
phone: phone ? phone.value : void 0
|
|
277
|
+
});
|
|
278
|
+
this.flush();
|
|
279
|
+
});
|
|
280
|
+
document.addEventListener(
|
|
281
|
+
"click",
|
|
282
|
+
(e) => {
|
|
283
|
+
const a = e.target?.closest?.("a[href]");
|
|
284
|
+
if (!a) return;
|
|
285
|
+
const channel = contactChannel(a.getAttribute("href") || "");
|
|
286
|
+
if (!channel) return;
|
|
287
|
+
this.track("contact", { channel });
|
|
288
|
+
this.flush();
|
|
289
|
+
},
|
|
290
|
+
true
|
|
291
|
+
);
|
|
292
|
+
document.addEventListener("visibilitychange", () => {
|
|
293
|
+
if (document.visibilityState === "hidden") this.flush();
|
|
294
|
+
});
|
|
295
|
+
window.addEventListener("pagehide", () => this.flush());
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
function platformCookies() {
|
|
299
|
+
const out = {};
|
|
300
|
+
try {
|
|
301
|
+
for (const part of document.cookie.split(";")) {
|
|
302
|
+
const [k, ...v] = part.trim().split("=");
|
|
303
|
+
const value = decodeURIComponent(v.join("=") || "").slice(0, 300);
|
|
304
|
+
if (!value) continue;
|
|
305
|
+
if (k === "_fbp") out.fbp = value;
|
|
306
|
+
else if (k === "_fbc") out.fbc = value;
|
|
307
|
+
else if (k === "_ttp") out.ttp = value;
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
function pageProduct() {
|
|
314
|
+
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
|
315
|
+
for (const el of Array.from(scripts)) {
|
|
316
|
+
let data;
|
|
317
|
+
try {
|
|
318
|
+
data = JSON.parse(el.textContent || "null");
|
|
319
|
+
} catch {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const queue = [data];
|
|
323
|
+
while (queue.length) {
|
|
324
|
+
const n = queue.shift();
|
|
325
|
+
if (Array.isArray(n)) {
|
|
326
|
+
queue.push(...n);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!n || typeof n !== "object") continue;
|
|
330
|
+
const o = n;
|
|
331
|
+
if (o["@graph"]) queue.push(o["@graph"]);
|
|
332
|
+
const type = o["@type"];
|
|
333
|
+
if (type !== "Product" && !(Array.isArray(type) && type.includes("Product"))) continue;
|
|
334
|
+
const offers = Array.isArray(o.offers) ? o.offers[0] : o.offers;
|
|
335
|
+
const id = String(o.sku || o.productID || o.gtin13 || o.mpn || o["@id"] || o.url || o.name || "").slice(0, 100);
|
|
336
|
+
if (!id) continue;
|
|
337
|
+
const brand = typeof o.brand === "string" ? o.brand : o.brand?.name;
|
|
338
|
+
const price = Number(offers?.price ?? offers?.lowPrice);
|
|
339
|
+
const item = { id };
|
|
340
|
+
if (typeof o.name === "string") item.name = o.name.slice(0, 200);
|
|
341
|
+
if (typeof o.category === "string") item.category = o.category.slice(0, 120);
|
|
342
|
+
if (brand) item.brand = String(brand).slice(0, 120);
|
|
343
|
+
if (Number.isFinite(price)) item.price = price;
|
|
344
|
+
const currency = typeof offers?.priceCurrency === "string" ? offers.priceCurrency.toUpperCase().slice(0, 3) : void 0;
|
|
345
|
+
return { item, currency };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
function contactChannel(href) {
|
|
351
|
+
if (/^tel:/i.test(href)) return "call";
|
|
352
|
+
if (/^mailto:[^?]/i.test(href)) return "email";
|
|
353
|
+
if (/^https?:\/\/(wa\.me\/\+?[0-9]|api\.whatsapp\.com\/send\/?\?(.*&)?phone=)/i.test(href)) return "whatsapp";
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
var tracker = null;
|
|
357
|
+
var waiting = [];
|
|
358
|
+
function init(o) {
|
|
359
|
+
if (!hasWindow()) return null;
|
|
360
|
+
if (tracker) return tracker;
|
|
361
|
+
if (!o || typeof o.key !== "string" || !/^pk_[A-Za-z0-9]{20,40}$/.test(o.key)) {
|
|
362
|
+
console.warn("[optiq] init needs the source's publishable key (pk_...), from the Developer page.");
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
tracker = new Tracker(o);
|
|
366
|
+
waiting.splice(0).forEach((run) => run());
|
|
367
|
+
return tracker;
|
|
368
|
+
}
|
|
369
|
+
var later = (run) => {
|
|
370
|
+
if (!hasWindow()) return;
|
|
371
|
+
if (tracker) run(tracker);
|
|
372
|
+
else waiting.push(() => tracker && run(tracker));
|
|
373
|
+
};
|
|
374
|
+
var track = (name, props) => later((t) => t.track(name, props));
|
|
375
|
+
var identify = (p) => later((t) => t.identify(p));
|
|
376
|
+
var consent = (state) => later((t) => t.consent(state));
|
|
377
|
+
var page = () => later((t) => t.page());
|
|
378
|
+
var flush = () => later((t) => t.flush());
|
|
379
|
+
export {
|
|
380
|
+
COLLECTOR,
|
|
381
|
+
Tracker,
|
|
382
|
+
consent,
|
|
383
|
+
contactChannel,
|
|
384
|
+
flush,
|
|
385
|
+
hashPerson,
|
|
386
|
+
identify,
|
|
387
|
+
init,
|
|
388
|
+
page,
|
|
389
|
+
track
|
|
390
|
+
};
|
package/dist/server.cjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/server.ts
|
|
21
|
+
var server_exports = {};
|
|
22
|
+
__export(server_exports, {
|
|
23
|
+
Optiq: () => Optiq,
|
|
24
|
+
OptiqError: () => OptiqError
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(server_exports);
|
|
27
|
+
|
|
28
|
+
// src/hash.ts
|
|
29
|
+
function sha256(s) {
|
|
30
|
+
const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
|
|
31
|
+
if (!c || !c.subtle || typeof TextEncoder === "undefined") return Promise.resolve(null);
|
|
32
|
+
return c.subtle.digest("SHA-256", new TextEncoder().encode(s)).then(
|
|
33
|
+
(buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""),
|
|
34
|
+
() => null
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
async function personOf(p) {
|
|
38
|
+
if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) {
|
|
39
|
+
const hash = await sha256(p.email.trim().toLowerCase());
|
|
40
|
+
return hash ? { hash, kind: "email" } : null;
|
|
41
|
+
}
|
|
42
|
+
if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
|
|
43
|
+
const digits = String(p.phone).replace(/[^0-9]/g, "");
|
|
44
|
+
const hash = digits.length >= 7 ? await sha256(digits) : null;
|
|
45
|
+
return hash ? { hash, kind: "phone" } : null;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function hashPerson(p) {
|
|
50
|
+
if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
|
|
51
|
+
if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
|
|
52
|
+
const digits = String(p.phone).replace(/[^0-9]/g, "");
|
|
53
|
+
if (digits.length >= 7) return sha256(digits);
|
|
54
|
+
}
|
|
55
|
+
return Promise.resolve(null);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/server.ts
|
|
59
|
+
var BATCH = 500;
|
|
60
|
+
function newId() {
|
|
61
|
+
const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
62
|
+
const a = new Uint8Array(20);
|
|
63
|
+
crypto.getRandomValues(a);
|
|
64
|
+
return Array.from(a, (b) => abc[b % 36]).join("");
|
|
65
|
+
}
|
|
66
|
+
var OptiqError = class extends Error {
|
|
67
|
+
constructor(message, status) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.status = status;
|
|
70
|
+
this.name = "OptiqError";
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var Optiq = class {
|
|
74
|
+
constructor(o) {
|
|
75
|
+
if (!o || typeof o.secretKey !== "string" || !/^sk_[A-Za-z0-9]{30,60}$/.test(o.secretKey)) {
|
|
76
|
+
throw new Error("Optiq needs the source's secret key (sk_...), from the Developer page.");
|
|
77
|
+
}
|
|
78
|
+
this.secretKey = o.secretKey;
|
|
79
|
+
this.baseUrl = (o.baseUrl || "https://enterprise.optiq.studio").replace(/\/+$/, "");
|
|
80
|
+
this.retries = Math.max(1, o.retries ?? 3);
|
|
81
|
+
}
|
|
82
|
+
async call(method, path, body) {
|
|
83
|
+
let last = null;
|
|
84
|
+
for (let attempt = 0; attempt < this.retries; attempt++) {
|
|
85
|
+
if (attempt) await new Promise((r) => setTimeout(r, 500 * 2 ** (attempt - 1)));
|
|
86
|
+
try {
|
|
87
|
+
const r = await fetch(`${this.baseUrl}${path}`, {
|
|
88
|
+
method,
|
|
89
|
+
headers: { Authorization: `Bearer ${this.secretKey}`, "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify(body)
|
|
91
|
+
});
|
|
92
|
+
const data = await r.json().catch(() => ({}));
|
|
93
|
+
if (r.ok) return data;
|
|
94
|
+
const message = typeof data.error === "string" ? data.error : `Optiq answered ${r.status}.`;
|
|
95
|
+
if (r.status !== 429 && r.status < 500) throw new OptiqError(message, r.status);
|
|
96
|
+
last = new OptiqError(message, r.status);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e instanceof OptiqError && e.status !== 429 && e.status < 500) throw e;
|
|
99
|
+
last = e;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw last instanceof Error ? last : new Error("Optiq did not answer.");
|
|
103
|
+
}
|
|
104
|
+
async prepare(e) {
|
|
105
|
+
const { email, phone, ...rest } = e;
|
|
106
|
+
const who = e.person_hash ? { hash: e.person_hash, kind: e.person_kind || "email" } : await personOf({ email, phone });
|
|
107
|
+
const iso = (v) => v instanceof Date ? v.toISOString() : v;
|
|
108
|
+
return {
|
|
109
|
+
...rest,
|
|
110
|
+
event_id: e.event_id || newId(),
|
|
111
|
+
occurred_at: iso(e.occurred_at),
|
|
112
|
+
last_ref_at: iso(e.last_ref_at),
|
|
113
|
+
person_hash: who?.hash || void 0,
|
|
114
|
+
person_kind: who?.kind || void 0
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Sends one event or many (any number: they go 500 at a time). Answers what was kept, repeated or refused. */
|
|
118
|
+
async track(events) {
|
|
119
|
+
const list = Array.isArray(events) ? events : [events];
|
|
120
|
+
const out = { accepted: 0, duplicates: 0, rejected: [] };
|
|
121
|
+
for (let i = 0; i < list.length; i += BATCH) {
|
|
122
|
+
const part = await Promise.all(list.slice(i, i + BATCH).map((e) => this.prepare(e)));
|
|
123
|
+
const r = await this.call("POST", "/api/v1/events", { events: part });
|
|
124
|
+
out.accepted += r.accepted || 0;
|
|
125
|
+
out.duplicates += r.duplicates || 0;
|
|
126
|
+
for (const x of r.rejected || []) out.rejected.push({ index: x.index + i, reason: x.reason });
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/** Forgets a customer in your company: every event and visit of theirs. Answers how many events went. */
|
|
131
|
+
async forget(who) {
|
|
132
|
+
const hash = who.person_hash || await hashPerson(who);
|
|
133
|
+
if (!hash && !who.visitor_id) throw new Error("Say who: an email, a phone, a person_hash or a visitor_id.");
|
|
134
|
+
return await this.call("DELETE", "/api/v1/people", { person_hash: hash || void 0, visitor_id: who.visitor_id });
|
|
135
|
+
}
|
|
136
|
+
};
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Consent } from "./browser";
|
|
2
|
+
export interface ServerEvent {
|
|
3
|
+
/** A standard name (lead, sign_up, purchase, first_deposit, subscribe, app_install, ...) or your own. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Your own id for it (an order or deposit number): a repeat with the same id counts once. */
|
|
6
|
+
event_id?: string;
|
|
7
|
+
/** When it happened; up to 90 days ago. Default: now. */
|
|
8
|
+
occurred_at?: string | Date;
|
|
9
|
+
/** Who: hashed here before it is sent. */
|
|
10
|
+
email?: string;
|
|
11
|
+
/** Who: digits only, hashed here before it is sent. */
|
|
12
|
+
phone?: string | number;
|
|
13
|
+
/** Who, if you already hash: SHA-256 of the trimmed, lower-case email, or of the phone's digits. */
|
|
14
|
+
person_hash?: string;
|
|
15
|
+
/** What person_hash is of, so the ad platforms match it as an email or a phone. Default "email". */
|
|
16
|
+
person_kind?: "email" | "phone";
|
|
17
|
+
/** What was bought: each with its id, and its name, category, price and quantity where known. */
|
|
18
|
+
items?: {
|
|
19
|
+
id: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
category?: string;
|
|
22
|
+
brand?: string;
|
|
23
|
+
variant?: string;
|
|
24
|
+
price?: number;
|
|
25
|
+
quantity?: number;
|
|
26
|
+
}[];
|
|
27
|
+
/** The browser's visitor id, if your site passes it to your server (localStorage optiq_vid). */
|
|
28
|
+
visitor_id?: string;
|
|
29
|
+
value?: number;
|
|
30
|
+
currency?: string;
|
|
31
|
+
properties?: Record<string, string | number | boolean>;
|
|
32
|
+
consent?: Consent;
|
|
33
|
+
/** The tracked link that brought them, when you know it better than the browser did. */
|
|
34
|
+
last_ref?: string;
|
|
35
|
+
last_ref_at?: string | Date;
|
|
36
|
+
click_ids?: {
|
|
37
|
+
gclid?: string;
|
|
38
|
+
fbclid?: string;
|
|
39
|
+
ttclid?: string;
|
|
40
|
+
wbraid?: string;
|
|
41
|
+
gbraid?: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export interface TrackResult {
|
|
45
|
+
accepted: number;
|
|
46
|
+
duplicates: number;
|
|
47
|
+
rejected: {
|
|
48
|
+
index: number;
|
|
49
|
+
reason: string;
|
|
50
|
+
}[];
|
|
51
|
+
}
|
|
52
|
+
export interface OptiqOptions {
|
|
53
|
+
/** The source's secret key (sk_...), from the Developer page. */
|
|
54
|
+
secretKey: string;
|
|
55
|
+
/** Default https://enterprise.optiq.studio */
|
|
56
|
+
baseUrl?: string;
|
|
57
|
+
/** Tries for a busy or failing answer (429, 5xx, no answer). Default 3. */
|
|
58
|
+
retries?: number;
|
|
59
|
+
}
|
|
60
|
+
export declare class OptiqError extends Error {
|
|
61
|
+
readonly status: number;
|
|
62
|
+
constructor(message: string, status: number);
|
|
63
|
+
}
|
|
64
|
+
export declare class Optiq {
|
|
65
|
+
private secretKey;
|
|
66
|
+
private baseUrl;
|
|
67
|
+
private retries;
|
|
68
|
+
constructor(o: OptiqOptions);
|
|
69
|
+
private call;
|
|
70
|
+
private prepare;
|
|
71
|
+
/** Sends one event or many (any number: they go 500 at a time). Answers what was kept, repeated or refused. */
|
|
72
|
+
track(events: ServerEvent | ServerEvent[]): Promise<TrackResult>;
|
|
73
|
+
/** Forgets a customer in your company: every event and visit of theirs. Answers how many events went. */
|
|
74
|
+
forget(who: {
|
|
75
|
+
email?: string;
|
|
76
|
+
phone?: string | number;
|
|
77
|
+
person_hash?: string;
|
|
78
|
+
visitor_id?: string;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
deleted: number;
|
|
81
|
+
}>;
|
|
82
|
+
}
|