@optiqstudio/track 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/browser.d.ts CHANGED
@@ -10,6 +10,45 @@ export interface InitOptions {
10
10
  endpoint?: string;
11
11
  /** The visitor's choice, if the site already knows it. */
12
12
  consent?: Consent;
13
+ /** Where suggestions come from. Default: the Optiq Studio Enterprise API. */
14
+ api?: string;
15
+ }
16
+ /** A product to show a shopper, and why. */
17
+ export interface Suggestion {
18
+ id: string;
19
+ name: string | null;
20
+ category: string | null;
21
+ brand: string | null;
22
+ price: number | null;
23
+ currency: string | null;
24
+ url: string | null;
25
+ image: string | null;
26
+ /** bought_together, viewed_together, in_category, popular or recently_viewed. */
27
+ reason: string;
28
+ /** The product it goes with (bought_together, viewed_together). */
29
+ because: string | null;
30
+ }
31
+ export interface SuggestOptions {
32
+ /** The product on screen: suggestions go with it, and it is left out. */
33
+ product?: string;
34
+ /** Product ids to leave out (what is already in the basket, for example). */
35
+ exclude?: string[];
36
+ /** How many, 1 to 24. Default 8. */
37
+ limit?: number;
38
+ }
39
+ /** One thing in a basket or an order. */
40
+ export interface Item {
41
+ id: string;
42
+ name?: string;
43
+ category?: string;
44
+ brand?: string;
45
+ variant?: string;
46
+ price?: number;
47
+ quantity?: number;
48
+ /** The product's page. */
49
+ url?: string;
50
+ /** The product's picture. */
51
+ image?: string;
13
52
  }
