@aranova/tracking-react 0.14.2 → 0.16.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/index.mjs CHANGED
@@ -7,13 +7,30 @@ import { useCallback, useEffect, useState } from "react";
7
7
  // ../tracking-core/src/consent.ts
8
8
  var CONSENT_STATE_KEY = "consent_state";
9
9
  var CONSENT_TIMESTAMP_KEY = "consent_timestamp";
10
- var grantedListeners = /* @__PURE__ */ new Set();
11
- function onConsentGranted(listener) {
12
- grantedListeners.add(listener);
10
+ var CONSENT_EXPIRES_AT_KEY = "consent_expires_at";
11
+ var DEFAULT_DECLINE_TTL_DAYS = 90;
12
+ var DAY_MS = 864e5;
13
+ var DEFAULT_CHOICE = {
14
+ state: "granted",
15
+ source: "default",
16
+ updatedAt: null,
17
+ expiresAt: null
18
+ };
19
+ var changeListeners = /* @__PURE__ */ new Set();
20
+ function onConsentChange(listener) {
21
+ changeListeners.add(listener);
13
22
  return () => {
14
- grantedListeners.delete(listener);
23
+ changeListeners.delete(listener);
15
24
  };
16
25
  }
26
+ function notifyConsentChanged(choice) {
27
+ for (const listener of changeListeners) {
28
+ try {
29
+ listener(choice);
30
+ } catch {
31
+ }
32
+ }
33
+ }
17
34
  function buildConsentPayload(state) {
18
35
  return {
19
36
  ad_storage: state,
@@ -22,47 +39,74 @@ function buildConsentPayload(state) {
22
39
  analytics_storage: state
23
40
  };
24
41
  }
25
- function getConsentState() {
26
- if (typeof window === "undefined") return "pending";
42
+ function getConsentChoice() {
43
+ if (typeof window === "undefined") return DEFAULT_CHOICE;
27
44
  try {
28
- const storedState = window.localStorage.getItem(CONSENT_STATE_KEY);
29
- if (storedState === "granted" || storedState === "denied") return storedState;
45
+ const stored = window.localStorage.getItem(CONSENT_STATE_KEY);
46
+ const updatedAt = window.localStorage.getItem(CONSENT_TIMESTAMP_KEY);
47
+ if (stored === "granted")
48
+ return { state: "granted", source: "explicit", updatedAt, expiresAt: null };
49
+ if (stored === "denied") {
50
+ let expiresAt = window.localStorage.getItem(CONSENT_EXPIRES_AT_KEY);
51
+ if (!expiresAt) {
52
+ expiresAt = new Date(Date.now() + DEFAULT_DECLINE_TTL_DAYS * DAY_MS).toISOString();
53
+ try {
54
+ window.localStorage.setItem(CONSENT_EXPIRES_AT_KEY, expiresAt);
55
+ } catch {
56
+ }
57
+ }
58
+ if (!(Date.parse(expiresAt) <= Date.now()))
59
+ return { state: "denied", source: "explicit", updatedAt, expiresAt };
60
+ }
30
61
  } catch {
31
62
  }
32
- return "pending";
63
+ return DEFAULT_CHOICE;
33
64
  }
34
- function setConsentState(state) {
65
+ function getConsentState() {
66
+ return getConsentChoice().state;
67
+ }
68
+ function pushConsentToPlatforms(state) {
35
69
  if (typeof window === "undefined") return;
36
- try {
37
- window.localStorage.setItem(CONSENT_STATE_KEY, state);
38
- window.localStorage.setItem(CONSENT_TIMESTAMP_KEY, (/* @__PURE__ */ new Date()).toISOString());
39
- } catch {
40
- }
41
70
  if (typeof window.gtag === "function")
42
71
  window.gtag("consent", "update", buildConsentPayload(state));
43
72
  if (typeof window.fbq === "function")
44
73
  window.fbq("consent", state === "granted" ? "grant" : "revoke");
45
- if (state === "granted") {
46
- for (const listener of grantedListeners) {
47
- try {
48
- listener();
49
- } catch {
50
- }
74
+ }
75
+ function setConsentState(state, options) {
76
+ if (typeof window === "undefined") return;
77
+ const requestedTtl = options?.declineTtlDays;
78
+ const ttlDays = typeof requestedTtl === "number" && Number.isFinite(requestedTtl) && requestedTtl > 0 ? requestedTtl : DEFAULT_DECLINE_TTL_DAYS;
79
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
80
+ const expiresAt = state === "denied" ? new Date(Date.now() + ttlDays * DAY_MS).toISOString() : null;
81
+ try {
82
+ window.localStorage.setItem(CONSENT_STATE_KEY, state);
83
+ window.localStorage.setItem(CONSENT_TIMESTAMP_KEY, updatedAt);
84
+ if (expiresAt !== null) {
85
+ window.localStorage.setItem(CONSENT_EXPIRES_AT_KEY, expiresAt);
86
+ } else {
87
+ window.localStorage.removeItem(CONSENT_EXPIRES_AT_KEY);
51
88
  }
89
+ } catch {
52
90
  }
91
+ pushConsentToPlatforms(state);
92
+ notifyConsentChanged({ state, source: "explicit", updatedAt, expiresAt });
93
+ }
94
+ function optIn() {
95
+ setConsentState("granted");
96
+ }
97
+ function optOut(options) {
98
+ setConsentState("denied", options);
53
99
  }
54
100
  function resetConsent() {
55
101
  if (typeof window === "undefined") return;
56
102
  try {
57
103
  window.localStorage.removeItem(CONSENT_STATE_KEY);
58
104
  window.localStorage.removeItem(CONSENT_TIMESTAMP_KEY);
105
+ window.localStorage.removeItem(CONSENT_EXPIRES_AT_KEY);
59
106
  } catch {
60
107
  }
61
- }
62
- function restoreStoredConsent() {
63
- const consentState = getConsentState();
64
- if (consentState === "granted" || consentState === "denied") setConsentState(consentState);
65
- return consentState;
108
+ pushConsentToPlatforms("granted");
109
+ notifyConsentChanged(DEFAULT_CHOICE);
66
110
  }
67
111
 
68
112
  // ../tracking-core/src/tracking.ts
@@ -206,13 +250,11 @@ function fireGtagConversion(input) {
206
250
  }
207
251
  function applyDefaultConsentState() {
208
252
  const gtag = ensureGtagFunction();
209
- gtag("consent", "default", {
210
- ad_storage: "denied",
211
- ad_user_data: "denied",
212
- ad_personalization: "denied",
213
- analytics_storage: "denied",
214
- wait_for_update: 500
215
- });
253
+ gtag(
254
+ "consent",
255
+ "default",
256
+ buildConsentPayload(getConsentState() === "denied" ? "denied" : "granted")
257
+ );
216
258
  }
217
259
  function loadGtagScript(gtagId) {
218
260
  if (typeof document === "undefined") return;
@@ -238,7 +280,6 @@ function bootstrapGoogleAdsTracking(gtagId) {
238
280
  applyDefaultConsentState();
239
281
  loadGtagScript(gtagId);
240
282
  initializeGtag(gtagId);
241
- restoreStoredConsent();
242
283
  }
243
284
  function bootstrapMultipleGtags(gtagIds) {
244
285
  if (typeof window === "undefined" || typeof document === "undefined") return;
@@ -253,7 +294,6 @@ function bootstrapMultipleGtags(gtagIds) {
253
294
  for (const id of ids) {
254
295
  gtag("config", id);
255
296
  }
256
- restoreStoredConsent();
257
297
  }
258
298
 
259
299
  // ../tracking-core/src/fbq.ts
@@ -313,7 +353,7 @@ function ensureFbqFunction() {
313
353
  return fbq;
314
354
  }
315
355
  function applyDefaultMetaConsentState() {
316
- ensureFbqFunction()("consent", "revoke");
356
+ ensureFbqFunction()("consent", getConsentState() === "denied" ? "revoke" : "grant");
317
357
  }
318
358
  function loadFbeventsScript() {
319
359
  if (typeof document === "undefined") return;
@@ -333,19 +373,12 @@ function initializeMetaPixel(pixelId) {
333
373
  fbq("init", pixelId);
334
374
  fbq("track", "PageView");
335
375
  }
336
- function restoreMetaConsentState() {
337
- if (typeof window === "undefined") return;
338
- const state = getConsentState();
339
- if (state === "granted") window.fbq?.("consent", "grant");
340
- else if (state === "denied") window.fbq?.("consent", "revoke");
341
- }
342
376
  function bootstrapMetaPixel(pixelId) {
343
377
  if (typeof window === "undefined" || typeof document === "undefined") return;
344
378
  if (!isValidMetaPixelId(pixelId)) return;
345
379
  applyDefaultMetaConsentState();
346
380
  loadFbeventsScript();
347
381
  initializeMetaPixel(pixelId);
348
- restoreMetaConsentState();
349
382
  captureFbc();
350
383
  }
351
384
  function bootstrapMultiplePixels(pixelIds) {
@@ -361,10 +394,75 @@ function bootstrapMultiplePixels(pixelIds) {
361
394
  fbq("init", id);
362
395
  }
363
396
  fbq("track", "PageView");
364
- restoreMetaConsentState();
365
397
  captureFbc();
366
398
  }
367
399
 
400
+ // ../tracking-core/src/landing.ts
401
+ var LANDING_STORAGE_KEY = "_aranova_track_landing";
402
+ var memoryRecord = null;
403
+ function sanitizeParams(value) {
404
+ if (typeof value !== "object" || value === null) return {};
405
+ const source = value;
406
+ return TRACKING_PARAM_KEYS.reduce((params, key) => {
407
+ const entry = source[key];
408
+ if (typeof entry === "string" && entry.length > 0) params[key] = entry;
409
+ return params;
410
+ }, {});
411
+ }
412
+ function readStoredRecord() {
413
+ try {
414
+ const raw = window.localStorage.getItem(LANDING_STORAGE_KEY);
415
+ if (!raw) return null;
416
+ const parsed = JSON.parse(raw);
417
+ if (typeof parsed.session_id !== "string" || parsed.session_id.length === 0) return null;
418
+ return { session_id: parsed.session_id, params: sanitizeParams(parsed.params) };
419
+ } catch {
420
+ return null;
421
+ }
422
+ }
423
+ function writeRecord(record) {
424
+ memoryRecord = record;
425
+ try {
426
+ window.localStorage.setItem(LANDING_STORAGE_KEY, JSON.stringify(record));
427
+ } catch {
428
+ }
429
+ }
430
+ function captureFromUrl(url) {
431
+ try {
432
+ const resolved = new URL(url ?? window.location.href, window.location.origin);
433
+ return getTrackingQueryValues(resolved.searchParams);
434
+ } catch {
435
+ return {};
436
+ }
437
+ }
438
+ function getOrCaptureLandingParams(sessionId, url) {
439
+ if (typeof window === "undefined") return {};
440
+ const stored = readStoredRecord();
441
+ if (stored && stored.session_id === sessionId) {
442
+ memoryRecord = stored;
443
+ return stored.params;
444
+ }
445
+ if (memoryRecord && memoryRecord.session_id === sessionId) {
446
+ return memoryRecord.params;
447
+ }
448
+ const record = { session_id: sessionId, params: captureFromUrl(url) };
449
+ writeRecord(record);
450
+ return record.params;
451
+ }
452
+ function buildLandingPayloadFields(sessionId, override) {
453
+ const params = override ?? (typeof window === "undefined" ? null : getOrCaptureLandingParams(sessionId));
454
+ if (params === null) return {};
455
+ return {
456
+ landing_gclid: params.gclid ?? null,
457
+ landing_fbclid: params.fbclid ?? null,
458
+ landing_utm_source: params.utm_source ?? null,
459
+ landing_utm_medium: params.utm_medium ?? null,
460
+ landing_utm_campaign: params.utm_campaign ?? null,
461
+ landing_utm_term: params.utm_term ?? null,
462
+ landing_utm_content: params.utm_content ?? null
463
+ };
464
+ }
465
+
368
466
  // ../tracking-core/src/payloads.ts
369
467
  function createTrackingClientContext(surface, input = {}) {
370
468
  return {
@@ -391,6 +489,9 @@ function createTrackingSessionUpsertPayload(trackingParams, input, context) {
391
489
  utm_campaign: trackingParams.utm_campaign,
392
490
  utm_term: trackingParams.utm_term,
393
491
  utm_content: trackingParams.utm_content,
492
+ // Omitted entirely when the landing isn't observable (SSR, no override) —
493
+ // present-as-null would wrongly tell the backend "landed with no params".
494
+ ...buildLandingPayloadFields(input.sessionId, input.landingParams),
394
495
  first_page: input.firstPage ?? null,
395
496
  consent_state: input.consentState ?? null,
396
497
  context
@@ -412,8 +513,6 @@ function createTrackingEventCreatePayload(trackingParams, input, context) {
412
513
 
413
514
  // ../tracking-core/src/resources/conversion-firing.ts
414
515
  var DEDUP_PREFIX = "_aranova_conv_";
415
- var MAX_PENDING = 100;
416
- var pendingQueue = [];
417
516
  function dedupKey(input) {
418
517
  return `${DEDUP_PREFIX}${input.transactionId ?? ""}:${input.sendTo}`;
419
518
  }
@@ -437,23 +536,9 @@ function fireOnce(input) {
437
536
  if (fireGtagConversion(input)) markFired(input);
438
537
  }
439
538
  function fireConversionWithConsent(input) {
440
- const state = getConsentState();
441
- if (state === "denied") return;
442
- if (state === "pending") {
443
- if (pendingQueue.length >= MAX_PENDING) pendingQueue.shift();
444
- pendingQueue.push(input);
445
- return;
446
- }
539
+ if (getConsentState() === "denied") return;
447
540
  fireOnce(input);
448
541
  }
449
- function flushPendingConversions() {
450
- if (getConsentState() !== "granted") return;
451
- while (pendingQueue.length > 0) {
452
- const input = pendingQueue.shift();
453
- if (input) fireOnce(input);
454
- }
455
- }
456
- if (typeof window !== "undefined") onConsentGranted(flushPendingConversions);
457
542
 
458
543
  // ../tracking-core/src/resources/conversion-config.ts
459
544
  function isStringMap(value) {
@@ -999,7 +1084,13 @@ function readTrackingParams() {
999
1084
  }
1000
1085
  function consentSnapshot() {
1001
1086
  try {
1002
- return { state: getConsentState() };
1087
+ const choice = getConsentChoice();
1088
+ return {
1089
+ state: choice.state,
1090
+ source: choice.source,
1091
+ updated_at: choice.updatedAt,
1092
+ expires_at: choice.expiresAt
1093
+ };
1003
1094
  } catch {
1004
1095
  return null;
1005
1096
  }
@@ -1072,6 +1163,7 @@ function createTrackingClient(config) {
1072
1163
  initialParams = captureTrackingParamsFromLocation();
1073
1164
  } catch {
1074
1165
  }
1166
+ getOrCaptureLandingParams(sessionId);
1075
1167
  try {
1076
1168
  captureFbc();
1077
1169
  } catch {
@@ -1121,6 +1213,11 @@ function createTrackingClient(config) {
1121
1213
  utm_campaign: params.utm_campaign,
1122
1214
  utm_term: params.utm_term,
1123
1215
  utm_content: params.utm_content,
1216
+ // Landing params for the CURRENT session id — captured on the spot when
1217
+ // the session just rotated (the current URL is the rotated session's
1218
+ // landing), reused from the stored record otherwise. Keys are omitted
1219
+ // entirely when the landing isn't observable (SSR).
1220
+ ...buildLandingPayloadFields(sessionId),
1124
1221
  first_page: firstPage,
1125
1222
  consent_state: consentSnapshot(),
1126
1223
  context
@@ -1188,6 +1285,7 @@ function createTrackingClient(config) {
1188
1285
  return {
1189
1286
  trackEvent,
1190
1287
  flush,
1288
+ flushBeacon: flushOnUnload,
1191
1289
  getSessionId: () => sessionId,
1192
1290
  getVisitorId: () => visitorId,
1193
1291
  destroy: () => {
@@ -1212,9 +1310,17 @@ var ctaClickMetadataSchema = z2.object({
1212
1310
  path: z2.string()
1213
1311
  }).strict(),
1214
1312
  section: z2.string().nullable().optional(),
1215
- destination_url: z2.string().nullable().optional()
1313
+ destination_url: z2.string().nullable().optional(),
1314
+ // Set by auto-capture (and available to manual callers): the link target
1315
+ // and a short element descriptor (tag#id) for tying clicks to specific UI.
1316
+ href: z2.string().nullable().optional(),
1317
+ element: z2.string().nullable().optional()
1318
+ }).strict();
1319
+ var ctaClickConfigSchema = z2.object({
1320
+ autoCapture: z2.object({
1321
+ selector: z2.string().optional()
1322
+ }).strict().optional()
1216
1323
  }).strict();
1217
- var ctaClickConfigSchema = z2.object({}).strict();
1218
1324
 
1219
1325
  // ../tracking-core/src/events/sdk-heartbeat.ts
1220
1326
  import { z as z3 } from "zod";
@@ -1290,31 +1396,44 @@ var multiPageSessionConfigSchema = z6.object({
1290
1396
  pageThreshold: z6.number().int().min(2)
1291
1397
  }).strict();
1292
1398
 
1293
- // ../tracking-core/src/events/phone-click.ts
1399
+ // ../tracking-core/src/events/page-exit.ts
1294
1400
  import { z as z7 } from "zod";
1295
- var phoneClickMetadataSchema = z7.object({
1296
- phone_number: z7.string(),
1401
+ var pageExitMetadataSchema = z7.object({
1402
+ dwell_ms: z7.number().int().min(0),
1403
+ // null = left without any scroll signal; floor is 0 so a valid 0% is never
1404
+ // rejected (a single bad field 422s the whole keepalive beacon batch).
1405
+ max_scroll_percent: z7.number().int().min(0).max(100).nullable(),
1297
1406
  page: z7.object({
1298
1407
  path: z7.string()
1299
- }).strict(),
1300
- section: z7.string().nullable().optional()
1408
+ }).strict()
1301
1409
  }).strict();
1302
- var phoneClickConfigSchema = z7.object({}).strict();
1410
+ var pageExitConfigSchema = z7.object({}).strict();
1303
1411
 
1304
- // ../tracking-core/src/events/scroll-depth.ts
1412
+ // ../tracking-core/src/events/phone-click.ts
1305
1413
  import { z as z8 } from "zod";
1306
- var scrollDepthMetadataSchema = z8.object({
1307
- depth_percent: z8.number().int().min(1).max(100),
1414
+ var phoneClickMetadataSchema = z8.object({
1415
+ phone_number: z8.string(),
1308
1416
  page: z8.object({
1309
1417
  path: z8.string()
1418
+ }).strict(),
1419
+ section: z8.string().nullable().optional()
1420
+ }).strict();
1421
+ var phoneClickConfigSchema = z8.object({}).strict();
1422
+
1423
+ // ../tracking-core/src/events/scroll-depth.ts
1424
+ import { z as z9 } from "zod";
1425
+ var scrollDepthMetadataSchema = z9.object({
1426
+ depth_percent: z9.number().int().min(1).max(100),
1427
+ page: z9.object({
1428
+ path: z9.string()
1310
1429
  }).strict()
1311
1430
  }).strict();
1312
- var scrollDepthConfigSchema = z8.object({
1313
- thresholds: z8.array(z8.number().int().min(1).max(100)).min(1)
1431
+ var scrollDepthConfigSchema = z9.object({
1432
+ thresholds: z9.array(z9.number().int().min(1).max(100)).min(1)
1314
1433
  }).strict();
1315
1434
 
1316
1435
  // ../tracking-core/src/events/specific-page-visit.ts
1317
- import { z as z9 } from "zod";
1436
+ import { z as z10 } from "zod";
1318
1437
  var SPECIFIC_PAGE_NAMES = [
1319
1438
  "contact_page",
1320
1439
  "about_page",
@@ -1325,18 +1444,18 @@ var SPECIFIC_PAGE_NAMES = [
1325
1444
  "faq_page",
1326
1445
  "testimonials_page"
1327
1446
  ];
1328
- var specificPageNameSchema = z9.enum(SPECIFIC_PAGE_NAMES);
1329
- var specificPageVisitMetadataSchema = z9.object({
1447
+ var specificPageNameSchema = z10.enum(SPECIFIC_PAGE_NAMES);
1448
+ var specificPageVisitMetadataSchema = z10.object({
1330
1449
  page_name: specificPageNameSchema,
1331
- page: z9.object({
1332
- path: z9.string()
1450
+ page: z10.object({
1451
+ path: z10.string()
1333
1452
  }).strict()
1334
1453
  }).strict();
1335
- var specificPageVisitConfigSchema = z9.object({
1336
- pages: z9.array(
1337
- z9.object({
1454
+ var specificPageVisitConfigSchema = z10.object({
1455
+ pages: z10.array(
1456
+ z10.object({
1338
1457
  name: specificPageNameSchema,
1339
- pathPattern: z9.custom((value) => value instanceof RegExp, {
1458
+ pathPattern: z10.custom((value) => value instanceof RegExp, {
1340
1459
  message: "pathPattern must be a RegExp"
1341
1460
  })
1342
1461
  }).strict()
@@ -1344,15 +1463,15 @@ var specificPageVisitConfigSchema = z9.object({
1344
1463
  }).strict();
1345
1464
 
1346
1465
  // ../tracking-core/src/events/time-on-site.ts
1347
- import { z as z10 } from "zod";
1348
- var timeOnSiteMetadataSchema = z10.object({
1349
- duration_ms: z10.number().int().nonnegative(),
1350
- page: z10.object({
1351
- path: z10.string()
1466
+ import { z as z11 } from "zod";
1467
+ var timeOnSiteMetadataSchema = z11.object({
1468
+ duration_ms: z11.number().int().nonnegative(),
1469
+ page: z11.object({
1470
+ path: z11.string()
1352
1471
  }).strict()
1353
1472
  }).strict();
1354
- var timeOnSiteConfigSchema = z10.object({
1355
- thresholdSeconds: z10.number().int().positive()
1473
+ var timeOnSiteConfigSchema = z11.object({
1474
+ thresholdSeconds: z11.number().int().positive()
1356
1475
  }).strict();
1357
1476
 
1358
1477
  // ../tracking-core/src/events/registry.ts
@@ -1394,6 +1513,11 @@ var EVENT_REGISTRY = {
1394
1513
  metadataSchema: sdkHeartbeatMetadataSchema,
1395
1514
  configSchema: sdkHeartbeatConfigSchema
1396
1515
  },
1516
+ page_exit: {
1517
+ kind: "automatic",
1518
+ metadataSchema: pageExitMetadataSchema,
1519
+ configSchema: pageExitConfigSchema
1520
+ },
1397
1521
  // --- manual triggers ---
1398
1522
  form_submit: {
1399
1523
  kind: "manual",
@@ -1772,6 +1896,163 @@ function attachFormStart(client, config) {
1772
1896
  };
1773
1897
  }
1774
1898
 
1899
+ // ../tracking-core/src/triggers/page-exit.ts
1900
+ function attachPageExit(client) {
1901
+ if (typeof window === "undefined" || typeof document === "undefined") {
1902
+ return () => {
1903
+ };
1904
+ }
1905
+ let currentPath2 = window.location.pathname;
1906
+ let activeSince = document.visibilityState === "visible" ? Date.now() : null;
1907
+ let accumulatedMs = 0;
1908
+ let maxScrollPercent = null;
1909
+ let rafId = null;
1910
+ function getScrollPercent() {
1911
+ const doc = document.documentElement;
1912
+ const scrollTop = window.scrollY || doc.scrollTop;
1913
+ const scrollHeight = doc.scrollHeight;
1914
+ const clientHeight = doc.clientHeight;
1915
+ if (scrollHeight <= clientHeight) return 100;
1916
+ return Math.round((scrollTop + clientHeight) / scrollHeight * 100);
1917
+ }
1918
+ function onScroll() {
1919
+ if (rafId !== null) return;
1920
+ rafId = requestAnimationFrame(() => {
1921
+ rafId = null;
1922
+ const percent = getScrollPercent();
1923
+ if (percent >= 1 && (maxScrollPercent === null || percent > maxScrollPercent)) {
1924
+ maxScrollPercent = Math.min(percent, 100);
1925
+ }
1926
+ });
1927
+ }
1928
+ function settledDwellMs() {
1929
+ let total = accumulatedMs;
1930
+ if (activeSince !== null) {
1931
+ total += Date.now() - activeSince;
1932
+ }
1933
+ return Math.max(0, Math.round(total));
1934
+ }
1935
+ function emitSegment(path, flush) {
1936
+ const dwell = settledDwellMs();
1937
+ if (dwell === 0) return;
1938
+ client.trackEvent({
1939
+ eventType: "page_exit",
1940
+ metadata: {
1941
+ dwell_ms: dwell,
1942
+ max_scroll_percent: maxScrollPercent,
1943
+ page: { path }
1944
+ },
1945
+ pageUrl: window.location.href,
1946
+ occurredAt: null
1947
+ });
1948
+ if (flush) {
1949
+ client.flushBeacon();
1950
+ }
1951
+ accumulatedMs = 0;
1952
+ activeSince = document.visibilityState === "visible" ? Date.now() : null;
1953
+ }
1954
+ function onNavigate() {
1955
+ const newPath = window.location.pathname;
1956
+ if (newPath === currentPath2) return;
1957
+ emitSegment(currentPath2, false);
1958
+ currentPath2 = newPath;
1959
+ maxScrollPercent = null;
1960
+ accumulatedMs = 0;
1961
+ activeSince = document.visibilityState === "visible" ? Date.now() : null;
1962
+ }
1963
+ function onVisibilityChange() {
1964
+ if (document.visibilityState === "hidden") {
1965
+ emitSegment(currentPath2, true);
1966
+ } else {
1967
+ activeSince = Date.now();
1968
+ }
1969
+ }
1970
+ function onPageHide() {
1971
+ emitSegment(currentPath2, true);
1972
+ }
1973
+ const originalPushState = history.pushState.bind(history);
1974
+ const originalReplaceState = history.replaceState.bind(history);
1975
+ function patchedPushState(...args) {
1976
+ originalPushState(...args);
1977
+ setTimeout(onNavigate, 0);
1978
+ }
1979
+ function patchedReplaceState(...args) {
1980
+ originalReplaceState(...args);
1981
+ setTimeout(onNavigate, 0);
1982
+ }
1983
+ history.pushState = patchedPushState;
1984
+ history.replaceState = patchedReplaceState;
1985
+ window.addEventListener("popstate", onNavigate);
1986
+ window.addEventListener("scroll", onScroll, { passive: true });
1987
+ document.addEventListener("visibilitychange", onVisibilityChange);
1988
+ window.addEventListener("pagehide", onPageHide);
1989
+ return () => {
1990
+ if (rafId !== null) cancelAnimationFrame(rafId);
1991
+ history.pushState = originalPushState;
1992
+ history.replaceState = originalReplaceState;
1993
+ window.removeEventListener("popstate", onNavigate);
1994
+ window.removeEventListener("scroll", onScroll);
1995
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1996
+ window.removeEventListener("pagehide", onPageHide);
1997
+ };
1998
+ }
1999
+
2000
+ // ../tracking-core/src/triggers/cta-click-capture.ts
2001
+ var DEFAULT_CTA_SELECTOR = "[data-aranova-cta]";
2002
+ var CTA_NAME_MAX_LENGTH = 120;
2003
+ function describeElement(el) {
2004
+ const tag = el.tagName.toLowerCase();
2005
+ return el.id ? `${tag}#${el.id}` : tag;
2006
+ }
2007
+ function resolveCtaName(el) {
2008
+ const explicit = el.getAttribute("data-aranova-cta");
2009
+ if (explicit && explicit.trim().length > 0) return explicit.trim();
2010
+ const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
2011
+ if (text.length > 0) return text.slice(0, CTA_NAME_MAX_LENGTH);
2012
+ return describeElement(el);
2013
+ }
2014
+ function attachCtaClickCapture(client, config) {
2015
+ if (typeof window === "undefined" || typeof document === "undefined") {
2016
+ return () => {
2017
+ };
2018
+ }
2019
+ const autoCapture = config.autoCapture;
2020
+ if (!autoCapture) {
2021
+ return () => {
2022
+ };
2023
+ }
2024
+ const selector = autoCapture.selector ?? DEFAULT_CTA_SELECTOR;
2025
+ function onClick(event) {
2026
+ const target = event.target;
2027
+ if (!(target instanceof Element)) return;
2028
+ let matched = null;
2029
+ try {
2030
+ matched = target.closest(selector);
2031
+ } catch {
2032
+ return;
2033
+ }
2034
+ if (matched === null) return;
2035
+ const href = matched instanceof HTMLAnchorElement ? matched.href || null : matched.getAttribute("href");
2036
+ client.trackEvent({
2037
+ eventType: "cta_click",
2038
+ metadata: {
2039
+ cta_name: resolveCtaName(matched),
2040
+ page: { path: window.location.pathname },
2041
+ section: matched.getAttribute("data-aranova-section"),
2042
+ destination_url: href,
2043
+ href,
2044
+ element: describeElement(matched)
2045
+ },
2046
+ pageUrl: window.location.href,
2047
+ occurredAt: null
2048
+ });
2049
+ }
2050
+ document.addEventListener("click", onClick, true);
2051
+ return () => {
2052
+ document.removeEventListener("click", onClick, true);
2053
+ };
2054
+ }
2055
+
1775
2056
  // ../tracking-core/src/resources/sales/errors.ts
1776
2057
  var AranovaApiError = class extends Error {
1777
2058
  constructor(message, options) {
@@ -1978,42 +2259,42 @@ function createSalesClient(config) {
1978
2259
  }
1979
2260
 
1980
2261
  // ../tracking-core/src/resources/sales/schema.ts
1981
- import { z as z11 } from "zod";
2262
+ import { z as z12 } from "zod";
1982
2263
  var SUPPORTED_CURRENCIES = ["USD", "CAD"];
1983
2264
  var TRACKING_ENVIRONMENTS = ["production", "development"];
1984
- var currencySchema = z11.enum(SUPPORTED_CURRENCIES);
1985
- var centsSchema = z11.number().int().nonnegative();
1986
- var quantitySchema = z11.string().regex(/^\d+(\.\d{1,3})?$/);
1987
- var metadataSchema = z11.record(z11.unknown());
1988
- var saleItemSchema = z11.object({
1989
- external_item_id: z11.string().nullable().optional(),
1990
- name: z11.string().nullable().optional(),
1991
- category: z11.string().nullable().optional(),
2265
+ var currencySchema = z12.enum(SUPPORTED_CURRENCIES);
2266
+ var centsSchema = z12.number().int().nonnegative();
2267
+ var quantitySchema = z12.string().regex(/^\d+(\.\d{1,3})?$/);
2268
+ var metadataSchema = z12.record(z12.unknown());
2269
+ var saleItemSchema = z12.object({
2270
+ external_item_id: z12.string().nullable().optional(),
2271
+ name: z12.string().nullable().optional(),
2272
+ category: z12.string().nullable().optional(),
1992
2273
  quantity: quantitySchema,
1993
2274
  unit_price_cents: centsSchema,
1994
2275
  // Non-negativity validated on the wire — same contract as the other cents
1995
2276
  // fields — and backstopped by the DB CHECK.
1996
2277
  unit_cost_cents: centsSchema.nullable().optional()
1997
2278
  }).strict();
1998
- var saleServiceSchema = z11.object({
1999
- service: z11.string(),
2279
+ var saleServiceSchema = z12.object({
2280
+ service: z12.string(),
2000
2281
  amount_cents: centsSchema
2001
2282
  }).strict();
2002
- var customerNameSchema = z11.string().max(200);
2003
- var customerPhoneSchema = z11.string().max(64);
2004
- var customerEmailSchema = z11.string().max(320).email();
2283
+ var customerNameSchema = z12.string().max(200);
2284
+ var customerPhoneSchema = z12.string().max(64);
2285
+ var customerEmailSchema = z12.string().max(320).email();
2005
2286
  function refineServiceXor(val, ctx, { requireAmount }) {
2006
2287
  if (val.services != null) {
2007
2288
  if (val.service != null) {
2008
2289
  ctx.addIssue({
2009
- code: z11.ZodIssueCode.custom,
2290
+ code: z12.ZodIssueCode.custom,
2010
2291
  message: "pass either `service` or `services`, not both",
2011
2292
  path: ["services"]
2012
2293
  });
2013
2294
  }
2014
2295
  if (val.services.length === 0) {
2015
2296
  ctx.addIssue({
2016
- code: z11.ZodIssueCode.custom,
2297
+ code: z12.ZodIssueCode.custom,
2017
2298
  message: "`services` must not be empty",
2018
2299
  path: ["services"]
2019
2300
  });
@@ -2021,7 +2302,7 @@ function refineServiceXor(val, ctx, { requireAmount }) {
2021
2302
  const keys = val.services.map((s) => s.service);
2022
2303
  if (new Set(keys).size !== keys.length) {
2023
2304
  ctx.addIssue({
2024
- code: z11.ZodIssueCode.custom,
2305
+ code: z12.ZodIssueCode.custom,
2025
2306
  message: "`services` must not list the same service more than once",
2026
2307
  path: ["services"]
2027
2308
  });
@@ -2030,7 +2311,7 @@ function refineServiceXor(val, ctx, { requireAmount }) {
2030
2311
  const sum = val.services.reduce((acc, s) => acc + s.amount_cents, 0);
2031
2312
  if (val.amount_total_cents !== sum) {
2032
2313
  ctx.addIssue({
2033
- code: z11.ZodIssueCode.custom,
2314
+ code: z12.ZodIssueCode.custom,
2034
2315
  message: "amount_total_cents must equal the sum of the services amounts (omit it to derive it automatically)",
2035
2316
  path: ["amount_total_cents"]
2036
2317
  });
@@ -2038,37 +2319,37 @@ function refineServiceXor(val, ctx, { requireAmount }) {
2038
2319
  }
2039
2320
  } else if (requireAmount && val.amount_total_cents == null) {
2040
2321
  ctx.addIssue({
2041
- code: z11.ZodIssueCode.custom,
2322
+ code: z12.ZodIssueCode.custom,
2042
2323
  message: "amount_total_cents is required unless `services` is provided",
2043
2324
  path: ["amount_total_cents"]
2044
2325
  });
2045
2326
  }
2046
2327
  }
2047
- var saleCreateSchema = z11.object({
2048
- external_id: z11.string().nullable().optional(),
2049
- description: z11.string().nullable().optional(),
2050
- service: z11.string().nullable().optional(),
2051
- services: z11.array(saleServiceSchema).nullable().optional(),
2328
+ var saleCreateSchema = z12.object({
2329
+ external_id: z12.string().nullable().optional(),
2330
+ description: z12.string().nullable().optional(),
2331
+ service: z12.string().nullable().optional(),
2332
+ services: z12.array(saleServiceSchema).nullable().optional(),
2052
2333
  currency: currencySchema,
2053
2334
  // Optional only because the plural `services` form derives it from the sum
2054
2335
  // (see refineServiceXor); the singular/serviceless path still requires it.
2055
2336
  amount_total_cents: centsSchema.nullable().optional(),
2056
- occurred_at: z11.string().datetime(),
2057
- environment: z11.enum(TRACKING_ENVIRONMENTS).default("production"),
2058
- items: z11.array(saleItemSchema).default([]),
2337
+ occurred_at: z12.string().datetime(),
2338
+ environment: z12.enum(TRACKING_ENVIRONMENTS).default("production"),
2339
+ items: z12.array(saleItemSchema).default([]),
2059
2340
  metadata: metadataSchema.nullable().optional(),
2060
2341
  customer_name: customerNameSchema.nullable().optional(),
2061
2342
  customer_phone: customerPhoneSchema.nullable().optional(),
2062
2343
  customer_email: customerEmailSchema.nullable().optional()
2063
2344
  }).strict().superRefine((val, ctx) => refineServiceXor(val, ctx, { requireAmount: true }));
2064
- var saleUpdateSchema = z11.object({
2065
- description: z11.string().nullable().optional(),
2066
- service: z11.string().nullable().optional(),
2067
- services: z11.array(saleServiceSchema).nullable().optional(),
2345
+ var saleUpdateSchema = z12.object({
2346
+ description: z12.string().nullable().optional(),
2347
+ service: z12.string().nullable().optional(),
2348
+ services: z12.array(saleServiceSchema).nullable().optional(),
2068
2349
  currency: currencySchema.optional(),
2069
2350
  amount_total_cents: centsSchema.optional(),
2070
- occurred_at: z11.string().datetime().optional(),
2071
- items: z11.array(saleItemSchema).optional(),
2351
+ occurred_at: z12.string().datetime().optional(),
2352
+ items: z12.array(saleItemSchema).optional(),
2072
2353
  metadata: metadataSchema.nullable().optional(),
2073
2354
  customer_name: customerNameSchema.nullable().optional(),
2074
2355
  customer_phone: customerPhoneSchema.nullable().optional(),
@@ -2154,36 +2435,68 @@ function useTrackingParams() {
2154
2435
  }, []);
2155
2436
  return trackingParams;
2156
2437
  }
2157
- function useConsentState() {
2158
- return useConsent().state;
2159
- }
2160
- function useConsent() {
2161
- const [state, setState] = useState("pending");
2438
+ var DEFAULT_CHOICE2 = {
2439
+ state: "granted",
2440
+ source: "default",
2441
+ updatedAt: null,
2442
+ expiresAt: null
2443
+ };
2444
+ function useCookiePreferences(options) {
2445
+ const [choice, setChoice] = useState(DEFAULT_CHOICE2);
2446
+ const ttlDays = options?.declineTtlDays;
2162
2447
  useEffect(() => {
2163
- setState(getConsentState());
2448
+ const sync = () => setChoice(getConsentChoice());
2449
+ sync();
2164
2450
  const handleStorage = (event) => {
2165
- if (event.key === CONSENT_STATE_KEY) setState(getConsentState());
2451
+ if (event.key === null || event.key === CONSENT_STATE_KEY || event.key === CONSENT_EXPIRES_AT_KEY || event.key === CONSENT_TIMESTAMP_KEY)
2452
+ sync();
2166
2453
  };
2167
2454
  window.addEventListener("storage", handleStorage);
2168
- return () => window.removeEventListener("storage", handleStorage);
2169
- }, []);
2170
- const accept = useCallback(() => {
2171
- setConsentState("granted");
2172
- setState("granted");
2455
+ const unsubscribe = onConsentChange(sync);
2456
+ return () => {
2457
+ window.removeEventListener("storage", handleStorage);
2458
+ unsubscribe();
2459
+ };
2173
2460
  }, []);
2174
- const decline = useCallback(() => {
2175
- setConsentState("denied");
2176
- setState("denied");
2461
+ const optOutAction = useCallback(() => {
2462
+ optOut(ttlDays != null ? { declineTtlDays: ttlDays } : void 0);
2463
+ }, [ttlDays]);
2464
+ const optInAction = useCallback(() => {
2465
+ optIn();
2177
2466
  }, []);
2178
2467
  const reset = useCallback(() => {
2179
2468
  resetConsent();
2180
- setState("pending");
2181
2469
  }, []);
2470
+ return {
2471
+ state: choice.state,
2472
+ source: choice.source,
2473
+ isDefault: choice.source === "default",
2474
+ isGranted: choice.state === "granted",
2475
+ isDenied: choice.state === "denied",
2476
+ updatedAt: choice.updatedAt,
2477
+ expiresAt: choice.expiresAt,
2478
+ optOut: optOutAction,
2479
+ optIn: optInAction,
2480
+ reset
2481
+ };
2482
+ }
2483
+ function useConsentState() {
2484
+ return useConsent().state;
2485
+ }
2486
+ function useConsent() {
2487
+ const {
2488
+ state,
2489
+ isGranted,
2490
+ isDenied,
2491
+ optIn: accept,
2492
+ optOut: decline,
2493
+ reset
2494
+ } = useCookiePreferences();
2182
2495
  return {
2183
2496
  state,
2184
- isPending: state === "pending",
2185
- isGranted: state === "granted",
2186
- isDenied: state === "denied",
2497
+ isPending: false,
2498
+ isGranted,
2499
+ isDenied,
2187
2500
  accept,
2188
2501
  decline,
2189
2502
  reset
@@ -2424,7 +2737,7 @@ function GoogleAdsTracking(props) {
2424
2737
  import { createContext as createContext2, useContext as useContext2, useEffect as useEffect5, useMemo as useMemo4 } from "react";
2425
2738
 
2426
2739
  // package.json
2427
- var version = "0.14.2";
2740
+ var version = "0.16.0";
2428
2741
 
2429
2742
  // ../tracking-core/src/phone-react.tsx
2430
2743
  import {
@@ -2612,6 +2925,7 @@ function createTracking(options) {
2612
2925
  const detectorClient = conversionStore ? withConversionAutoFire(rawClient, createConversionAutoFire(conversionStore)) : rawClient;
2613
2926
  detachers.push(attachAutoPageView(detectorClient));
2614
2927
  detachers.push(attachBfcacheRestore(detectorClient));
2928
+ detachers.push(attachPageExit(detectorClient));
2615
2929
  const timeOnSite = triggers.automatic.time_on_site;
2616
2930
  if (timeOnSite) {
2617
2931
  detachers.push(attachTimeOnSite(detectorClient, timeOnSite));
@@ -2632,6 +2946,10 @@ function createTracking(options) {
2632
2946
  if (formStart) {
2633
2947
  detachers.push(attachFormStart(detectorClient, formStart));
2634
2948
  }
2949
+ const ctaClick = triggers.manual?.cta_click;
2950
+ if (ctaClick) {
2951
+ detachers.push(attachCtaClickCapture(detectorClient, ctaClick));
2952
+ }
2635
2953
  return () => {
2636
2954
  for (let i = detachers.length - 1; i >= 0; i--) {
2637
2955
  detachers[i]();
@@ -2655,6 +2973,7 @@ export {
2655
2973
  AdPlatformTracking,
2656
2974
  AranovaApiError,
2657
2975
  ConsentBanner,
2976
+ DEFAULT_DECLINE_TTL_DAYS,
2658
2977
  DEFAULT_PHONE_COUNTRY,
2659
2978
  GoogleAdsTracking,
2660
2979
  NAMED_RANGES,
@@ -2674,7 +2993,11 @@ export {
2674
2993
  formatPhone,
2675
2994
  formatPhoneAsTyped,
2676
2995
  fromMinor,
2996
+ getConsentChoice,
2677
2997
  getConsentState,
2998
+ onConsentChange,
2999
+ optIn,
3000
+ optOut,
2678
3001
  parsePhone,
2679
3002
  phoneField,
2680
3003
  resetConsent,
@@ -2689,6 +3012,7 @@ export {
2689
3012
  toMinor,
2690
3013
  useConsent,
2691
3014
  useConsentState,
3015
+ useCookiePreferences,
2692
3016
  useGclid,
2693
3017
  usePhoneConfig,
2694
3018
  usePhoneField,