@optiqstudio/track 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DaveLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # @optiqstudio/track
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Join every lead, sale and deposit to the video, post or advert that brought it, for
4
+ [Optiq Studio Enterprise](https://enterprise.optiq.studio). For browsers and servers, with types.
5
+
6
+ ```sh
7
+ npm install @optiqstudio/track
8
+ ```
9
+
10
+ Your keys are on the Developer page in Enterprise. Full docs: https://enterprise.optiq.studio/developers
11
+
12
+ ## In the browser
13
+
14
+ ```ts
15
+ import { init, track, identify, consent } from "@optiqstudio/track";
16
+
17
+ init({ key: "pk_your_publishable_key" }); // page views, form starts and contacts by itself
18
+
19
+ track("purchase", { value: 1500, currency: "GMD" });
20
+ identify({ email: "ama@example.gm" }); // hashed in the browser; never sent as written
21
+ consent("denied"); // nothing that tells one person from another is kept
22
+ ```
23
+
24
+ The publishable key works only from your own domains. `init` does nothing during a server render.
25
+
26
+ Sent by itself:
27
+
28
+ - page views, including single-page moves;
29
+ - form starts;
30
+ - leads from forms marked `data-optiq="lead"`;
31
+ - presses on WhatsApp, call and email links.
32
+
33
+ `init({ key, auto: false })` turns these off. Call `page()` yourself when they are off.
34
+
35
+ ### React and Next.js
36
+
37
+ ```tsx
38
+ "use client";
39
+ import { useEffect } from "react";
40
+ import { init } from "@optiqstudio/track";
41
+
42
+ export default function Optiq() {
43
+ useEffect(() => {
44
+ init({ key: process.env.NEXT_PUBLIC_OPTIQ_KEY! });
45
+ }, []);
46
+ return null;
47
+ }
48
+ ```
49
+
50
+ ## From your server
51
+
52
+ ```ts
53
+ import { Optiq } from "@optiqstudio/track/server";
54
+
55
+ const optiq = new Optiq({ secretKey: process.env.OPTIQ_SECRET_KEY! });
56
+
57
+ await optiq.track({ name: "first_deposit", event_id: "dep_1042", email: "ama@example.gm", value: 500, currency: "GMD" });
58
+ await optiq.forget({ email: "ama@example.gm" });
59
+ ```
60
+
61
+ This runs on Node 20 or later, Deno, Bun and edge runtimes.
62
+
63
+ - **Hashing:** emails and phones are hashed with SHA-256 before they leave your server.
64
+ - **Repeats:** each `event_id` counts once, so a retry is safe.
65
+ - **Batching:** any number of events go 500 at a time.
66
+ - **Retries:** busy answers are tried again.
67
+
68
+ ## The script tag
69
+
70
+ The same code serves sites without a build step:
71
+
72
+ ```html
73
+ <script async src="https://enterprise.optiq.studio/t.js" data-key="pk_your_publishable_key"></script>
74
+ <script>optiq("track", "purchase", { value: 1500, currency: "GMD" });</script>
75
+ ```
76
+
77
+ ## What is kept
78
+
79
+ - **No IP addresses.**
80
+ - **People only as hashes:** a person is kept only as a SHA-256 hash of their email or phone.
81
+ - **No pooling:** a company's events are its own, never pooled with another company's and never sold.
82
+ - **Consent denied:** there is no visitor id, person or click id. Pages are still counted.
83
+
84
+ MIT © DaveLabs
@@ -0,0 +1,74 @@
1
+ import { hashPerson } from "./hash";
2
+ export { hashPerson };
3
+ export type Consent = "granted" | "denied" | "unknown";
4
+ export interface InitOptions {
5
+ /** The source's publishable key (Developer page). It works only from the source's own domains. */
6
+ key: string;
7
+ /** Page views on load and on every move of a single-page site, form starts, leads and contacts. Default true. */
8
+ auto?: boolean;
9
+ /** Where events go. Default: the Optiq Studio Enterprise collector. */
10
+ endpoint?: string;
11
+ /** The visitor's choice, if the site already knows it. */
12
+ consent?: Consent;
13
+ }
14
+ export interface Properties {
15
+ /** A sale's or deposit's amount. */
16
+ value?: number;
17
+ /** Three letters: GMD, USD, EUR, ... */
18
+ currency?: string;
19
+ /** Hashed in the browser before anything is sent. */
20
+ email?: string;
21
+ /** Hashed in the browser before anything is sent (digits only). */
22
+ phone?: string | number;
23
+ [property: string]: string | number | boolean | undefined;
24
+ }
25
+ export declare const COLLECTOR = "https://optiq-collect-951694748196.europe-west2.run.app/e";
26
+ /** One tracker on one page. Most sites use the module functions below, which share one. */
27
+ export declare class Tracker {
28
+ private key;
29
+ private endpoint;
30
+ private consentState;
31
+ private touch;
32
+ private utm;
33
+ private clicks;
34
+ private visit;
35
+ private person;
36
+ private queue;
37
+ private timer;
38
+ private lastPath;
39
+ private wired;
40
+ constructor(o: InitOptions);
41
+ private visitor;
42
+ private context;
43
+ /** Sends what waits now (it also goes by itself within half a second, and when the page is hidden). */
44
+ flush(): void;
45
+ /** Something a visitor did: a standard name (lead, sign_up, purchase, first_deposit, ...) or one of your own. */
46
+ track(name: string, props?: Properties): void;
47
+ /** Who the visitor is, from now on: their email or phone, hashed here. */
48
+ identify(p: {
49
+ email?: string;
50
+ phone?: string | number;
51
+ }): void;
52
+ /** The visitor's choice. Denied: no visitor id, person, click ids or stored visit; pages are still counted. */
53
+ consent(state: Consent): void;
54
+ /** A page view (called by itself in auto mode, on load and on every move of a single-page site). */
55
+ page(): void;
56
+ private wire;
57
+ }
58
+ /** Which kind of contact a link begins, or null: tel: is a call, mailto: with an address an email, wa.me with a number WhatsApp. */
59
+ export declare function contactChannel(href: string): "call" | "email" | "whatsapp" | null;
60
+ /** Starts tracking on this page (once; later calls are ignored). In a server render it does nothing. */
61
+ export declare function init(o: InitOptions): Tracker | null;
62
+ /** Something a visitor did. Calls made before init() wait for it. */
63
+ export declare const track: (name: string, props?: Properties) => void;
64
+ /** Who the visitor is: their email or phone, hashed in the browser. */
65
+ export declare const identify: (p: {
66
+ email?: string;
67
+ phone?: string | number;
68
+ }) => void;
69
+ /** The visitor's choice: "granted", "denied" or "unknown". */
70
+ export declare const consent: (state: Consent) => void;
71
+ /** A page view by hand (auto mode sends them by itself). */
72
+ export declare const page: () => void;
73
+ /** Sends what waits now. */
74
+ export declare const flush: () => void;
package/dist/hash.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** SHA-256 in hex, by Web Crypto (browsers, Node 20+, Deno, Bun, edge runtimes); null where it is missing. */
2
+ export declare function sha256(s: string): Promise<string | null>;
3
+ /** An email (trimmed, lower-case) or a phone (digits only), as a SHA-256: the only way a person is ever sent. */
4
+ export declare function hashPerson(p: {
5
+ email?: unknown;
6
+ phone?: unknown;
7
+ } | null | undefined): Promise<string | null>;
package/dist/index.cjs ADDED
@@ -0,0 +1,328 @@
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/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ COLLECTOR: () => COLLECTOR,
24
+ Tracker: () => Tracker,
25
+ consent: () => consent,
26
+ contactChannel: () => contactChannel,
27
+ flush: () => flush,
28
+ hashPerson: () => hashPerson,
29
+ identify: () => identify,
30
+ init: () => init,
31
+ page: () => page,
32
+ track: () => track
33
+ });
34
+ module.exports = __toCommonJS(index_exports);
35
+
36
+ // src/hash.ts
37
+ function sha256(s) {
38
+ const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
39
+ if (!c || !c.subtle || typeof TextEncoder === "undefined") return Promise.resolve(null);
40
+ return c.subtle.digest("SHA-256", new TextEncoder().encode(s)).then(
41
+ (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""),
42
+ () => null
43
+ );
44
+ }
45
+ function hashPerson(p) {
46
+ if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
47
+ if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
48
+ const digits = String(p.phone).replace(/[^0-9]/g, "");
49
+ if (digits.length >= 7) return sha256(digits);
50
+ }
51
+ return Promise.resolve(null);
52
+ }
53
+
54
+ // src/browser.ts
55
+ var COLLECTOR = "https://optiq-collect-951694748196.europe-west2.run.app/e";
56
+ var DAY = 864e5;
57
+ var IDLE = 30 * 6e4;
58
+ var hasWindow = () => typeof window !== "undefined" && typeof document !== "undefined";
59
+ function get(k) {
60
+ try {
61
+ return window.localStorage.getItem(`optiq_${k}`);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ function put(k, v) {
67
+ try {
68
+ if (v === null) window.localStorage.removeItem(`optiq_${k}`);
69
+ else window.localStorage.setItem(`optiq_${k}`, v);
70
+ } catch {
71
+ }
72
+ }
73
+ function json(k) {
74
+ try {
75
+ return JSON.parse(get(k) || "{}") || {};
76
+ } catch {
77
+ return {};
78
+ }
79
+ }
80
+ function rid(n) {
81
+ const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
82
+ const a = new Uint8Array(n);
83
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(a);
84
+ let out = "";
85
+ for (let i = 0; i < n; i++) out += abc.charAt((a[i] || Math.floor(Math.random() * 256)) % 36);
86
+ return out;
87
+ }
88
+ var Tracker = class {
89
+ constructor(o) {
90
+ this.queue = [];
91
+ this.timer = null;
92
+ this.lastPath = null;
93
+ this.wired = false;
94
+ this.key = o.key;
95
+ this.endpoint = o.endpoint || COLLECTOR;
96
+ this.consentState = o.consent || get("consent") || "unknown";
97
+ const q = new URLSearchParams(window.location.search);
98
+ const now = Date.now();
99
+ this.touch = json("touch");
100
+ const osr = q.get("osr");
101
+ if (osr && /^[A-Za-z0-9]{4,16}$/.test(osr)) {
102
+ const at = new Date(now).toISOString();
103
+ if (!this.touch.first || now - Date.parse(this.touch.first.at) > 90 * DAY) this.touch.first = { ref: osr, at };
104
+ this.touch.last = { ref: osr, at };
105
+ put("touch", JSON.stringify(this.touch));
106
+ }
107
+ const urlUtm = {};
108
+ for (const k of ["source", "medium", "campaign", "content", "term"]) {
109
+ const v = q.get(`utm_${k}`);
110
+ if (v) urlUtm[k] = v.slice(0, 200);
111
+ }
112
+ if (Object.keys(urlUtm).length) put("utm", JSON.stringify(urlUtm));
113
+ this.utm = Object.keys(urlUtm).length ? urlUtm : json("utm");
114
+ const urlClicks = {};
115
+ for (const k of ["gclid", "fbclid", "ttclid", "wbraid", "gbraid"]) {
116
+ const v = q.get(k);
117
+ if (v) urlClicks[k] = v.slice(0, 500);
118
+ }
119
+ if (Object.keys(urlClicks).length) put("clicks", JSON.stringify(urlClicks));
120
+ this.clicks = Object.keys(urlClicks).length ? urlClicks : json("clicks");
121
+ const kept = this.consentState === "denied" ? {} : json("visit");
122
+ const arrivedWith = `${q.get("utm_campaign") || ""}|${osr || ""}`;
123
+ this.visit = kept.id && now - (kept.last || 0) <= IDLE && (arrivedWith === "|" || arrivedWith === kept.from) ? kept : {
124
+ id: rid(20),
125
+ started: new Date(now).toISOString(),
126
+ from: arrivedWith,
127
+ landing: window.location.pathname,
128
+ referrer: document.referrer ? document.referrer.split("?")[0].slice(0, 300) : null,
129
+ utm: urlUtm,
130
+ click: q.get("gclid") ? "gclid" : q.get("fbclid") ? "fbclid" : q.get("ttclid") ? "ttclid" : null,
131
+ ref: osr && /^[A-Za-z0-9]{4,16}$/.test(osr) ? osr : null
132
+ };
133
+ this.person = get("ph");
134
+ if (o.auto !== false) this.wire();
135
+ }
136
+ visitor() {
137
+ if (this.consentState === "denied") return null;
138
+ let v = get("vid");
139
+ if (!v) {
140
+ v = rid(20);
141
+ put("vid", v);
142
+ }
143
+ return v;
144
+ }
145
+ context() {
146
+ let tz = null;
147
+ try {
148
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
149
+ } catch {
150
+ tz = null;
151
+ }
152
+ const v = this.visit;
153
+ return {
154
+ id: v.id,
155
+ started_at: v.started,
156
+ landing: v.landing,
157
+ referrer: v.referrer,
158
+ utm: v.utm,
159
+ click: v.click,
160
+ ref: v.ref,
161
+ lang: (navigator.language || "").slice(0, 20) || null,
162
+ tz,
163
+ screen: window.screen ? `${window.screen.width}x${window.screen.height}` : null
164
+ };
165
+ }
166
+ /** Sends what waits now (it also goes by itself within half a second, and when the page is hidden). */
167
+ flush() {
168
+ if (this.timer) clearTimeout(this.timer);
169
+ this.timer = null;
170
+ while (this.queue.length) {
171
+ const body = JSON.stringify({ key: this.key, visit: this.context(), events: this.queue.splice(0, 50) });
172
+ let sent = false;
173
+ try {
174
+ sent = !!(navigator.sendBeacon && navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" })));
175
+ } catch {
176
+ sent = false;
177
+ }
178
+ if (!sent) {
179
+ try {
180
+ void fetch(this.endpoint, { method: "POST", body, keepalive: true, mode: "cors", headers: { "Content-Type": "text/plain" } }).catch(() => void 0);
181
+ } catch {
182
+ }
183
+ }
184
+ }
185
+ }
186
+ /** Something a visitor did: a standard name (lead, sign_up, purchase, first_deposit, ...) or one of your own. */
187
+ track(name, props = {}) {
188
+ const rest = {};
189
+ for (const [k, v] of Object.entries(props)) if (!["value", "currency", "email", "phone"].includes(k) && v !== void 0) rest[k] = v;
190
+ const fresh = Date.now();
191
+ this.visit.last = fresh;
192
+ if (this.consentState !== "denied") put("visit", JSON.stringify(this.visit));
193
+ const last = this.touch.last && fresh - Date.parse(this.touch.last.at) <= 7 * DAY ? this.touch.last : null;
194
+ const denied = this.consentState === "denied";
195
+ const ev = {
196
+ event_id: rid(18),
197
+ name: String(name || "").toLowerCase(),
198
+ occurred_at: new Date(fresh).toISOString(),
199
+ visitor_id: this.visitor(),
200
+ page_url: window.location.origin + window.location.pathname,
201
+ referrer: document.referrer ? document.referrer.split("?")[0].slice(0, 300) : null,
202
+ utm: this.utm,
203
+ click_ids: denied ? {} : this.clicks,
204
+ first_ref: this.touch.first ? this.touch.first.ref : null,
205
+ first_ref_at: this.touch.first ? this.touch.first.at : null,
206
+ last_ref: last ? last.ref : null,
207
+ last_ref_at: last ? last.at : null,
208
+ value: props.value === void 0 ? null : Number(props.value),
209
+ currency: props.currency || null,
210
+ properties: rest,
211
+ consent: this.consentState
212
+ };
213
+ void hashPerson(denied ? null : props).then((h) => {
214
+ if (h) {
215
+ this.person = h;
216
+ put("ph", h);
217
+ }
218
+ ev.person_hash = denied ? null : this.person;
219
+ this.queue.push(ev);
220
+ if (!this.timer) this.timer = setTimeout(() => this.flush(), 400);
221
+ });
222
+ }
223
+ /** Who the visitor is, from now on: their email or phone, hashed here. */
224
+ identify(p) {
225
+ void hashPerson(p).then((h) => {
226
+ if (h && this.consentState !== "denied") {
227
+ this.person = h;
228
+ put("ph", h);
229
+ }
230
+ });
231
+ }
232
+ /** The visitor's choice. Denied: no visitor id, person, click ids or stored visit; pages are still counted. */
233
+ consent(state) {
234
+ this.consentState = state === "granted" || state === "denied" ? state : "unknown";
235
+ put("consent", this.consentState);
236
+ if (this.consentState === "denied") {
237
+ for (const k of ["vid", "ph", "clicks", "visit"]) put(k, null);
238
+ this.person = null;
239
+ this.clicks = {};
240
+ }
241
+ }
242
+ /** A page view (called by itself in auto mode, on load and on every move of a single-page site). */
243
+ page() {
244
+ const path = window.location.pathname + window.location.search;
245
+ if (path === this.lastPath) return;
246
+ this.lastPath = path;
247
+ this.track("page_view");
248
+ }
249
+ wire() {
250
+ if (this.wired) return;
251
+ this.wired = true;
252
+ this.page();
253
+ for (const m of ["pushState", "replaceState"]) {
254
+ const orig = history[m];
255
+ if (typeof orig !== "function") continue;
256
+ history[m] = function(...args) {
257
+ const r = orig.apply(this, args);
258
+ setTimeout(() => tracker?.page(), 0);
259
+ return r;
260
+ };
261
+ }
262
+ window.addEventListener("popstate", () => this.page());
263
+ const begun = typeof WeakSet === "function" ? /* @__PURE__ */ new WeakSet() : null;
264
+ document.addEventListener("focusin", (e) => {
265
+ const form = e.target?.closest?.("form");
266
+ if (!form || !begun || begun.has(form)) return;
267
+ begun.add(form);
268
+ this.track("form_start", { form: (form.getAttribute("name") || form.id || "").slice(0, 60) || void 0 });
269
+ });
270
+ document.addEventListener("submit", (e) => {
271
+ const form = e.target;
272
+ if (!form || !form.getAttribute || form.getAttribute("data-optiq") !== "lead") return;
273
+ const email = form.querySelector('input[type="email"]');
274
+ const phone = form.querySelector('input[type="tel"]');
275
+ this.track("lead", {
276
+ form: (form.getAttribute("name") || form.id || "").slice(0, 60) || void 0,
277
+ email: email ? email.value : void 0,
278
+ phone: phone ? phone.value : void 0
279
+ });
280
+ this.flush();
281
+ });
282
+ document.addEventListener(
283
+ "click",
284
+ (e) => {
285
+ const a = e.target?.closest?.("a[href]");
286
+ if (!a) return;
287
+ const channel = contactChannel(a.getAttribute("href") || "");
288
+ if (!channel) return;
289
+ this.track("contact", { channel });
290
+ this.flush();
291
+ },
292
+ true
293
+ );
294
+ document.addEventListener("visibilitychange", () => {
295
+ if (document.visibilityState === "hidden") this.flush();
296
+ });
297
+ window.addEventListener("pagehide", () => this.flush());
298
+ }
299
+ };
300
+ function contactChannel(href) {
301
+ if (/^tel:/i.test(href)) return "call";
302
+ if (/^mailto:[^?]/i.test(href)) return "email";
303
+ if (/^https?:\/\/(wa\.me\/\+?[0-9]|api\.whatsapp\.com\/send\/?\?(.*&)?phone=)/i.test(href)) return "whatsapp";
304
+ return null;
305
+ }
306
+ var tracker = null;
307
+ var waiting = [];
308
+ function init(o) {
309
+ if (!hasWindow()) return null;
310
+ if (tracker) return tracker;
311
+ if (!o || typeof o.key !== "string" || !/^pk_[A-Za-z0-9]{20,40}$/.test(o.key)) {
312
+ console.warn("[optiq] init needs the source's publishable key (pk_...), from the Developer page.");
313
+ return null;
314
+ }
315
+ tracker = new Tracker(o);
316
+ waiting.splice(0).forEach((run) => run());
317
+ return tracker;
318
+ }
319
+ var later = (run) => {
320
+ if (!hasWindow()) return;
321
+ if (tracker) run(tracker);
322
+ else waiting.push(() => tracker && run(tracker));
323
+ };
324
+ var track = (name, props) => later((t) => t.track(name, props));
325
+ var identify = (p) => later((t) => t.identify(p));
326
+ var consent = (state) => later((t) => t.consent(state));
327
+ var page = () => later((t) => t.page());
328
+ var flush = () => later((t) => t.flush());
@@ -0,0 +1,2 @@
1
+ export { COLLECTOR, Tracker, consent, contactChannel, flush, hashPerson, identify, init, page, track } from "./browser";
2
+ export type { Consent, InitOptions, Properties } from "./browser";
package/dist/index.js ADDED
@@ -0,0 +1,305 @@
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
+ function hashPerson(p) {
11
+ if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
12
+ if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
13
+ const digits = String(p.phone).replace(/[^0-9]/g, "");
14
+ if (digits.length >= 7) return sha256(digits);
15
+ }
16
+ return Promise.resolve(null);
17
+ }
18
+
19
+ // src/browser.ts
20
+ var COLLECTOR = "https://optiq-collect-951694748196.europe-west2.run.app/e";
21
+ var DAY = 864e5;
22
+ var IDLE = 30 * 6e4;
23
+ var hasWindow = () => typeof window !== "undefined" && typeof document !== "undefined";
24
+ function get(k) {
25
+ try {
26
+ return window.localStorage.getItem(`optiq_${k}`);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function put(k, v) {
32
+ try {
33
+ if (v === null) window.localStorage.removeItem(`optiq_${k}`);
34
+ else window.localStorage.setItem(`optiq_${k}`, v);
35
+ } catch {
36
+ }
37
+ }
38
+ function json(k) {
39
+ try {
40
+ return JSON.parse(get(k) || "{}") || {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+ function rid(n) {
46
+ const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
47
+ const a = new Uint8Array(n);
48
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(a);
49
+ let out = "";
50
+ for (let i = 0; i < n; i++) out += abc.charAt((a[i] || Math.floor(Math.random() * 256)) % 36);
51
+ return out;
52
+ }
53
+ var Tracker = class {
54
+ constructor(o) {
55
+ this.queue = [];
56
+ this.timer = null;
57
+ this.lastPath = null;
58
+ this.wired = false;
59
+ this.key = o.key;
60
+ this.endpoint = o.endpoint || COLLECTOR;
61
+ this.consentState = o.consent || get("consent") || "unknown";
62
+ const q = new URLSearchParams(window.location.search);
63
+ const now = Date.now();
64
+ this.touch = json("touch");
65
+ const osr = q.get("osr");
66
+ if (osr && /^[A-Za-z0-9]{4,16}$/.test(osr)) {
67
+ const at = new Date(now).toISOString();
68
+ if (!this.touch.first || now - Date.parse(this.touch.first.at) > 90 * DAY) this.touch.first = { ref: osr, at };
69
+ this.touch.last = { ref: osr, at };
70
+ put("touch", JSON.stringify(this.touch));
71
+ }
72
+ const urlUtm = {};
73
+ for (const k of ["source", "medium", "campaign", "content", "term"]) {
74
+ const v = q.get(`utm_${k}`);
75
+ if (v) urlUtm[k] = v.slice(0, 200);
76
+ }
77
+ if (Object.keys(urlUtm).length) put("utm", JSON.stringify(urlUtm));
78
+ this.utm = Object.keys(urlUtm).length ? urlUtm : json("utm");
79
+ const urlClicks = {};
80
+ for (const k of ["gclid", "fbclid", "ttclid", "wbraid", "gbraid"]) {
81
+ const v = q.get(k);
82
+ if (v) urlClicks[k] = v.slice(0, 500);
83
+ }
84
+ if (Object.keys(urlClicks).length) put("clicks", JSON.stringify(urlClicks));
85
+ this.clicks = Object.keys(urlClicks).length ? urlClicks : json("clicks");
86
+ const kept = this.consentState === "denied" ? {} : json("visit");
87
+ const arrivedWith = `${q.get("utm_campaign") || ""}|${osr || ""}`;
88
+ this.visit = kept.id && now - (kept.last || 0) <= IDLE && (arrivedWith === "|" || arrivedWith === kept.from) ? kept : {
89
+ id: rid(20),
90
+ started: new Date(now).toISOString(),
91
+ from: arrivedWith,
92
+ landing: window.location.pathname,
93
+ referrer: document.referrer ? document.referrer.split("?")[0].slice(0, 300) : null,
94
+ utm: urlUtm,
95
+ click: q.get("gclid") ? "gclid" : q.get("fbclid") ? "fbclid" : q.get("ttclid") ? "ttclid" : null,
96
+ ref: osr && /^[A-Za-z0-9]{4,16}$/.test(osr) ? osr : null
97
+ };
98
+ this.person = get("ph");
99
+ if (o.auto !== false) this.wire();
100
+ }
101
+ visitor() {
102
+ if (this.consentState === "denied") return null;
103
+ let v = get("vid");
104
+ if (!v) {
105
+ v = rid(20);
106
+ put("vid", v);
107
+ }
108
+ return v;
109
+ }
110
+ context() {
111
+ let tz = null;
112
+ try {
113
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
114
+ } catch {
115
+ tz = null;
116
+ }
117
+ const v = this.visit;
118
+ return {
119
+ id: v.id,
120
+ started_at: v.started,
121
+ landing: v.landing,
122
+ referrer: v.referrer,
123
+ utm: v.utm,
124
+ click: v.click,
125
+ ref: v.ref,
126
+ lang: (navigator.language || "").slice(0, 20) || null,
127
+ tz,
128
+ screen: window.screen ? `${window.screen.width}x${window.screen.height}` : null
129
+ };
130
+ }
131
+ /** Sends what waits now (it also goes by itself within half a second, and when the page is hidden). */
132
+ flush() {
133
+ if (this.timer) clearTimeout(this.timer);
134
+ this.timer = null;
135
+ while (this.queue.length) {
136
+ const body = JSON.stringify({ key: this.key, visit: this.context(), events: this.queue.splice(0, 50) });
137
+ let sent = false;
138
+ try {
139
+ sent = !!(navigator.sendBeacon && navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" })));
140
+ } catch {
141
+ sent = false;
142
+ }
143
+ if (!sent) {
144
+ try {
145
+ void fetch(this.endpoint, { method: "POST", body, keepalive: true, mode: "cors", headers: { "Content-Type": "text/plain" } }).catch(() => void 0);
146
+ } catch {
147
+ }
148
+ }
149
+ }
150
+ }
151
+ /** Something a visitor did: a standard name (lead, sign_up, purchase, first_deposit, ...) or one of your own. */
152
+ track(name, props = {}) {
153
+ const rest = {};
154
+ for (const [k, v] of Object.entries(props)) if (!["value", "currency", "email", "phone"].includes(k) && v !== void 0) rest[k] = v;
155
+ const fresh = Date.now();
156
+ this.visit.last = fresh;
157
+ if (this.consentState !== "denied") put("visit", JSON.stringify(this.visit));
158
+ const last = this.touch.last && fresh - Date.parse(this.touch.last.at) <= 7 * DAY ? this.touch.last : null;
159
+ const denied = this.consentState === "denied";
160
+ const ev = {
161
+ event_id: rid(18),
162
+ name: String(name || "").toLowerCase(),
163
+ occurred_at: new Date(fresh).toISOString(),
164
+ visitor_id: this.visitor(),
165
+ page_url: window.location.origin + window.location.pathname,
166
+ referrer: document.referrer ? document.referrer.split("?")[0].slice(0, 300) : null,
167
+ utm: this.utm,
168
+ click_ids: denied ? {} : this.clicks,
169
+ first_ref: this.touch.first ? this.touch.first.ref : null,
170
+ first_ref_at: this.touch.first ? this.touch.first.at : null,
171
+ last_ref: last ? last.ref : null,
172
+ last_ref_at: last ? last.at : null,
173
+ value: props.value === void 0 ? null : Number(props.value),
174
+ currency: props.currency || null,
175
+ properties: rest,
176
+ consent: this.consentState
177
+ };
178
+ void hashPerson(denied ? null : props).then((h) => {
179
+ if (h) {
180
+ this.person = h;
181
+ put("ph", h);
182
+ }
183
+ ev.person_hash = denied ? null : this.person;
184
+ this.queue.push(ev);
185
+ if (!this.timer) this.timer = setTimeout(() => this.flush(), 400);
186
+ });
187
+ }
188
+ /** Who the visitor is, from now on: their email or phone, hashed here. */
189
+ identify(p) {
190
+ void hashPerson(p).then((h) => {
191
+ if (h && this.consentState !== "denied") {
192
+ this.person = h;
193
+ put("ph", h);
194
+ }
195
+ });
196
+ }
197
+ /** The visitor's choice. Denied: no visitor id, person, click ids or stored visit; pages are still counted. */
198
+ consent(state) {
199
+ this.consentState = state === "granted" || state === "denied" ? state : "unknown";
200
+ put("consent", this.consentState);
201
+ if (this.consentState === "denied") {
202
+ for (const k of ["vid", "ph", "clicks", "visit"]) put(k, null);
203
+ this.person = null;
204
+ this.clicks = {};
205
+ }
206
+ }
207
+ /** A page view (called by itself in auto mode, on load and on every move of a single-page site). */
208
+ page() {
209
+ const path = window.location.pathname + window.location.search;
210
+ if (path === this.lastPath) return;
211
+ this.lastPath = path;
212
+ this.track("page_view");
213
+ }
214
+ wire() {
215
+ if (this.wired) return;
216
+ this.wired = true;
217
+ this.page();
218
+ for (const m of ["pushState", "replaceState"]) {
219
+ const orig = history[m];
220
+ if (typeof orig !== "function") continue;
221
+ history[m] = function(...args) {
222
+ const r = orig.apply(this, args);
223
+ setTimeout(() => tracker?.page(), 0);
224
+ return r;
225
+ };
226
+ }
227
+ window.addEventListener("popstate", () => this.page());
228
+ const begun = typeof WeakSet === "function" ? /* @__PURE__ */ new WeakSet() : null;
229
+ document.addEventListener("focusin", (e) => {
230
+ const form = e.target?.closest?.("form");
231
+ if (!form || !begun || begun.has(form)) return;
232
+ begun.add(form);
233
+ this.track("form_start", { form: (form.getAttribute("name") || form.id || "").slice(0, 60) || void 0 });
234
+ });
235
+ document.addEventListener("submit", (e) => {
236
+ const form = e.target;
237
+ if (!form || !form.getAttribute || form.getAttribute("data-optiq") !== "lead") return;
238
+ const email = form.querySelector('input[type="email"]');
239
+ const phone = form.querySelector('input[type="tel"]');
240
+ this.track("lead", {
241
+ form: (form.getAttribute("name") || form.id || "").slice(0, 60) || void 0,
242
+ email: email ? email.value : void 0,
243
+ phone: phone ? phone.value : void 0
244
+ });
245
+ this.flush();
246
+ });
247
+ document.addEventListener(
248
+ "click",
249
+ (e) => {
250
+ const a = e.target?.closest?.("a[href]");
251
+ if (!a) return;
252
+ const channel = contactChannel(a.getAttribute("href") || "");
253
+ if (!channel) return;
254
+ this.track("contact", { channel });
255
+ this.flush();
256
+ },
257
+ true
258
+ );
259
+ document.addEventListener("visibilitychange", () => {
260
+ if (document.visibilityState === "hidden") this.flush();
261
+ });
262
+ window.addEventListener("pagehide", () => this.flush());
263
+ }
264
+ };
265
+ function contactChannel(href) {
266
+ if (/^tel:/i.test(href)) return "call";
267
+ if (/^mailto:[^?]/i.test(href)) return "email";
268
+ if (/^https?:\/\/(wa\.me\/\+?[0-9]|api\.whatsapp\.com\/send\/?\?(.*&)?phone=)/i.test(href)) return "whatsapp";
269
+ return null;
270
+ }
271
+ var tracker = null;
272
+ var waiting = [];
273
+ function init(o) {
274
+ if (!hasWindow()) return null;
275
+ if (tracker) return tracker;
276
+ if (!o || typeof o.key !== "string" || !/^pk_[A-Za-z0-9]{20,40}$/.test(o.key)) {
277
+ console.warn("[optiq] init needs the source's publishable key (pk_...), from the Developer page.");
278
+ return null;
279
+ }
280
+ tracker = new Tracker(o);
281
+ waiting.splice(0).forEach((run) => run());
282
+ return tracker;
283
+ }
284
+ var later = (run) => {
285
+ if (!hasWindow()) return;
286
+ if (tracker) run(tracker);
287
+ else waiting.push(() => tracker && run(tracker));
288
+ };
289
+ var track = (name, props) => later((t) => t.track(name, props));
290
+ var identify = (p) => later((t) => t.identify(p));
291
+ var consent = (state) => later((t) => t.consent(state));
292
+ var page = () => later((t) => t.page());
293
+ var flush = () => later((t) => t.flush());
294
+ export {
295
+ COLLECTOR,
296
+ Tracker,
297
+ consent,
298
+ contactChannel,
299
+ flush,
300
+ hashPerson,
301
+ identify,
302
+ init,
303
+ page,
304
+ track
305
+ };
@@ -0,0 +1,123 @@
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
+ function hashPerson(p) {
38
+ if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
39
+ if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
40
+ const digits = String(p.phone).replace(/[^0-9]/g, "");
41
+ if (digits.length >= 7) return sha256(digits);
42
+ }
43
+ return Promise.resolve(null);
44
+ }
45
+
46
+ // src/server.ts
47
+ var BATCH = 500;
48
+ function newId() {
49
+ const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
50
+ const a = new Uint8Array(20);
51
+ crypto.getRandomValues(a);
52
+ return Array.from(a, (b) => abc[b % 36]).join("");
53
+ }
54
+ var OptiqError = class extends Error {
55
+ constructor(message, status) {
56
+ super(message);
57
+ this.status = status;
58
+ this.name = "OptiqError";
59
+ }
60
+ };
61
+ var Optiq = class {
62
+ constructor(o) {
63
+ if (!o || typeof o.secretKey !== "string" || !/^sk_[A-Za-z0-9]{30,60}$/.test(o.secretKey)) {
64
+ throw new Error("Optiq needs the source's secret key (sk_...), from the Developer page.");
65
+ }
66
+ this.secretKey = o.secretKey;
67
+ this.baseUrl = (o.baseUrl || "https://enterprise.optiq.studio").replace(/\/+$/, "");
68
+ this.retries = Math.max(1, o.retries ?? 3);
69
+ }
70
+ async call(method, path, body) {
71
+ let last = null;
72
+ for (let attempt = 0; attempt < this.retries; attempt++) {
73
+ if (attempt) await new Promise((r) => setTimeout(r, 500 * 2 ** (attempt - 1)));
74
+ try {
75
+ const r = await fetch(`${this.baseUrl}${path}`, {
76
+ method,
77
+ headers: { Authorization: `Bearer ${this.secretKey}`, "Content-Type": "application/json" },
78
+ body: JSON.stringify(body)
79
+ });
80
+ const data = await r.json().catch(() => ({}));
81
+ if (r.ok) return data;
82
+ const message = typeof data.error === "string" ? data.error : `Optiq answered ${r.status}.`;
83
+ if (r.status !== 429 && r.status < 500) throw new OptiqError(message, r.status);
84
+ last = new OptiqError(message, r.status);
85
+ } catch (e) {
86
+ if (e instanceof OptiqError && e.status !== 429 && e.status < 500) throw e;
87
+ last = e;
88
+ }
89
+ }
90
+ throw last instanceof Error ? last : new Error("Optiq did not answer.");
91
+ }
92
+ async prepare(e) {
93
+ const { email, phone, ...rest } = e;
94
+ const hash = e.person_hash || await hashPerson({ email, phone });
95
+ const iso = (v) => v instanceof Date ? v.toISOString() : v;
96
+ return {
97
+ ...rest,
98
+ event_id: e.event_id || newId(),
99
+ occurred_at: iso(e.occurred_at),
100
+ last_ref_at: iso(e.last_ref_at),
101
+ person_hash: hash || void 0
102
+ };
103
+ }
104
+ /** Sends one event or many (any number: they go 500 at a time). Answers what was kept, repeated or refused. */
105
+ async track(events) {
106
+ const list = Array.isArray(events) ? events : [events];
107
+ const out = { accepted: 0, duplicates: 0, rejected: [] };
108
+ for (let i = 0; i < list.length; i += BATCH) {
109
+ const part = await Promise.all(list.slice(i, i + BATCH).map((e) => this.prepare(e)));
110
+ const r = await this.call("POST", "/api/v1/events", { events: part });
111
+ out.accepted += r.accepted || 0;
112
+ out.duplicates += r.duplicates || 0;
113
+ for (const x of r.rejected || []) out.rejected.push({ index: x.index + i, reason: x.reason });
114
+ }
115
+ return out;
116
+ }
117
+ /** Forgets a customer in your company: every event and visit of theirs. Answers how many events went. */
118
+ async forget(who) {
119
+ const hash = who.person_hash || await hashPerson(who);
120
+ if (!hash && !who.visitor_id) throw new Error("Say who: an email, a phone, a person_hash or a visitor_id.");
121
+ return await this.call("DELETE", "/api/v1/people", { person_hash: hash || void 0, visitor_id: who.visitor_id });
122
+ }
123
+ };
@@ -0,0 +1,70 @@
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
+ /** The browser's visitor id, if your site passes it to your server (localStorage optiq_vid). */
16
+ visitor_id?: string;
17
+ value?: number;
18
+ currency?: string;
19
+ properties?: Record<string, string | number | boolean>;
20
+ consent?: Consent;
21
+ /** The tracked link that brought them, when you know it better than the browser did. */
22
+ last_ref?: string;
23
+ last_ref_at?: string | Date;
24
+ click_ids?: {
25
+ gclid?: string;
26
+ fbclid?: string;
27
+ ttclid?: string;
28
+ wbraid?: string;
29
+ gbraid?: string;
30
+ };
31
+ }
32
+ export interface TrackResult {
33
+ accepted: number;
34
+ duplicates: number;
35
+ rejected: {
36
+ index: number;
37
+ reason: string;
38
+ }[];
39
+ }
40
+ export interface OptiqOptions {
41
+ /** The source's secret key (sk_...), from the Developer page. */
42
+ secretKey: string;
43
+ /** Default https://enterprise.optiq.studio */
44
+ baseUrl?: string;
45
+ /** Tries for a busy or failing answer (429, 5xx, no answer). Default 3. */
46
+ retries?: number;
47
+ }
48
+ export declare class OptiqError extends Error {
49
+ readonly status: number;
50
+ constructor(message: string, status: number);
51
+ }
52
+ export declare class Optiq {
53
+ private secretKey;
54
+ private baseUrl;
55
+ private retries;
56
+ constructor(o: OptiqOptions);
57
+ private call;
58
+ private prepare;
59
+ /** Sends one event or many (any number: they go 500 at a time). Answers what was kept, repeated or refused. */
60
+ track(events: ServerEvent | ServerEvent[]): Promise<TrackResult>;
61
+ /** Forgets a customer in your company: every event and visit of theirs. Answers how many events went. */
62
+ forget(who: {
63
+ email?: string;
64
+ phone?: string | number;
65
+ person_hash?: string;
66
+ visitor_id?: string;
67
+ }): Promise<{
68
+ deleted: number;
69
+ }>;
70
+ }
package/dist/server.js ADDED
@@ -0,0 +1,100 @@
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
+ function hashPerson(p) {
11
+ if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
12
+ if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
13
+ const digits = String(p.phone).replace(/[^0-9]/g, "");
14
+ if (digits.length >= 7) return sha256(digits);
15
+ }
16
+ return Promise.resolve(null);
17
+ }
18
+
19
+ // src/server.ts
20
+ var BATCH = 500;
21
+ function newId() {
22
+ const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
23
+ const a = new Uint8Array(20);
24
+ crypto.getRandomValues(a);
25
+ return Array.from(a, (b) => abc[b % 36]).join("");
26
+ }
27
+ var OptiqError = class extends Error {
28
+ constructor(message, status) {
29
+ super(message);
30
+ this.status = status;
31
+ this.name = "OptiqError";
32
+ }
33
+ };
34
+ var Optiq = class {
35
+ constructor(o) {
36
+ if (!o || typeof o.secretKey !== "string" || !/^sk_[A-Za-z0-9]{30,60}$/.test(o.secretKey)) {
37
+ throw new Error("Optiq needs the source's secret key (sk_...), from the Developer page.");
38
+ }
39
+ this.secretKey = o.secretKey;
40
+ this.baseUrl = (o.baseUrl || "https://enterprise.optiq.studio").replace(/\/+$/, "");
41
+ this.retries = Math.max(1, o.retries ?? 3);
42
+ }
43
+ async call(method, path, body) {
44
+ let last = null;
45
+ for (let attempt = 0; attempt < this.retries; attempt++) {
46
+ if (attempt) await new Promise((r) => setTimeout(r, 500 * 2 ** (attempt - 1)));
47
+ try {
48
+ const r = await fetch(`${this.baseUrl}${path}`, {
49
+ method,
50
+ headers: { Authorization: `Bearer ${this.secretKey}`, "Content-Type": "application/json" },
51
+ body: JSON.stringify(body)
52
+ });
53
+ const data = await r.json().catch(() => ({}));
54
+ if (r.ok) return data;
55
+ const message = typeof data.error === "string" ? data.error : `Optiq answered ${r.status}.`;
56
+ if (r.status !== 429 && r.status < 500) throw new OptiqError(message, r.status);
57
+ last = new OptiqError(message, r.status);
58
+ } catch (e) {
59
+ if (e instanceof OptiqError && e.status !== 429 && e.status < 500) throw e;
60
+ last = e;
61
+ }
62
+ }
63
+ throw last instanceof Error ? last : new Error("Optiq did not answer.");
64
+ }
65
+ async prepare(e) {
66
+ const { email, phone, ...rest } = e;
67
+ const hash = e.person_hash || await hashPerson({ email, phone });
68
+ const iso = (v) => v instanceof Date ? v.toISOString() : v;
69
+ return {
70
+ ...rest,
71
+ event_id: e.event_id || newId(),
72
+ occurred_at: iso(e.occurred_at),
73
+ last_ref_at: iso(e.last_ref_at),
74
+ person_hash: hash || void 0
75
+ };
76
+ }
77
+ /** Sends one event or many (any number: they go 500 at a time). Answers what was kept, repeated or refused. */
78
+ async track(events) {
79
+ const list = Array.isArray(events) ? events : [events];
80
+ const out = { accepted: 0, duplicates: 0, rejected: [] };
81
+ for (let i = 0; i < list.length; i += BATCH) {
82
+ const part = await Promise.all(list.slice(i, i + BATCH).map((e) => this.prepare(e)));
83
+ const r = await this.call("POST", "/api/v1/events", { events: part });
84
+ out.accepted += r.accepted || 0;
85
+ out.duplicates += r.duplicates || 0;
86
+ for (const x of r.rejected || []) out.rejected.push({ index: x.index + i, reason: x.reason });
87
+ }
88
+ return out;
89
+ }
90
+ /** Forgets a customer in your company: every event and visit of theirs. Answers how many events went. */
91
+ async forget(who) {
92
+ const hash = who.person_hash || await hashPerson(who);
93
+ if (!hash && !who.visitor_id) throw new Error("Say who: an email, a phone, a person_hash or a visitor_id.");
94
+ return await this.call("DELETE", "/api/v1/people", { person_hash: hash || void 0, visitor_id: who.visitor_id });
95
+ }
96
+ };
97
+ export {
98
+ Optiq,
99
+ OptiqError
100
+ };
package/dist/t.js ADDED
@@ -0,0 +1,2 @@
1
+ /* Optiq Studio Enterprise tracking 0.1.0: <script async src="https://enterprise.optiq.studio/t.js" data-key="pk_..."></script>. Built from @optiqstudio/track (packages/track); docs: https://enterprise.optiq.studio/developers */
2
+ "use strict";(()=>{function k(n){let t=typeof globalThis!="undefined"?globalThis.crypto:void 0;return!t||!t.subtle||typeof TextEncoder=="undefined"?Promise.resolve(null):t.subtle.digest("SHA-256",new TextEncoder().encode(n)).then(e=>Array.from(new Uint8Array(e),r=>r.toString(16).padStart(2,"0")).join(""),()=>null)}function m(n){if(n&&typeof n.email=="string"&&n.email.indexOf("@")>0)return k(n.email.trim().toLowerCase());if(n&&(typeof n.phone=="string"||typeof n.phone=="number")){let t=String(n.phone).replace(/[^0-9]/g,"");if(t.length>=7)return k(t)}return Promise.resolve(null)}var A="https://optiq-collect-951694748196.europe-west2.run.app/e",b=864e5,P=30*6e4,S=()=>typeof window!="undefined"&&typeof document!="undefined";function p(n){try{return window.localStorage.getItem(`optiq_${n}`)}catch(t){return null}}function d(n,t){try{t===null?window.localStorage.removeItem(`optiq_${n}`):window.localStorage.setItem(`optiq_${n}`,t)}catch(e){}}function h(n){try{return JSON.parse(p(n)||"{}")||{}}catch(t){return{}}}function w(n){let t="abcdefghijklmnopqrstuvwxyz0123456789",e=new Uint8Array(n);typeof crypto!="undefined"&&crypto.getRandomValues&&crypto.getRandomValues(e);let r="";for(let s=0;s<n;s++)r+=t.charAt((e[s]||Math.floor(Math.random()*256))%36);return r}var y=class{constructor(t){this.queue=[];this.timer=null;this.lastPath=null;this.wired=!1;this.key=t.key,this.endpoint=t.endpoint||A,this.consentState=t.consent||p("consent")||"unknown";let e=new URLSearchParams(window.location.search),r=Date.now();this.touch=h("touch");let s=e.get("osr");if(s&&/^[A-Za-z0-9]{4,16}$/.test(s)){let l=new Date(r).toISOString();(!this.touch.first||r-Date.parse(this.touch.first.at)>90*b)&&(this.touch.first={ref:s,at:l}),this.touch.last={ref:s,at:l},d("touch",JSON.stringify(this.touch))}let i={};for(let l of["source","medium","campaign","content","term"]){let f=e.get(`utm_${l}`);f&&(i[l]=f.slice(0,200))}Object.keys(i).length&&d("utm",JSON.stringify(i)),this.utm=Object.keys(i).length?i:h("utm");let o={};for(let l of["gclid","fbclid","ttclid","wbraid","gbraid"]){let f=e.get(l);f&&(o[l]=f.slice(0,500))}Object.keys(o).length&&d("clicks",JSON.stringify(o)),this.clicks=Object.keys(o).length?o:h("clicks");let u=this.consentState==="denied"?{}:h("visit"),c=`${e.get("utm_campaign")||""}|${s||""}`;this.visit=u.id&&r-(u.last||0)<=P&&(c==="|"||c===u.from)?u:{id:w(20),started:new Date(r).toISOString(),from:c,landing:window.location.pathname,referrer:document.referrer?document.referrer.split("?")[0].slice(0,300):null,utm:i,click:e.get("gclid")?"gclid":e.get("fbclid")?"fbclid":e.get("ttclid")?"ttclid":null,ref:s&&/^[A-Za-z0-9]{4,16}$/.test(s)?s:null},this.person=p("ph"),t.auto!==!1&&this.wire()}visitor(){if(this.consentState==="denied")return null;let t=p("vid");return t||(t=w(20),d("vid",t)),t}context(){let t=null;try{t=Intl.DateTimeFormat().resolvedOptions().timeZone||null}catch(r){t=null}let e=this.visit;return{id:e.id,started_at:e.started,landing:e.landing,referrer:e.referrer,utm:e.utm,click:e.click,ref:e.ref,lang:(navigator.language||"").slice(0,20)||null,tz:t,screen:window.screen?`${window.screen.width}x${window.screen.height}`:null}}flush(){for(this.timer&&clearTimeout(this.timer),this.timer=null;this.queue.length;){let t=JSON.stringify({key:this.key,visit:this.context(),events:this.queue.splice(0,50)}),e=!1;try{e=!!(navigator.sendBeacon&&navigator.sendBeacon(this.endpoint,new Blob([t],{type:"text/plain"})))}catch(r){e=!1}if(!e)try{fetch(this.endpoint,{method:"POST",body:t,keepalive:!0,mode:"cors",headers:{"Content-Type":"text/plain"}}).catch(()=>{})}catch(r){}}}track(t,e={}){let r={};for(let[c,l]of Object.entries(e))!["value","currency","email","phone"].includes(c)&&l!==void 0&&(r[c]=l);let s=Date.now();this.visit.last=s,this.consentState!=="denied"&&d("visit",JSON.stringify(this.visit));let i=this.touch.last&&s-Date.parse(this.touch.last.at)<=7*b?this.touch.last:null,o=this.consentState==="denied",u={event_id:w(18),name:String(t||"").toLowerCase(),occurred_at:new Date(s).toISOString(),visitor_id:this.visitor(),page_url:window.location.origin+window.location.pathname,referrer:document.referrer?document.referrer.split("?")[0].slice(0,300):null,utm:this.utm,click_ids:o?{}:this.clicks,first_ref:this.touch.first?this.touch.first.ref:null,first_ref_at:this.touch.first?this.touch.first.at:null,last_ref:i?i.ref:null,last_ref_at:i?i.at:null,value:e.value===void 0?null:Number(e.value),currency:e.currency||null,properties:r,consent:this.consentState};m(o?null:e).then(c=>{c&&(this.person=c,d("ph",c)),u.person_hash=o?null:this.person,this.queue.push(u),this.timer||(this.timer=setTimeout(()=>this.flush(),400))})}identify(t){m(t).then(e=>{e&&this.consentState!=="denied"&&(this.person=e,d("ph",e))})}consent(t){if(this.consentState=t==="granted"||t==="denied"?t:"unknown",d("consent",this.consentState),this.consentState==="denied"){for(let e of["vid","ph","clicks","visit"])d(e,null);this.person=null,this.clicks={}}}page(){let t=window.location.pathname+window.location.search;t!==this.lastPath&&(this.lastPath=t,this.track("page_view"))}wire(){if(this.wired)return;this.wired=!0,this.page();for(let e of["pushState","replaceState"]){let r=history[e];typeof r=="function"&&(history[e]=function(...s){let i=r.apply(this,s);return setTimeout(()=>a==null?void 0:a.page(),0),i})}window.addEventListener("popstate",()=>this.page());let t=typeof WeakSet=="function"?new WeakSet:null;document.addEventListener("focusin",e=>{var s,i;let r=(i=(s=e.target)==null?void 0:s.closest)==null?void 0:i.call(s,"form");!r||!t||t.has(r)||(t.add(r),this.track("form_start",{form:(r.getAttribute("name")||r.id||"").slice(0,60)||void 0}))}),document.addEventListener("submit",e=>{let r=e.target;if(!r||!r.getAttribute||r.getAttribute("data-optiq")!=="lead")return;let s=r.querySelector('input[type="email"]'),i=r.querySelector('input[type="tel"]');this.track("lead",{form:(r.getAttribute("name")||r.id||"").slice(0,60)||void 0,email:s?s.value:void 0,phone:i?i.value:void 0}),this.flush()}),document.addEventListener("click",e=>{var i,o;let r=(o=(i=e.target)==null?void 0:i.closest)==null?void 0:o.call(i,"a[href]");if(!r)return;let s=L(r.getAttribute("href")||"");s&&(this.track("contact",{channel:s}),this.flush())},!0),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"&&this.flush()}),window.addEventListener("pagehide",()=>this.flush())}};function L(n){return/^tel:/i.test(n)?"call":/^mailto:[^?]/i.test(n)?"email":/^https?:\/\/(wa\.me\/\+?[0-9]|api\.whatsapp\.com\/send\/?\?(.*&)?phone=)/i.test(n)?"whatsapp":null}var a=null,_=[];function q(n){return S()?a||(!n||typeof n.key!="string"||!/^pk_[A-Za-z0-9]{20,40}$/.test(n.key)?(console.warn("[optiq] init needs the source's publishable key (pk_...), from the Developer page."),null):(a=new y(n),_.splice(0).forEach(t=>t()),a)):null}var g=n=>{S()&&(a?n(a):_.push(()=>a&&n(a)))},v=(n,t)=>g(e=>e.track(n,t)),T=n=>g(t=>t.identify(n)),O=n=>g(t=>t.consent(n)),x=()=>g(n=>n.page());(function(){if(window.__optiqLoaded)return;window.__optiqLoaded=!0;let n=document.currentScript||document.querySelector('script[src*="/t.js"][data-key]');if(!n)return;let t=(window.optiq&&window.optiq.q||[]).map(i=>Array.prototype.slice.call(i));function e(i,o,u){i==="track"?v(String(o),u||{}):i==="identify"?T(o||{}):i==="consent"?O(o):i==="page"&&x()}let r=(...i)=>e(...i);r.track=(i,o)=>v(i,o),window.optiq=r;let s=t.filter(i=>i[0]==="consent").pop();q({key:n.getAttribute("data-key")||"",endpoint:n.getAttribute("data-endpoint")||void 0,consent:s&&(s[1]==="granted"||s[1]==="denied")?s[1]:void 0}),t.forEach(i=>e(...i))})();})();
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@optiqstudio/track",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Optiq Studio Enterprise tracking: join every lead, sale and deposit to the video, post or advert that brought it. Browser and server.",
5
+ "keywords": ["optiq", "optiq studio", "attribution", "tracking", "conversions", "analytics", "marketing", "whatsapp"],
6
+ "homepage": "https://enterprise.optiq.studio/developers",
7
+ "license": "MIT",
8
+ "author": "DaveLabs <info@davelabs.co> (https://davelabs.co)",
9
+ "type": "module",
10
+ "sideEffects": false,
11
+ "main": "./dist/index.cjs",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "require": "./dist/index.cjs"
19
+ },
20
+ "./server": {
21
+ "types": "./dist/server.d.ts",
22
+ "import": "./dist/server.js",
23
+ "require": "./dist/server.cjs"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": ["dist", "README.md", "LICENSE"],
28
+ "engines": { "node": ">=20" },
29
+ "scripts": {
30
+ "build": "node build.mjs",
31
+ "test": "node --test test/*.test.mjs",
32
+ "prepublishOnly": "npm run build && npm test"
33
+ },
34
+ "devDependencies": {
35
+ "esbuild": "0.25.12",
36
+ "typescript": "5.9.3"
37
+ }
38
+ }