14
53
  export interface Properties {
15
54
  /** A sale's or deposit's amount. */
@@ -20,19 +59,24 @@ export interface Properties {
20
59
  email?: string;
21
60
  /** Hashed in the browser before anything is sent (digits only). */
22
61
  phone?: string | number;
23
- [property: string]: string | number | boolean | undefined;
62
+ /** What was viewed, put in the basket or bought (view_content, add_to_cart, begin_checkout, purchase). */
63
+ items?: Item[];
64
+ [property: string]: string | number | boolean | Item[] | undefined;
24
65
  }
25
66
  export declare const COLLECTOR = "https://optiq-collect-951694748196.europe-west2.run.app/e";
67
+ export declare const API = "https://enterpriseapi-951694748196.europe-west2.run.app";
26
68
  /** One tracker on one page. Most sites use the module functions below, which share one. */
27
69
  export declare class Tracker {
28
70
  private key;
29
71
  private endpoint;
72
+ private api;
30
73
  private consentState;
31
74
  private touch;
32
75
  private utm;
33
76
  private clicks;
34
77
  private visit;
35
78
  private person;
79
+ private personKind;
36
80
  private queue;
37
81
  private timer;
38
82
  private lastPath;
@@ -44,6 +88,11 @@ export declare class Tracker {
44
88
  flush(): void;
45
89
  /** Something a visitor did: a standard name (lead, sign_up, purchase, first_deposit, ...) or one of your own. */
46
90
  track(name: string, props?: Properties): void;
91
+ /** Products to show this shopper, from what they (and shoppers like them) looked at and bought, and why. */
92
+ suggest(o?: SuggestOptions): Promise<{
93
+ suggestions: Suggestion[];
94
+ recentlyViewed: Suggestion[];
95
+ }>;
47
96
  /** Who the visitor is, from now on: their email or phone, hashed here. */
48
97
  identify(p: {
49
98
  email?: string;
@@ -53,6 +102,11 @@ export declare class Tracker {
53
102
  consent(state: Consent): void;
54
103
  /** A page view (called by itself in auto mode, on load and on every move of a single-page site). */
55
104
  page(): void;
105
+ /**
106
+ * What a shop's page says by itself: a product looked at (the schema.org Product the page publishes for Google, with
107
+ * its id, name, category, brand and price) and a search (?q=, ?s=, ?search=, ?query=, ?keyword=). Once per page.
108
+ */
109
+ private shopSignals;
56
110
  private wire;
57
111
  }
58
112
  /** 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. */
@@ -72,3 +126,8 @@ export declare const consent: (state: Consent) => void;
72
126
  export declare const page: () => void;
73
127
  /** Sends what waits now. */
74
128
  export declare const flush: () => void;
129
+ /** Products to show this shopper, and why (an empty list if anything goes wrong, so a page never breaks). */
130
+ export declare function suggest(o?: SuggestOptions): Promise<{
131
+ suggestions: Suggestion[];
132
+ recentlyViewed: Suggestion[];
133
+ }>;
package/dist/hash.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  /** SHA-256 in hex, by Web Crypto (browsers, Node 20+, Deno, Bun, edge runtimes); null where it is missing. */
2
2
  export declare function sha256(s: string): Promise<string | null>;
3
+ /** Which a person hash is of: the ad platforms take emails and phones apart. */
4
+ export type PersonKind = "email" | "phone";
5
+ /** The hash and what it is of, or null. */
6
+ export declare function personOf(p: {
7
+ email?: unknown;
8
+ phone?: unknown;
9
+ } | null | undefined): Promise<{
10
+ hash: string;
11
+ kind: PersonKind;
12
+ } | null>;
3
13
  /** An email (trimmed, lower-case) or a phone (digits only), as a SHA-256: the only way a person is ever sent. */
4
14
  export declare function hashPerson(p: {
5
15
  email?: unknown;
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ API: () => API,
23
24
  COLLECTOR: () => COLLECTOR,
24
25
  Tracker: () => Tracker,
25
26
  consent: () => consent,
@@ -29,6 +30,7 @@ __export(index_exports, {
29
30
  identify: () => identify,
30
31
  init: () => init,
31
32
  page: () => page,
33
+ suggest: () => suggest,
32
34
  track: () => track
33
35
  });
34
36
  module.exports = __toCommonJS(index_exports);
@@ -42,6 +44,18 @@ function sha256(s) {
42
44
  () => null
43
45
  );
44
46
  }
47
+ async function personOf(p) {
48
+ if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) {
49
+ const hash = await sha256(p.email.trim().toLowerCase());
50
+ return hash ? { hash, kind: "email" } : null;
51
+ }
52
+ if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
53
+ const digits = String(p.phone).replace(/[^0-9]/g, "");
54
+ const hash = digits.length >= 7 ? await sha256(digits) : null;
55
+ return hash ? { hash, kind: "phone" } : null;
56
+ }
57
+ return null;
58
+ }
45
59
  function hashPerson(p) {
46
60
  if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
47
61
  if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
@@ -53,6 +67,7 @@ function hashPerson(p) {
53
67
 
54
68
  // src/browser.ts
55
69
  var COLLECTOR = "https://optiq-collect-951694748196.europe-west2.run.app/e";
70
+ var API = "https://enterpriseapi-951694748196.europe-west2.run.app";
56
71
  var DAY = 864e5;
57
72
  var IDLE = 30 * 6e4;
58
73
  var hasWindow = () => typeof window !== "undefined" && typeof document !== "undefined";
@@ -93,6 +108,7 @@ var Tracker = class {
93
108
  this.wired = false;
94
109
  this.key = o.key;
95
110
  this.endpoint = o.endpoint || COLLECTOR;
111
+ this.api = (o.api || API).replace(/\/+$/, "");
96
112
  this.consentState = o.consent || get("consent") || "unknown";
97
113
  const q = new URLSearchParams(window.location.search);
98
114
  const now = Date.now();
@@ -131,6 +147,7 @@ var Tracker = class {
131
147
  ref: osr && /^[A-Za-z0-9]{4,16}$/.test(osr) ? osr : null
132
148
  };
133
149
  this.person = get("ph");
150
+ this.personKind = get("pk") || null;
134
151
  if (o.auto !== false) this.wire();
135
152
  }
136
153
  visitor() {
@@ -186,7 +203,7 @@ var Tracker = class {
186
203
  /** Something a visitor did: a standard name (lead, sign_up, purchase, first_deposit, ...) or one of your own. */
187
204
  track(name, props = {}) {
188
205
  const rest = {};
189
- for (const [k, v] of Object.entries(props)) if (!["value", "currency", "email", "phone"].includes(k) && v !== void 0) rest[k] = v;
206
+ 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;
190
207
  const fresh = Date.now();
191
208
  this.visit.last = fresh;
192
209
  if (this.consentState !== "denied") put("visit", JSON.stringify(this.visit));
@@ -200,7 +217,7 @@ var Tracker = class {
200
217
  page_url: window.location.origin + window.location.pathname,
201
218
  referrer: document.referrer ? document.referrer.split("?")[0].slice(0, 300) : null,
202
219
  utm: this.utm,
203
- click_ids: denied ? {} : this.clicks,
220
+ click_ids: denied ? {} : { ...this.clicks, ...platformCookies() },
204
221
  first_ref: this.touch.first ? this.touch.first.ref : null,
205
222
  first_ref_at: this.touch.first ? this.touch.first.at : null,
206
223
  last_ref: last ? last.ref : null,
@@ -208,24 +225,46 @@ var Tracker = class {
208
225
  value: props.value === void 0 ? null : Number(props.value),
209
226
  currency: props.currency || null,
210
227
  properties: rest,
211
- consent: this.consentState
228
+ consent: this.consentState,
229
+ ...Array.isArray(props.items) && props.items.length ? { items: props.items.slice(0, 50) } : {}
212
230
  };
213
- void hashPerson(denied ? null : props).then((h) => {
231
+ void personOf(denied ? null : props).then((h) => {
214
232
  if (h) {
215
- this.person = h;
216
- put("ph", h);
233
+ this.person = h.hash;
234
+ this.personKind = h.kind;
235
+ put("ph", h.hash);
236
+ put("pk", h.kind);
217
237
  }
218
238
  ev.person_hash = denied ? null : this.person;
239
+ ev.person_kind = denied || !this.person ? null : this.personKind;
219
240
  this.queue.push(ev);
220
241
  if (!this.timer) this.timer = setTimeout(() => this.flush(), 400);
221
242
  });
222
243
  }
244
+ /** Products to show this shopper, from what they (and shoppers like them) looked at and bought, and why. */
245
+ async suggest(o = {}) {
246
+ const q = new URLSearchParams({ key: this.key });
247
+ const visitor = this.consentState === "denied" ? null : get("vid");
248
+ if (visitor) q.set("visitor", visitor);
249
+ if (o.product) q.set("product", o.product);
250
+ if (o.exclude?.length) q.set("exclude", o.exclude.join(","));
251
+ if (o.limit) q.set("limit", String(o.limit));
252
+ try {
253
+ const r = await fetch(`${this.api}/api/v1/suggest?${q}`, { mode: "cors", credentials: "omit" });
254
+ if (!r.ok) return { suggestions: [], recentlyViewed: [] };
255
+ return await r.json();
256
+ } catch {
257
+ return { suggestions: [], recentlyViewed: [] };
258
+ }
259
+ }
223
260
  /** Who the visitor is, from now on: their email or phone, hashed here. */
224
261
  identify(p) {
225
- void hashPerson(p).then((h) => {
262
+ void personOf(p).then((h) => {
226
263
  if (h && this.consentState !== "denied") {
227
- this.person = h;
228
- put("ph", h);
264
+ this.person = h.hash;
265
+ this.personKind = h.kind;
266
+ put("ph", h.hash);
267
+ put("pk", h.kind);
229
268
  }
230
269
  });
231
270
  }
@@ -234,8 +273,9 @@ var Tracker = class {
234
273
  this.consentState = state === "granted" || state === "denied" ? state : "unknown";
235
274
  put("consent", this.consentState);
236
275
  if (this.consentState === "denied") {
237
- for (const k of ["vid", "ph", "clicks", "visit"]) put(k, null);
276
+ for (const k of ["vid", "ph", "pk", "clicks", "visit"]) put(k, null);
238
277
  this.person = null;
278
+ this.personKind = null;
239
279
  this.clicks = {};
240
280
  }
241
281
  }
@@ -245,6 +285,19 @@ var Tracker = class {
245
285
  if (path === this.lastPath) return;
246
286
  this.lastPath = path;
247
287
  this.track("page_view");
288
+ if (this.wired) setTimeout(() => this.shopSignals(path), 1200);
289
+ }
290
+ /**
291
+ * What a shop's page says by itself: a product looked at (the schema.org Product the page publishes for Google, with
292
+ * its id, name, category, brand and price) and a search (?q=, ?s=, ?search=, ?query=, ?keyword=). Once per page.
293
+ */
294
+ shopSignals(path) {
295
+ if (path !== window.location.pathname + window.location.search) return;
296
+ const q = new URLSearchParams(window.location.search);
297
+ const query = ["q", "s", "search", "query", "keyword"].map((k) => q.get(k)).find((v) => v && v.trim());
298
+ if (query) this.track("search", { query: query.trim().slice(0, 200) });
299
+ const product = pageProduct();
300
+ if (product) this.track("view_content", { items: [product.item], value: product.item.price, currency: product.currency });
248
301
  }
249
302
  wire() {
250
303
  if (this.wired) return;
@@ -297,6 +350,62 @@ var Tracker = class {
297
350
  window.addEventListener("pagehide", () => this.flush());
298
351
  }
299
352
  };
353
+ function platformCookies() {
354
+ const out = {};
355
+ try {
356
+ for (const part of document.cookie.split(";")) {
357
+ const [k, ...v] = part.trim().split("=");
358
+ const value = decodeURIComponent(v.join("=") || "").slice(0, 300);
359
+ if (!value) continue;
360
+ if (k === "_fbp") out.fbp = value;
361
+ else if (k === "_fbc") out.fbc = value;
362
+ else if (k === "_ttp") out.ttp = value;
363
+ }
364
+ } catch {
365
+ }
366
+ return out;
367
+ }
368
+ function pageProduct() {
369
+ const scripts = document.querySelectorAll('script[type="application/ld+json"]');
370
+ for (const el of Array.from(scripts)) {
371
+ let data;
372
+ try {
373
+ data = JSON.parse(el.textContent || "null");
374
+ } catch {
375
+ continue;
376
+ }
377
+ const queue = [data];
378
+ while (queue.length) {
379
+ const n = queue.shift();
380
+ if (Array.isArray(n)) {
381
+ queue.push(...n);
382
+ continue;
383
+ }
384
+ if (!n || typeof n !== "object") continue;
385
+ const o = n;
386
+ if (o["@graph"]) queue.push(o["@graph"]);
387
+ const type = o["@type"];
388
+ if (type !== "Product" && !(Array.isArray(type) && type.includes("Product"))) continue;
389
+ const offers = Array.isArray(o.offers) ? o.offers[0] : o.offers;
390
+ const id = String(o.sku || o.productID || o.gtin13 || o.mpn || o["@id"] || o.url || o.name || "").slice(0, 100);
391
+ if (!id) continue;
392
+ const brand = typeof o.brand === "string" ? o.brand : o.brand?.name;
393
+ const price = Number(offers?.price ?? offers?.lowPrice);
394
+ const item = { id };
395
+ if (typeof o.name === "string") item.name = o.name.slice(0, 200);
396
+ if (typeof o.category === "string") item.category = o.category.slice(0, 120);
397
+ if (brand) item.brand = String(brand).slice(0, 120);
398
+ if (Number.isFinite(price)) item.price = price;
399
+ const image = typeof o.image === "string" ? o.image : Array.isArray(o.image) ? o.image.find((x) => typeof x === "string") : o.image?.url;
400
+ if (typeof image === "string" && /^https?:\/\//.test(image)) item.image = image.slice(0, 500);
401
+ const page2 = typeof o.url === "string" && /^https?:\/\//.test(o.url) ? o.url : window.location.origin + window.location.pathname;
402
+ item.url = page2.slice(0, 500);
403
+ const currency = typeof offers?.priceCurrency === "string" ? offers.priceCurrency.toUpperCase().slice(0, 3) : void 0;
404
+ return { item, currency };
405
+ }
406
+ }
407
+ return null;
408
+ }
300
409
  function contactChannel(href) {
301
410
  if (/^tel:/i.test(href)) return "call";
302
411
  if (/^mailto:[^?]/i.test(href)) return "email";
@@ -326,3 +435,7 @@ var identify = (p) => later((t) => t.identify(p));
326
435
  var consent = (state) => later((t) => t.consent(state));
327
436
  var page = () => later((t) => t.page());
328
437
  var flush = () => later((t) => t.flush());
438
+ function suggest(o) {
439
+ if (!hasWindow()) return Promise.resolve({ suggestions: [], recentlyViewed: [] });
440
+ return new Promise((resolve) => later((t) => void t.suggest(o).then(resolve)));
441
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { COLLECTOR, Tracker, consent, contactChannel, flush, hashPerson, identify, init, page, track } from "./browser";
2
- export type { Consent, InitOptions, Properties } from "./browser";
1
+ export { API, COLLECTOR, Tracker, consent, contactChannel, flush, hashPerson, identify, init, page, suggest, track } from "./browser";
2
+ export type { Consent, InitOptions, Item, Properties, SuggestOptions, Suggestion } from "./browser";
package/dist/index.js CHANGED
@@ -7,6 +7,18 @@ function sha256(s) {
7
7
  () => null
8
8
  );
9
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
+ }
10
22
  function hashPerson(p) {
11
23
  if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
12
24
  if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
@@ -18,6 +30,7 @@ function hashPerson(p) {
18
30
 
19
31
  // src/browser.ts
20
32
  var COLLECTOR = "https://optiq-collect-951694748196.europe-west2.run.app/e";
33
+ var API = "https://enterpriseapi-951694748196.europe-west2.run.app";
21
34
  var DAY = 864e5;
22
35
  var IDLE = 30 * 6e4;
23
36
  var hasWindow = () => typeof window !== "undefined" && typeof document !== "undefined";
@@ -58,6 +71,7 @@ var Tracker = class {
58
71
  this.wired = false;
59
72
  this.key = o.key;
60
73
  this.endpoint = o.endpoint || COLLECTOR;
74
+ this.api = (o.api || API).replace(/\/+$/, "");
61
75
  this.consentState = o.consent || get("consent") || "unknown";
62
76
  const q = new URLSearchParams(window.location.search);
63
77
  const now = Date.now();
@@ -96,6 +110,7 @@ var Tracker = class {
96
110
  ref: osr && /^[A-Za-z0-9]{4,16}$/.test(osr) ? osr : null
97
111
  };
98
112
  this.person = get("ph");
113
+ this.personKind = get("pk") || null;
99
114
  if (o.auto !== false) this.wire();
100
115
  }
101
116
  visitor() {
@@ -151,7 +166,7 @@ var Tracker = class {
151
166
  /** Something a visitor did: a standard name (lead, sign_up, purchase, first_deposit, ...) or one of your own. */
152
167
  track(name, props = {}) {
153
168
  const rest = {};
154
- for (const [k, v] of Object.entries(props)) if (!["value", "currency", "email", "phone"].includes(k) && v !== void 0) rest[k] = v;
169
+ 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;
155
170
  const fresh = Date.now();
156
171
  this.visit.last = fresh;
157
172
  if (this.consentState !== "denied") put("visit", JSON.stringify(this.visit));
@@ -165,7 +180,7 @@ var Tracker = class {
165
180
  page_url: window.location.origin + window.location.pathname,
166
181
  referrer: document.referrer ? document.referrer.split("?")[0].slice(0, 300) : null,
167
182
  utm: this.utm,
168
- click_ids: denied ? {} : this.clicks,
183
+ click_ids: denied ? {} : { ...this.clicks, ...platformCookies() },
169
184
  first_ref: this.touch.first ? this.touch.first.ref : null,
170
185
  first_ref_at: this.touch.first ? this.touch.first.at : null,
171
186
  last_ref: last ? last.ref : null,
@@ -173,24 +188,46 @@ var Tracker = class {
173
188
  value: props.value === void 0 ? null : Number(props.value),
174
189
  currency: props.currency || null,
175
190
  properties: rest,
176
- consent: this.consentState
191
+ consent: this.consentState,
192
+ ...Array.isArray(props.items) && props.items.length ? { items: props.items.slice(0, 50) } : {}
177
193
  };
178
- void hashPerson(denied ? null : props).then((h) => {
194
+ void personOf(denied ? null : props).then((h) => {
179
195
  if (h) {
180
- this.person = h;
181
- put("ph", h);
196
+ this.person = h.hash;
197
+ this.personKind = h.kind;
198
+ put("ph", h.hash);
199
+ put("pk", h.kind);
182
200
  }
183
201
  ev.person_hash = denied ? null : this.person;
202
+ ev.person_kind = denied || !this.person ? null : this.personKind;
184
203
  this.queue.push(ev);
185
204
  if (!this.timer) this.timer = setTimeout(() => this.flush(), 400);
186
205
  });
187
206
  }
207
+ /** Products to show this shopper, from what they (and shoppers like them) looked at and bought, and why. */
208
+ async suggest(o = {}) {
209
+ const q = new URLSearchParams({ key: this.key });
210
+ const visitor = this.consentState === "denied" ? null : get("vid");
211
+ if (visitor) q.set("visitor", visitor);
212
+ if (o.product) q.set("product", o.product);
213
+ if (o.exclude?.length) q.set("exclude", o.exclude.join(","));
214
+ if (o.limit) q.set("limit", String(o.limit));
215
+ try {
216
+ const r = await fetch(`${this.api}/api/v1/suggest?${q}`, { mode: "cors", credentials: "omit" });
217
+ if (!r.ok) return { suggestions: [], recentlyViewed: [] };
218
+ return await r.json();
219
+ } catch {
220
+ return { suggestions: [], recentlyViewed: [] };
221
+ }
222
+ }
188
223
  /** Who the visitor is, from now on: their email or phone, hashed here. */
189
224
  identify(p) {
190
- void hashPerson(p).then((h) => {
225
+ void personOf(p).then((h) => {
191
226
  if (h && this.consentState !== "denied") {
192
- this.person = h;
193
- put("ph", h);
227
+ this.person = h.hash;
228
+ this.personKind = h.kind;
229
+ put("ph", h.hash);
230
+ put("pk", h.kind);
194
231
  }
195
232
  });
196
233
  }
@@ -199,8 +236,9 @@ var Tracker = class {
199
236
  this.consentState = state === "granted" || state === "denied" ? state : "unknown";
200
237
  put("consent", this.consentState);
201
238
  if (this.consentState === "denied") {
202
- for (const k of ["vid", "ph", "clicks", "visit"]) put(k, null);
239
+ for (const k of ["vid", "ph", "pk", "clicks", "visit"]) put(k, null);
203
240
  this.person = null;
241
+ this.personKind = null;
204
242
  this.clicks = {};
205
243
  }
206
244
  }
@@ -210,6 +248,19 @@ var Tracker = class {
210
248
  if (path === this.lastPath) return;
211
249
  this.lastPath = path;
212
250
  this.track("page_view");
251
+ if (this.wired) setTimeout(() => this.shopSignals(path), 1200);
252
+ }
253
+ /**
254
+ * What a shop's page says by itself: a product looked at (the schema.org Product the page publishes for Google, with
255
+ * its id, name, category, brand and price) and a search (?q=, ?s=, ?search=, ?query=, ?keyword=). Once per page.
256
+ */
257
+ shopSignals(path) {
258
+ if (path !== window.location.pathname + window.location.search) return;
259
+ const q = new URLSearchParams(window.location.search);
260
+ const query = ["q", "s", "search", "query", "keyword"].map((k) => q.get(k)).find((v) => v && v.trim());
261
+ if (query) this.track("search", { query: query.trim().slice(0, 200) });
262
+ const product = pageProduct();
263
+ if (product) this.track("view_content", { items: [product.item], value: product.item.price, currency: product.currency });
213
264
  }
214
265
  wire() {
215
266
  if (this.wired) return;
@@ -262,6 +313,62 @@ var Tracker = class {
262
313
  window.addEventListener("pagehide", () => this.flush());
263
314
  }
264
315
  };
316
+ function platformCookies() {
317
+ const out = {};
318
+ try {
319
+ for (const part of document.cookie.split(";")) {
320
+ const [k, ...v] = part.trim().split("=");
321
+ const value = decodeURIComponent(v.join("=") || "").slice(0, 300);
322
+ if (!value) continue;
323
+ if (k === "_fbp") out.fbp = value;
324
+ else if (k === "_fbc") out.fbc = value;
325
+ else if (k === "_ttp") out.ttp = value;
326
+ }
327
+ } catch {
328
+ }
329
+ return out;
330
+ }
331
+ function pageProduct() {
332
+ const scripts = document.querySelectorAll('script[type="application/ld+json"]');
333
+ for (const el of Array.from(scripts)) {
334
+ let data;
335
+ try {
336
+ data = JSON.parse(el.textContent || "null");
337
+ } catch {
338
+ continue;
339
+ }
340
+ const queue = [data];
341
+ while (queue.length) {
342
+ const n = queue.shift();
343
+ if (Array.isArray(n)) {
344
+ queue.push(...n);
345
+ continue;
346
+ }
347
+ if (!n || typeof n !== "object") continue;
348
+ const o = n;
349
+ if (o["@graph"]) queue.push(o["@graph"]);
350
+ const type = o["@type"];
351
+ if (type !== "Product" && !(Array.isArray(type) && type.includes("Product"))) continue;
352
+ const offers = Array.isArray(o.offers) ? o.offers[0] : o.offers;
353
+ const id = String(o.sku || o.productID || o.gtin13 || o.mpn || o["@id"] || o.url || o.name || "").slice(0, 100);
354
+ if (!id) continue;
355
+ const brand = typeof o.brand === "string" ? o.brand : o.brand?.name;
356
+ const price = Number(offers?.price ?? offers?.lowPrice);
357
+ const item = { id };
358
+ if (typeof o.name === "string") item.name = o.name.slice(0, 200);
359
+ if (typeof o.category === "string") item.category = o.category.slice(0, 120);
360
+ if (brand) item.brand = String(brand).slice(0, 120);
361
+ if (Number.isFinite(price)) item.price = price;
362
+ const image = typeof o.image === "string" ? o.image : Array.isArray(o.image) ? o.image.find((x) => typeof x === "string") : o.image?.url;
363
+ if (typeof image === "string" && /^https?:\/\//.test(image)) item.image = image.slice(0, 500);
364
+ const page2 = typeof o.url === "string" && /^https?:\/\//.test(o.url) ? o.url : window.location.origin + window.location.pathname;
365
+ item.url = page2.slice(0, 500);
366
+ const currency = typeof offers?.priceCurrency === "string" ? offers.priceCurrency.toUpperCase().slice(0, 3) : void 0;
367
+ return { item, currency };
368
+ }
369
+ }
370
+ return null;
371
+ }
265
372
  function contactChannel(href) {
266
373
  if (/^tel:/i.test(href)) return "call";
267
374
  if (/^mailto:[^?]/i.test(href)) return "email";
@@ -291,7 +398,12 @@ var identify = (p) => later((t) => t.identify(p));
291
398
  var consent = (state) => later((t) => t.consent(state));
292
399
  var page = () => later((t) => t.page());
293
400
  var flush = () => later((t) => t.flush());
401
+ function suggest(o) {
402
+ if (!hasWindow()) return Promise.resolve({ suggestions: [], recentlyViewed: [] });
403
+ return new Promise((resolve) => later((t) => void t.suggest(o).then(resolve)));
404
+ }
294
405
  export {
406
+ API,
295
407
  COLLECTOR,
296
408
  Tracker,
297
409
  consent,
@@ -301,5 +413,6 @@ export {
301
413
  identify,
302
414
  init,
303
415
  page,
416
+ suggest,
304
417
  track
305
418
  };
package/dist/server.cjs CHANGED
@@ -34,6 +34,18 @@ function sha256(s) {
34
34
  () => null
35
35
  );
36
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
+ }
37
49
  function hashPerson(p) {
38
50
  if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
39
51
  if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
@@ -58,7 +70,7 @@ var OptiqError = class extends Error {
58
70
  this.name = "OptiqError";
59
71
  }
60
72
  };
61
- var Optiq = class {
73
+ var Optiq = class _Optiq {
62
74
  constructor(o) {
63
75
  if (!o || typeof o.secretKey !== "string" || !/^sk_[A-Za-z0-9]{30,60}$/.test(o.secretKey)) {
64
76
  throw new Error("Optiq needs the source's secret key (sk_...), from the Developer page.");
@@ -91,14 +103,15 @@ var Optiq = class {
91
103
  }
92
104
  async prepare(e) {
93
105
  const { email, phone, ...rest } = e;
94
- const hash = e.person_hash || await hashPerson({ email, phone });
106
+ const who = e.person_hash ? { hash: e.person_hash, kind: e.person_kind || "email" } : await personOf({ email, phone });
95
107
  const iso = (v) => v instanceof Date ? v.toISOString() : v;
96
108
  return {
97
109
  ...rest,
98
110
  event_id: e.event_id || newId(),
99
111
  occurred_at: iso(e.occurred_at),
100
112
  last_ref_at: iso(e.last_ref_at),
101
- person_hash: hash || void 0
113
+ person_hash: who?.hash || void 0,
114
+ person_kind: who?.kind || void 0
102
115
  };
103
116
  }
104
117
  /** Sends one event or many (any number: they go 500 at a time). Answers what was kept, repeated or refused. */
@@ -114,6 +127,33 @@ var Optiq = class {
114
127
  }
115
128
  return out;
116
129
  }
130
+ async get(path) {
131
+ const r = await fetch(`${this.baseUrl}${path}`, { headers: { Authorization: `Bearer ${this.secretKey}` } });
132
+ const data = await r.json().catch(() => ({}));
133
+ if (!r.ok) throw new OptiqError(typeof data.error === "string" ? data.error : `Optiq answered ${r.status}.`, r.status);
134
+ return data;
135
+ }
136
+ static who(w) {
137
+ const q = new URLSearchParams();
138
+ if (w.person_hash) q.set("person_hash", w.person_hash);
139
+ else if (w.email) q.set("email", w.email);
140
+ else if (w.phone !== void 0) q.set("phone", String(w.phone));
141
+ if (w.visitor_id) q.set("visitor_id", w.visitor_id);
142
+ return q;
143
+ }
144
+ /** Products to offer one customer (across every device they used), and why: for your pages, emails and messages. */
145
+ async suggest(o) {
146
+ const q = _Optiq.who(o);
147
+ if (o.visitor_id) q.set("visitor", o.visitor_id);
148
+ if (o.product) q.set("product", o.product);
149
+ if (o.exclude?.length) q.set("exclude", o.exclude.join(","));
150
+ if (o.limit) q.set("limit", String(o.limit));
151
+ return this.get(`/api/v1/suggest?${q}`);
152
+ }
153
+ /** Everything your company knows about one customer: visits, sources, products, orders, spend, segment, a timeline. */
154
+ async profile(who) {
155
+ return this.get(`/api/v1/people?${_Optiq.who(who)}`);
156
+ }
117
157
  /** Forgets a customer in your company: every event and visit of theirs. Answers how many events went. */
118
158
  async forget(who) {
119
159
  const hash = who.person_hash || await hashPerson(who);
package/dist/server.d.ts CHANGED
@@ -12,6 +12,20 @@ export interface ServerEvent {
12
12
  phone?: string | number;
13
13
  /** Who, if you already hash: SHA-256 of the trimmed, lower-case email, or of the phone's digits. */
14
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
+ url?: string;
27
+ image?: string;
28
+ }[];
15
29
  /** The browser's visitor id, if your site passes it to your server (localStorage optiq_vid). */
16
30
  visitor_id?: string;
17
31
  value?: number;
@@ -58,6 +72,28 @@ export declare class Optiq {
58
72
  private prepare;
59
73
  /** Sends one event or many (any number: they go 500 at a time). Answers what was kept, repeated or refused. */
60
74
  track(events: ServerEvent | ServerEvent[]): Promise<TrackResult>;
75
+ private get;
76
+ private static who;
77
+ /** Products to offer one customer (across every device they used), and why: for your pages, emails and messages. */
78
+ suggest(o: {
79
+ email?: string;
80
+ phone?: string | number;
81
+ person_hash?: string;
82
+ visitor_id?: string;
83
+ product?: string;
84
+ exclude?: string[];
85
+ limit?: number;
86
+ }): Promise<{
87
+ suggestions: Record<string, unknown>[];
88
+ recentlyViewed: Record<string, unknown>[];
89
+ }>;
90
+ /** Everything your company knows about one customer: visits, sources, products, orders, spend, segment, a timeline. */
91
+ profile(who: {
92
+ email?: string;
93
+ phone?: string | number;
94
+ person_hash?: string;
95
+ visitor_id?: string;
96
+ }): Promise<Record<string, unknown>>;
61
97
  /** Forgets a customer in your company: every event and visit of theirs. Answers how many events went. */
62
98
  forget(who: {
63
99
  email?: string;
package/dist/server.js CHANGED
@@ -7,6 +7,18 @@ function sha256(s) {
7
7
  () => null
8
8
  );
9
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
+ }
10
22
  function hashPerson(p) {
11
23
  if (p && typeof p.email === "string" && p.email.indexOf("@") > 0) return sha256(p.email.trim().toLowerCase());
12
24
  if (p && (typeof p.phone === "string" || typeof p.phone === "number")) {
@@ -31,7 +43,7 @@ var OptiqError = class extends Error {
31
43
  this.name = "OptiqError";
32
44
  }
33
45
  };
34
- var Optiq = class {
46
+ var Optiq = class _Optiq {
35
47
  constructor(o) {
36
48
  if (!o || typeof o.secretKey !== "string" || !/^sk_[A-Za-z0-9]{30,60}$/.test(o.secretKey)) {
37
49
  throw new Error("Optiq needs the source's secret key (sk_...), from the Developer page.");
@@ -64,14 +76,15 @@ var Optiq = class {
64
76
  }
65
77
  async prepare(e) {
66
78
  const { email, phone, ...rest } = e;
67
- const hash = e.person_hash || await hashPerson({ email, phone });
79
+ const who = e.person_hash ? { hash: e.person_hash, kind: e.person_kind || "email" } : await personOf({ email, phone });
68
80
  const iso = (v) => v instanceof Date ? v.toISOString() : v;
69
81
  return {
70
82
  ...rest,
71
83
  event_id: e.event_id || newId(),
72
84
  occurred_at: iso(e.occurred_at),
73
85
  last_ref_at: iso(e.last_ref_at),
74
- person_hash: hash || void 0
86
+ person_hash: who?.hash || void 0,
87
+ person_kind: who?.kind || void 0
75
88
  };
76
89
  }
77
90
  /** Sends one event or many (any number: they go 500 at a time). Answers what was kept, repeated or refused. */
@@ -87,6 +100,33 @@ var Optiq = class {
87
100
  }
88
101
  return out;
89
102
  }
103
+ async get(path) {
104
+ const r = await fetch(`${this.baseUrl}${path}`, { headers: { Authorization: `Bearer ${this.secretKey}` } });
105
+ const data = await r.json().catch(() => ({}));
106
+ if (!r.ok) throw new OptiqError(typeof data.error === "string" ? data.error : `Optiq answered ${r.status}.`, r.status);
107
+ return data;
108
+ }
109
+ static who(w) {
110
+ const q = new URLSearchParams();
111
+ if (w.person_hash) q.set("person_hash", w.person_hash);
112
+ else if (w.email) q.set("email", w.email);
113
+ else if (w.phone !== void 0) q.set("phone", String(w.phone));
114
+ if (w.visitor_id) q.set("visitor_id", w.visitor_id);
115
+ return q;
116
+ }
117
+ /** Products to offer one customer (across every device they used), and why: for your pages, emails and messages. */
118
+ async suggest(o) {
119
+ const q = _Optiq.who(o);
120
+ if (o.visitor_id) q.set("visitor", o.visitor_id);
121
+ if (o.product) q.set("product", o.product);
122
+ if (o.exclude?.length) q.set("exclude", o.exclude.join(","));
123
+ if (o.limit) q.set("limit", String(o.limit));
124
+ return this.get(`/api/v1/suggest?${q}`);
125
+ }
126
+ /** Everything your company knows about one customer: visits, sources, products, orders, spend, segment, a timeline. */
127
+ async profile(who) {
128
+ return this.get(`/api/v1/people?${_Optiq.who(who)}`);
129
+ }
90
130
  /** Forgets a customer in your company: every event and visit of theirs. Answers how many events went. */
91
131
  async forget(who) {
92
132
  const hash = who.person_hash || await hashPerson(who);
package/dist/t.js CHANGED
@@ -1,2 +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))})();})();
1
+ /* Optiq Studio Enterprise tracking 0.3.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";(()=>{var N=Object.defineProperty;var x=Object.getOwnPropertySymbols;var H=Object.prototype.hasOwnProperty,U=Object.prototype.propertyIsEnumerable;var O=(n,t,e)=>t in n?N(n,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):n[t]=e,m=(n,t)=>{for(var e in t||(t={}))H.call(t,e)&&O(n,e,t[e]);if(x)for(var e of x(t))U.call(t,e)&&O(n,e,t[e]);return n};function T(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)}async function k(n){if(n&&typeof n.email=="string"&&n.email.indexOf("@")>0){let t=await T(n.email.trim().toLowerCase());return t?{hash:t,kind:"email"}:null}if(n&&(typeof n.phone=="string"||typeof n.phone=="number")){let t=String(n.phone).replace(/[^0-9]/g,""),e=t.length>=7?await T(t):null;return e?{hash:e,kind:"phone"}:null}return null}var J="https://optiq-collect-951694748196.europe-west2.run.app/e",M="https://enterpriseapi-951694748196.europe-west2.run.app",R=864e5,z=30*6e4,S=()=>typeof window!="undefined"&&typeof document!="undefined";function f(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 y(n){try{return JSON.parse(f(n)||"{}")||{}}catch(t){return{}}}function v(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 b=class{constructor(t){this.queue=[];this.timer=null;this.lastPath=null;this.wired=!1;this.key=t.key,this.endpoint=t.endpoint||J,this.api=(t.api||M).replace(/\/+$/,""),this.consentState=t.consent||f("consent")||"unknown";let e=new URLSearchParams(window.location.search),r=Date.now();this.touch=y("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*R)&&(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 u=e.get(`utm_${l}`);u&&(i[l]=u.slice(0,200))}Object.keys(i).length&&d("utm",JSON.stringify(i)),this.utm=Object.keys(i).length?i:y("utm");let a={};for(let l of["gclid","fbclid","ttclid","wbraid","gbraid"]){let u=e.get(l);u&&(a[l]=u.slice(0,500))}Object.keys(a).length&&d("clicks",JSON.stringify(a)),this.clicks=Object.keys(a).length?a:y("clicks");let c=this.consentState==="denied"?{}:y("visit"),o=`${e.get("utm_campaign")||""}|${s||""}`;this.visit=c.id&&r-(c.last||0)<=z&&(o==="|"||o===c.from)?c:{id:v(20),started:new Date(r).toISOString(),from:o,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=f("ph"),this.personKind=f("pk")||null,t.auto!==!1&&this.wire()}visitor(){if(this.consentState==="denied")return null;let t=f("vid");return t||(t=v(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[o,l]of Object.entries(e))!["value","currency","email","phone","items"].includes(o)&&l!==void 0&&!Array.isArray(l)&&(r[o]=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*R?this.touch.last:null,a=this.consentState==="denied",c=m({event_id:v(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:a?{}:m(m({},this.clicks),W()),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},Array.isArray(e.items)&&e.items.length?{items:e.items.slice(0,50)}:{});k(a?null:e).then(o=>{o&&(this.person=o.hash,this.personKind=o.kind,d("ph",o.hash),d("pk",o.kind)),c.person_hash=a?null:this.person,c.person_kind=a||!this.person?null:this.personKind,this.queue.push(c),this.timer||(this.timer=setTimeout(()=>this.flush(),400))})}async suggest(t={}){var s;let e=new URLSearchParams({key:this.key}),r=this.consentState==="denied"?null:f("vid");r&&e.set("visitor",r),t.product&&e.set("product",t.product),(s=t.exclude)!=null&&s.length&&e.set("exclude",t.exclude.join(",")),t.limit&&e.set("limit",String(t.limit));try{let i=await fetch(`${this.api}/api/v1/suggest?${e}`,{mode:"cors",credentials:"omit"});return i.ok?await i.json():{suggestions:[],recentlyViewed:[]}}catch(i){return{suggestions:[],recentlyViewed:[]}}}identify(t){k(t).then(e=>{e&&this.consentState!=="denied"&&(this.person=e.hash,this.personKind=e.kind,d("ph",e.hash),d("pk",e.kind))})}consent(t){if(this.consentState=t==="granted"||t==="denied"?t:"unknown",d("consent",this.consentState),this.consentState==="denied"){for(let e of["vid","ph","pk","clicks","visit"])d(e,null);this.person=null,this.personKind=null,this.clicks={}}}page(){let t=window.location.pathname+window.location.search;t!==this.lastPath&&(this.lastPath=t,this.track("page_view"),this.wired&&setTimeout(()=>this.shopSignals(t),1200))}shopSignals(t){if(t!==window.location.pathname+window.location.search)return;let e=new URLSearchParams(window.location.search),r=["q","s","search","query","keyword"].map(i=>e.get(i)).find(i=>i&&i.trim());r&&this.track("search",{query:r.trim().slice(0,200)});let s=Z();s&&this.track("view_content",{items:[s.item],value:s.item.price,currency:s.currency})}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(()=>g==null?void 0:g.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,a;let r=(a=(i=e.target)==null?void 0:i.closest)==null?void 0:a.call(i,"a[href]");if(!r)return;let s=B(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 W(){let n={};try{for(let t of document.cookie.split(";")){let[e,...r]=t.trim().split("="),s=decodeURIComponent(r.join("=")||"").slice(0,300);s&&(e==="_fbp"?n.fbp=s:e==="_fbc"?n.fbc=s:e==="_ttp"&&(n.ttp=s))}}catch(t){}return n}function Z(){var t,e,r;let n=document.querySelectorAll('script[type="application/ld+json"]');for(let s of Array.from(n)){let i;try{i=JSON.parse(s.textContent||"null")}catch(c){continue}let a=[i];for(;a.length;){let c=a.shift();if(Array.isArray(c)){a.push(...c);continue}if(!c||typeof c!="object")continue;let o=c;o["@graph"]&&a.push(o["@graph"]);let l=o["@type"];if(l!=="Product"&&!(Array.isArray(l)&&l.includes("Product")))continue;let u=Array.isArray(o.offers)?o.offers[0]:o.offers,P=String(o.sku||o.productID||o.gtin13||o.mpn||o["@id"]||o.url||o.name||"").slice(0,100);if(!P)continue;let A=typeof o.brand=="string"?o.brand:(t=o.brand)==null?void 0:t.name,q=Number((e=u==null?void 0:u.price)!=null?e:u==null?void 0:u.lowPrice),p={id:P};typeof o.name=="string"&&(p.name=o.name.slice(0,200)),typeof o.category=="string"&&(p.category=o.category.slice(0,120)),A&&(p.brand=String(A).slice(0,120)),Number.isFinite(q)&&(p.price=q);let w=typeof o.image=="string"?o.image:Array.isArray(o.image)?o.image.find(V=>typeof V=="string"):(r=o.image)==null?void 0:r.url;typeof w=="string"&&/^https?:\/\//.test(w)&&(p.image=w.slice(0,500));let D=typeof o.url=="string"&&/^https?:\/\//.test(o.url)?o.url:window.location.origin+window.location.pathname;p.url=D.slice(0,500);let K=typeof(u==null?void 0:u.priceCurrency)=="string"?u.priceCurrency.toUpperCase().slice(0,3):void 0;return{item:p,currency:K}}}return null}function B(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 g=null,C=[];function L(n){return S()?g||(!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):(g=new b(n),C.splice(0).forEach(t=>t()),g)):null}var h=n=>{S()&&(g?n(g):C.push(()=>g&&n(g)))},_=(n,t)=>h(e=>e.track(n,t)),I=n=>h(t=>t.identify(n)),E=n=>h(t=>t.consent(n)),j=()=>h(n=>n.page());function $(n){return S()?new Promise(t=>h(e=>void e.suggest(n).then(t))):Promise.resolve({suggestions:[],recentlyViewed:[]})}(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,a,c){i==="track"?_(String(a),c||{}):i==="identify"?I(a||{}):i==="consent"?E(a):i==="page"&&j()}let r=(...i)=>e(...i);r.track=(i,a)=>_(i,a),r.suggest=i=>$(i),window.optiq=r;let s=t.filter(i=>i[0]==="consent").pop();L({key:n.getAttribute("data-key")||"",endpoint:n.getAttribute("data-endpoint")||void 0,api:n.getAttribute("data-api")||void 0,consent:s&&(s[1]==="granted"||s[1]==="denied")?s[1]:void 0}),t.forEach(i=>e(...i))})();})();
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@optiqstudio/track",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
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"],
5
+ "keywords": [
6
+ "optiq",
7
+ "optiq studio",
8
+ "attribution",
9
+ "tracking",
10
+ "conversions",
11
+ "analytics",
12
+ "marketing",
13
+ "whatsapp"
14
+ ],
6
15
  "homepage": "https://enterprise.optiq.studio/developers",
7
16
  "license": "MIT",
8
17
  "author": "DaveLabs <info@davelabs.co> (https://davelabs.co)",
@@ -24,8 +33,14 @@
24
33
  },
25
34
  "./package.json": "./package.json"
26
35
  },
27
- "files": ["dist", "README.md", "LICENSE"],
28
- "engines": { "node": ">=20" },
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
29
44
  "scripts": {
30
45
  "build": "node build.mjs",
31
46
  "test": "node --test test/*.test.mjs",