@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.js CHANGED
@@ -23,6 +23,7 @@ __export(src_exports, {
23
23
  AdPlatformTracking: () => AdPlatformTracking,
24
24
  AranovaApiError: () => AranovaApiError,
25
25
  ConsentBanner: () => ConsentBanner,
26
+ DEFAULT_DECLINE_TTL_DAYS: () => DEFAULT_DECLINE_TTL_DAYS,
26
27
  DEFAULT_PHONE_COUNTRY: () => DEFAULT_PHONE_COUNTRY,
27
28
  GoogleAdsTracking: () => GoogleAdsTracking,
28
29
  NAMED_RANGES: () => NAMED_RANGES,
@@ -42,7 +43,11 @@ __export(src_exports, {
42
43
  formatPhone: () => formatPhone,
43
44
  formatPhoneAsTyped: () => formatPhoneAsTyped,
44
45
  fromMinor: () => fromMinor,
46
+ getConsentChoice: () => getConsentChoice,
45
47
  getConsentState: () => getConsentState,
48
+ onConsentChange: () => onConsentChange,
49
+ optIn: () => optIn,
50
+ optOut: () => optOut,
46
51
  parsePhone: () => parsePhone,
47
52
  phoneField: () => phoneField,
48
53
  resetConsent: () => resetConsent,
@@ -57,6 +62,7 @@ __export(src_exports, {
57
62
  toMinor: () => toMinor,
58
63
  useConsent: () => useConsent,
59
64
  useConsentState: () => useConsentState,
65
+ useCookiePreferences: () => useCookiePreferences,
60
66
  useGclid: () => useGclid,
61
67
  usePhoneConfig: () => usePhoneConfig,
62
68
  usePhoneField: () => usePhoneField,
@@ -73,13 +79,30 @@ var import_react = require("react");
73
79
  // ../tracking-core/src/consent.ts
74
80
  var CONSENT_STATE_KEY = "consent_state";
75
81
  var CONSENT_TIMESTAMP_KEY = "consent_timestamp";
76
- var grantedListeners = /* @__PURE__ */ new Set();
77
- function onConsentGranted(listener) {
78
- grantedListeners.add(listener);
82
+ var CONSENT_EXPIRES_AT_KEY = "consent_expires_at";
83
+ var DEFAULT_DECLINE_TTL_DAYS = 90;
84
+ var DAY_MS = 864e5;
85
+ var DEFAULT_CHOICE = {
86
+ state: "granted",
87
+ source: "default",
88
+ updatedAt: null,
89
+ expiresAt: null
90
+ };
91
+ var changeListeners = /* @__PURE__ */ new Set();
92
+ function onConsentChange(listener) {
93
+ changeListeners.add(listener);
79
94
  return () => {
80
- grantedListeners.delete(listener);
95
+ changeListeners.delete(listener);
81
96
  };
82
97
  }
98
+ function notifyConsentChanged(choice) {
99
+ for (const listener of changeListeners) {
100
+ try {
101
+ listener(choice);
102
+ } catch {
103
+ }
104
+ }
105
+ }
83
106
  function buildConsentPayload(state) {
84
107
  return {
85
108
  ad_storage: state,
@@ -88,47 +111,74 @@ function buildConsentPayload(state) {
88
111
  analytics_storage: state
89
112
  };
90
113
  }
91
- function getConsentState() {
92
- if (typeof window === "undefined") return "pending";
114
+ function getConsentChoice() {
115
+ if (typeof window === "undefined") return DEFAULT_CHOICE;
93
116
  try {
94
- const storedState = window.localStorage.getItem(CONSENT_STATE_KEY);
95
- if (storedState === "granted" || storedState === "denied") return storedState;
117
+ const stored = window.localStorage.getItem(CONSENT_STATE_KEY);
118
+ const updatedAt = window.localStorage.getItem(CONSENT_TIMESTAMP_KEY);
119
+ if (stored === "granted")
120
+ return { state: "granted", source: "explicit", updatedAt, expiresAt: null };
121
+ if (stored === "denied") {
122
+ let expiresAt = window.localStorage.getItem(CONSENT_EXPIRES_AT_KEY);
123
+ if (!expiresAt) {
124
+ expiresAt = new Date(Date.now() + DEFAULT_DECLINE_TTL_DAYS * DAY_MS).toISOString();
125
+ try {
126
+ window.localStorage.setItem(CONSENT_EXPIRES_AT_KEY, expiresAt);
127
+ } catch {
128
+ }
129
+ }
130
+ if (!(Date.parse(expiresAt) <= Date.now()))
131
+ return { state: "denied", source: "explicit", updatedAt, expiresAt };
132
+ }
96
133
  } catch {
97
134
  }
98
- return "pending";
135
+ return DEFAULT_CHOICE;
99
136
  }
100
- function setConsentState(state) {
137
+ function getConsentState() {
138
+ return getConsentChoice().state;
139
+ }
140
+ function pushConsentToPlatforms(state) {
101
141
  if (typeof window === "undefined") return;
102
- try {
103
- window.localStorage.setItem(CONSENT_STATE_KEY, state);
104
- window.localStorage.setItem(CONSENT_TIMESTAMP_KEY, (/* @__PURE__ */ new Date()).toISOString());
105
- } catch {
106
- }
107
142
  if (typeof window.gtag === "function")
108
143
  window.gtag("consent", "update", buildConsentPayload(state));
109
144
  if (typeof window.fbq === "function")
110
145
  window.fbq("consent", state === "granted" ? "grant" : "revoke");
111
- if (state === "granted") {
112
- for (const listener of grantedListeners) {
113
- try {
114
- listener();
115
- } catch {
116
- }
146
+ }
147
+ function setConsentState(state, options) {
148
+ if (typeof window === "undefined") return;
149
+ const requestedTtl = options?.declineTtlDays;
150
+ const ttlDays = typeof requestedTtl === "number" && Number.isFinite(requestedTtl) && requestedTtl > 0 ? requestedTtl : DEFAULT_DECLINE_TTL_DAYS;
151
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
152
+ const expiresAt = state === "denied" ? new Date(Date.now() + ttlDays * DAY_MS).toISOString() : null;
153
+ try {
154
+ window.localStorage.setItem(CONSENT_STATE_KEY, state);
155
+ window.localStorage.setItem(CONSENT_TIMESTAMP_KEY, updatedAt);
156
+ if (expiresAt !== null) {
157
+ window.localStorage.setItem(CONSENT_EXPIRES_AT_KEY, expiresAt);
158
+ } else {
159
+ window.localStorage.removeItem(CONSENT_EXPIRES_AT_KEY);
117
160
  }
161
+ } catch {
118
162
  }
163
+ pushConsentToPlatforms(state);
164
+ notifyConsentChanged({ state, source: "explicit", updatedAt, expiresAt });
165
+ }
166
+ function optIn() {
167
+ setConsentState("granted");
168
+ }
169
+ function optOut(options) {
170
+ setConsentState("denied", options);
119
171
  }
120
172
  function resetConsent() {
121
173
  if (typeof window === "undefined") return;
122
174
  try {
123
175
  window.localStorage.removeItem(CONSENT_STATE_KEY);
124
176
  window.localStorage.removeItem(CONSENT_TIMESTAMP_KEY);
177
+ window.localStorage.removeItem(CONSENT_EXPIRES_AT_KEY);
125
178
  } catch {
126
179
  }
127
- }
128
- function restoreStoredConsent() {
129
- const consentState = getConsentState();
130
- if (consentState === "granted" || consentState === "denied") setConsentState(consentState);
131
- return consentState;
180
+ pushConsentToPlatforms("granted");
181
+ notifyConsentChanged(DEFAULT_CHOICE);
132
182
  }
133
183
 
134
184
  // ../tracking-core/src/tracking.ts
@@ -272,13 +322,11 @@ function fireGtagConversion(input) {
272
322
  }
273
323
  function applyDefaultConsentState() {
274
324
  const gtag = ensureGtagFunction();
275
- gtag("consent", "default", {
276
- ad_storage: "denied",
277
- ad_user_data: "denied",
278
- ad_personalization: "denied",
279
- analytics_storage: "denied",
280
- wait_for_update: 500
281
- });
325
+ gtag(
326
+ "consent",
327
+ "default",
328
+ buildConsentPayload(getConsentState() === "denied" ? "denied" : "granted")
329
+ );
282
330
  }
283
331
  function loadGtagScript(gtagId) {
284
332
  if (typeof document === "undefined") return;
@@ -304,7 +352,6 @@ function bootstrapGoogleAdsTracking(gtagId) {
304
352
  applyDefaultConsentState();
305
353
  loadGtagScript(gtagId);
306
354
  initializeGtag(gtagId);
307
- restoreStoredConsent();
308
355
  }
309
356
  function bootstrapMultipleGtags(gtagIds) {
310
357
  if (typeof window === "undefined" || typeof document === "undefined") return;
@@ -319,7 +366,6 @@ function bootstrapMultipleGtags(gtagIds) {
319
366
  for (const id of ids) {
320
367
  gtag("config", id);
321
368
  }
322
- restoreStoredConsent();
323
369
  }
324
370
 
325
371
  // ../tracking-core/src/fbq.ts
@@ -379,7 +425,7 @@ function ensureFbqFunction() {
379
425
  return fbq;
380
426
  }
381
427
  function applyDefaultMetaConsentState() {
382
- ensureFbqFunction()("consent", "revoke");
428
+ ensureFbqFunction()("consent", getConsentState() === "denied" ? "revoke" : "grant");
383
429
  }
384
430
  function loadFbeventsScript() {
385
431
  if (typeof document === "undefined") return;
@@ -399,19 +445,12 @@ function initializeMetaPixel(pixelId) {
399
445
  fbq("init", pixelId);
400
446
  fbq("track", "PageView");
401
447
  }
402
- function restoreMetaConsentState() {
403
- if (typeof window === "undefined") return;
404
- const state = getConsentState();
405
- if (state === "granted") window.fbq?.("consent", "grant");
406
- else if (state === "denied") window.fbq?.("consent", "revoke");
407
- }
408
448
  function bootstrapMetaPixel(pixelId) {
409
449
  if (typeof window === "undefined" || typeof document === "undefined") return;
410
450
  if (!isValidMetaPixelId(pixelId)) return;
411
451
  applyDefaultMetaConsentState();
412
452
  loadFbeventsScript();
413
453
  initializeMetaPixel(pixelId);
414
- restoreMetaConsentState();
415
454
  captureFbc();
416
455
  }
417
456
  function bootstrapMultiplePixels(pixelIds) {
@@ -427,10 +466,75 @@ function bootstrapMultiplePixels(pixelIds) {
427
466
  fbq("init", id);
428
467
  }
429
468
  fbq("track", "PageView");
430
- restoreMetaConsentState();
431
469
  captureFbc();
432
470
  }
433
471
 
472
+ // ../tracking-core/src/landing.ts
473
+ var LANDING_STORAGE_KEY = "_aranova_track_landing";
474
+ var memoryRecord = null;
475
+ function sanitizeParams(value) {
476
+ if (typeof value !== "object" || value === null) return {};
477
+ const source = value;
478
+ return TRACKING_PARAM_KEYS.reduce((params, key) => {
479
+ const entry = source[key];
480
+ if (typeof entry === "string" && entry.length > 0) params[key] = entry;
481
+ return params;
482
+ }, {});
483
+ }
484
+ function readStoredRecord() {
485
+ try {
486
+ const raw = window.localStorage.getItem(LANDING_STORAGE_KEY);
487
+ if (!raw) return null;
488
+ const parsed = JSON.parse(raw);
489
+ if (typeof parsed.session_id !== "string" || parsed.session_id.length === 0) return null;
490
+ return { session_id: parsed.session_id, params: sanitizeParams(parsed.params) };
491
+ } catch {
492
+ return null;
493
+ }
494
+ }
495
+ function writeRecord(record) {
496
+ memoryRecord = record;
497
+ try {
498
+ window.localStorage.setItem(LANDING_STORAGE_KEY, JSON.stringify(record));
499
+ } catch {
500
+ }
501
+ }
502
+ function captureFromUrl(url) {
503
+ try {
504
+ const resolved = new URL(url ?? window.location.href, window.location.origin);
505
+ return getTrackingQueryValues(resolved.searchParams);
506
+ } catch {
507
+ return {};
508
+ }
509
+ }
510
+ function getOrCaptureLandingParams(sessionId, url) {
511
+ if (typeof window === "undefined") return {};
512
+ const stored = readStoredRecord();
513
+ if (stored && stored.session_id === sessionId) {
514
+ memoryRecord = stored;
515
+ return stored.params;
516
+ }
517
+ if (memoryRecord && memoryRecord.session_id === sessionId) {
518
+ return memoryRecord.params;
519
+ }
520
+ const record = { session_id: sessionId, params: captureFromUrl(url) };
521
+ writeRecord(record);
522
+ return record.params;
523
+ }
524
+ function buildLandingPayloadFields(sessionId, override) {
525
+ const params = override ?? (typeof window === "undefined" ? null : getOrCaptureLandingParams(sessionId));
526
+ if (params === null) return {};
527
+ return {
528
+ landing_gclid: params.gclid ?? null,
529
+ landing_fbclid: params.fbclid ?? null,
530
+ landing_utm_source: params.utm_source ?? null,
531
+ landing_utm_medium: params.utm_medium ?? null,
532
+ landing_utm_campaign: params.utm_campaign ?? null,
533
+ landing_utm_term: params.utm_term ?? null,
534
+ landing_utm_content: params.utm_content ?? null
535
+ };
536
+ }
537
+
434
538
  // ../tracking-core/src/payloads.ts
435
539
  function createTrackingClientContext(surface, input = {}) {
436
540
  return {
@@ -457,6 +561,9 @@ function createTrackingSessionUpsertPayload(trackingParams, input, context) {
457
561
  utm_campaign: trackingParams.utm_campaign,
458
562
  utm_term: trackingParams.utm_term,
459
563
  utm_content: trackingParams.utm_content,
564
+ // Omitted entirely when the landing isn't observable (SSR, no override) —
565
+ // present-as-null would wrongly tell the backend "landed with no params".
566
+ ...buildLandingPayloadFields(input.sessionId, input.landingParams),
460
567
  first_page: input.firstPage ?? null,
461
568
  consent_state: input.consentState ?? null,
462
569
  context
@@ -478,8 +585,6 @@ function createTrackingEventCreatePayload(trackingParams, input, context) {
478
585
 
479
586
  // ../tracking-core/src/resources/conversion-firing.ts
480
587
  var DEDUP_PREFIX = "_aranova_conv_";
481
- var MAX_PENDING = 100;
482
- var pendingQueue = [];
483
588
  function dedupKey(input) {
484
589
  return `${DEDUP_PREFIX}${input.transactionId ?? ""}:${input.sendTo}`;
485
590
  }
@@ -503,23 +608,9 @@ function fireOnce(input) {
503
608
  if (fireGtagConversion(input)) markFired(input);
504
609
  }
505
610
  function fireConversionWithConsent(input) {
506
- const state = getConsentState();
507
- if (state === "denied") return;
508
- if (state === "pending") {
509
- if (pendingQueue.length >= MAX_PENDING) pendingQueue.shift();
510
- pendingQueue.push(input);
511
- return;
512
- }
611
+ if (getConsentState() === "denied") return;
513
612
  fireOnce(input);
514
613
  }
515
- function flushPendingConversions() {
516
- if (getConsentState() !== "granted") return;
517
- while (pendingQueue.length > 0) {
518
- const input = pendingQueue.shift();
519
- if (input) fireOnce(input);
520
- }
521
- }
522
- if (typeof window !== "undefined") onConsentGranted(flushPendingConversions);
523
614
 
524
615
  // ../tracking-core/src/resources/conversion-config.ts
525
616
  function isStringMap(value) {
@@ -1065,7 +1156,13 @@ function readTrackingParams() {
1065
1156
  }
1066
1157
  function consentSnapshot() {
1067
1158
  try {
1068
- return { state: getConsentState() };
1159
+ const choice = getConsentChoice();
1160
+ return {
1161
+ state: choice.state,
1162
+ source: choice.source,
1163
+ updated_at: choice.updatedAt,
1164
+ expires_at: choice.expiresAt
1165
+ };
1069
1166
  } catch {
1070
1167
  return null;
1071
1168
  }
@@ -1138,6 +1235,7 @@ function createTrackingClient(config) {
1138
1235
  initialParams = captureTrackingParamsFromLocation();
1139
1236
  } catch {
1140
1237
  }
1238
+ getOrCaptureLandingParams(sessionId);
1141
1239
  try {
1142
1240
  captureFbc();
1143
1241
  } catch {
@@ -1187,6 +1285,11 @@ function createTrackingClient(config) {
1187
1285
  utm_campaign: params.utm_campaign,
1188
1286
  utm_term: params.utm_term,
1189
1287
  utm_content: params.utm_content,
1288
+ // Landing params for the CURRENT session id — captured on the spot when
1289
+ // the session just rotated (the current URL is the rotated session's
1290
+ // landing), reused from the stored record otherwise. Keys are omitted
1291
+ // entirely when the landing isn't observable (SSR).
1292
+ ...buildLandingPayloadFields(sessionId),
1190
1293
  first_page: firstPage,
1191
1294
  consent_state: consentSnapshot(),
1192
1295
  context
@@ -1254,6 +1357,7 @@ function createTrackingClient(config) {
1254
1357
  return {
1255
1358
  trackEvent,
1256
1359
  flush,
1360
+ flushBeacon: flushOnUnload,
1257
1361
  getSessionId: () => sessionId,
1258
1362
  getVisitorId: () => visitorId,
1259
1363
  destroy: () => {
@@ -1278,9 +1382,17 @@ var ctaClickMetadataSchema = import_zod2.z.object({
1278
1382
  path: import_zod2.z.string()
1279
1383
  }).strict(),
1280
1384
  section: import_zod2.z.string().nullable().optional(),
1281
- destination_url: import_zod2.z.string().nullable().optional()
1385
+ destination_url: import_zod2.z.string().nullable().optional(),
1386
+ // Set by auto-capture (and available to manual callers): the link target
1387
+ // and a short element descriptor (tag#id) for tying clicks to specific UI.
1388
+ href: import_zod2.z.string().nullable().optional(),
1389
+ element: import_zod2.z.string().nullable().optional()
1390
+ }).strict();
1391
+ var ctaClickConfigSchema = import_zod2.z.object({
1392
+ autoCapture: import_zod2.z.object({
1393
+ selector: import_zod2.z.string().optional()
1394
+ }).strict().optional()
1282
1395
  }).strict();
1283
- var ctaClickConfigSchema = import_zod2.z.object({}).strict();
1284
1396
 
1285
1397
  // ../tracking-core/src/events/sdk-heartbeat.ts
1286
1398
  var import_zod3 = require("zod");
@@ -1356,31 +1468,44 @@ var multiPageSessionConfigSchema = import_zod6.z.object({
1356
1468
  pageThreshold: import_zod6.z.number().int().min(2)
1357
1469
  }).strict();
1358
1470
 
1359
- // ../tracking-core/src/events/phone-click.ts
1471
+ // ../tracking-core/src/events/page-exit.ts
1360
1472
  var import_zod7 = require("zod");
1361
- var phoneClickMetadataSchema = import_zod7.z.object({
1362
- phone_number: import_zod7.z.string(),
1473
+ var pageExitMetadataSchema = import_zod7.z.object({
1474
+ dwell_ms: import_zod7.z.number().int().min(0),
1475
+ // null = left without any scroll signal; floor is 0 so a valid 0% is never
1476
+ // rejected (a single bad field 422s the whole keepalive beacon batch).
1477
+ max_scroll_percent: import_zod7.z.number().int().min(0).max(100).nullable(),
1363
1478
  page: import_zod7.z.object({
1364
1479
  path: import_zod7.z.string()
1365
- }).strict(),
1366
- section: import_zod7.z.string().nullable().optional()
1480
+ }).strict()
1367
1481
  }).strict();
1368
- var phoneClickConfigSchema = import_zod7.z.object({}).strict();
1482
+ var pageExitConfigSchema = import_zod7.z.object({}).strict();
1369
1483
 
1370
- // ../tracking-core/src/events/scroll-depth.ts
1484
+ // ../tracking-core/src/events/phone-click.ts
1371
1485
  var import_zod8 = require("zod");
1372
- var scrollDepthMetadataSchema = import_zod8.z.object({
1373
- depth_percent: import_zod8.z.number().int().min(1).max(100),
1486
+ var phoneClickMetadataSchema = import_zod8.z.object({
1487
+ phone_number: import_zod8.z.string(),
1374
1488
  page: import_zod8.z.object({
1375
1489
  path: import_zod8.z.string()
1490
+ }).strict(),
1491
+ section: import_zod8.z.string().nullable().optional()
1492
+ }).strict();
1493
+ var phoneClickConfigSchema = import_zod8.z.object({}).strict();
1494
+
1495
+ // ../tracking-core/src/events/scroll-depth.ts
1496
+ var import_zod9 = require("zod");
1497
+ var scrollDepthMetadataSchema = import_zod9.z.object({
1498
+ depth_percent: import_zod9.z.number().int().min(1).max(100),
1499
+ page: import_zod9.z.object({
1500
+ path: import_zod9.z.string()
1376
1501
  }).strict()
1377
1502
  }).strict();
1378
- var scrollDepthConfigSchema = import_zod8.z.object({
1379
- thresholds: import_zod8.z.array(import_zod8.z.number().int().min(1).max(100)).min(1)
1503
+ var scrollDepthConfigSchema = import_zod9.z.object({
1504
+ thresholds: import_zod9.z.array(import_zod9.z.number().int().min(1).max(100)).min(1)
1380
1505
  }).strict();
1381
1506
 
1382
1507
  // ../tracking-core/src/events/specific-page-visit.ts
1383
- var import_zod9 = require("zod");
1508
+ var import_zod10 = require("zod");
1384
1509
  var SPECIFIC_PAGE_NAMES = [
1385
1510
  "contact_page",
1386
1511
  "about_page",
@@ -1391,18 +1516,18 @@ var SPECIFIC_PAGE_NAMES = [
1391
1516
  "faq_page",
1392
1517
  "testimonials_page"
1393
1518
  ];
1394
- var specificPageNameSchema = import_zod9.z.enum(SPECIFIC_PAGE_NAMES);
1395
- var specificPageVisitMetadataSchema = import_zod9.z.object({
1519
+ var specificPageNameSchema = import_zod10.z.enum(SPECIFIC_PAGE_NAMES);
1520
+ var specificPageVisitMetadataSchema = import_zod10.z.object({
1396
1521
  page_name: specificPageNameSchema,
1397
- page: import_zod9.z.object({
1398
- path: import_zod9.z.string()
1522
+ page: import_zod10.z.object({
1523
+ path: import_zod10.z.string()
1399
1524
  }).strict()
1400
1525
  }).strict();
1401
- var specificPageVisitConfigSchema = import_zod9.z.object({
1402
- pages: import_zod9.z.array(
1403
- import_zod9.z.object({
1526
+ var specificPageVisitConfigSchema = import_zod10.z.object({
1527
+ pages: import_zod10.z.array(
1528
+ import_zod10.z.object({
1404
1529
  name: specificPageNameSchema,
1405
- pathPattern: import_zod9.z.custom((value) => value instanceof RegExp, {
1530
+ pathPattern: import_zod10.z.custom((value) => value instanceof RegExp, {
1406
1531
  message: "pathPattern must be a RegExp"
1407
1532
  })
1408
1533
  }).strict()
@@ -1410,15 +1535,15 @@ var specificPageVisitConfigSchema = import_zod9.z.object({
1410
1535
  }).strict();
1411
1536
 
1412
1537
  // ../tracking-core/src/events/time-on-site.ts
1413
- var import_zod10 = require("zod");
1414
- var timeOnSiteMetadataSchema = import_zod10.z.object({
1415
- duration_ms: import_zod10.z.number().int().nonnegative(),
1416
- page: import_zod10.z.object({
1417
- path: import_zod10.z.string()
1538
+ var import_zod11 = require("zod");
1539
+ var timeOnSiteMetadataSchema = import_zod11.z.object({
1540
+ duration_ms: import_zod11.z.number().int().nonnegative(),
1541
+ page: import_zod11.z.object({
1542
+ path: import_zod11.z.string()
1418
1543
  }).strict()
1419
1544
  }).strict();
1420
- var timeOnSiteConfigSchema = import_zod10.z.object({
1421
- thresholdSeconds: import_zod10.z.number().int().positive()
1545
+ var timeOnSiteConfigSchema = import_zod11.z.object({
1546
+ thresholdSeconds: import_zod11.z.number().int().positive()
1422
1547
  }).strict();
1423
1548
 
1424
1549
  // ../tracking-core/src/events/registry.ts
@@ -1460,6 +1585,11 @@ var EVENT_REGISTRY = {
1460
1585
  metadataSchema: sdkHeartbeatMetadataSchema,
1461
1586
  configSchema: sdkHeartbeatConfigSchema
1462
1587
  },
1588
+ page_exit: {
1589
+ kind: "automatic",
1590
+ metadataSchema: pageExitMetadataSchema,
1591
+ configSchema: pageExitConfigSchema
1592
+ },
1463
1593
  // --- manual triggers ---
1464
1594
  form_submit: {
1465
1595
  kind: "manual",
@@ -1838,6 +1968,163 @@ function attachFormStart(client, config) {
1838
1968
  };
1839
1969
  }
1840
1970
 
1971
+ // ../tracking-core/src/triggers/page-exit.ts
1972
+ function attachPageExit(client) {
1973
+ if (typeof window === "undefined" || typeof document === "undefined") {
1974
+ return () => {
1975
+ };
1976
+ }
1977
+ let currentPath2 = window.location.pathname;
1978
+ let activeSince = document.visibilityState === "visible" ? Date.now() : null;
1979
+ let accumulatedMs = 0;
1980
+ let maxScrollPercent = null;
1981
+ let rafId = null;
1982
+ function getScrollPercent() {
1983
+ const doc = document.documentElement;
1984
+ const scrollTop = window.scrollY || doc.scrollTop;
1985
+ const scrollHeight = doc.scrollHeight;
1986
+ const clientHeight = doc.clientHeight;
1987
+ if (scrollHeight <= clientHeight) return 100;
1988
+ return Math.round((scrollTop + clientHeight) / scrollHeight * 100);
1989
+ }
1990
+ function onScroll() {
1991
+ if (rafId !== null) return;
1992
+ rafId = requestAnimationFrame(() => {
1993
+ rafId = null;
1994
+ const percent = getScrollPercent();
1995
+ if (percent >= 1 && (maxScrollPercent === null || percent > maxScrollPercent)) {
1996
+ maxScrollPercent = Math.min(percent, 100);
1997
+ }
1998
+ });
1999
+ }
2000
+ function settledDwellMs() {
2001
+ let total = accumulatedMs;
2002
+ if (activeSince !== null) {
2003
+ total += Date.now() - activeSince;
2004
+ }
2005
+ return Math.max(0, Math.round(total));
2006
+ }
2007
+ function emitSegment(path, flush) {
2008
+ const dwell = settledDwellMs();
2009
+ if (dwell === 0) return;
2010
+ client.trackEvent({
2011
+ eventType: "page_exit",
2012
+ metadata: {
2013
+ dwell_ms: dwell,
2014
+ max_scroll_percent: maxScrollPercent,
2015
+ page: { path }
2016
+ },
2017
+ pageUrl: window.location.href,
2018
+ occurredAt: null
2019
+ });
2020
+ if (flush) {
2021
+ client.flushBeacon();
2022
+ }
2023
+ accumulatedMs = 0;
2024
+ activeSince = document.visibilityState === "visible" ? Date.now() : null;
2025
+ }
2026
+ function onNavigate() {
2027
+ const newPath = window.location.pathname;
2028
+ if (newPath === currentPath2) return;
2029
+ emitSegment(currentPath2, false);
2030
+ currentPath2 = newPath;
2031
+ maxScrollPercent = null;
2032
+ accumulatedMs = 0;
2033
+ activeSince = document.visibilityState === "visible" ? Date.now() : null;
2034
+ }
2035
+ function onVisibilityChange() {
2036
+ if (document.visibilityState === "hidden") {
2037
+ emitSegment(currentPath2, true);
2038
+ } else {
2039
+ activeSince = Date.now();
2040
+ }
2041
+ }
2042
+ function onPageHide() {
2043
+ emitSegment(currentPath2, true);
2044
+ }
2045
+ const originalPushState = history.pushState.bind(history);
2046
+ const originalReplaceState = history.replaceState.bind(history);
2047
+ function patchedPushState(...args) {
2048
+ originalPushState(...args);
2049
+ setTimeout(onNavigate, 0);
2050
+ }
2051
+ function patchedReplaceState(...args) {
2052
+ originalReplaceState(...args);
2053
+ setTimeout(onNavigate, 0);
2054
+ }
2055
+ history.pushState = patchedPushState;
2056
+ history.replaceState = patchedReplaceState;
2057
+ window.addEventListener("popstate", onNavigate);
2058
+ window.addEventListener("scroll", onScroll, { passive: true });
2059
+ document.addEventListener("visibilitychange", onVisibilityChange);
2060
+ window.addEventListener("pagehide", onPageHide);
2061
+ return () => {
2062
+ if (rafId !== null) cancelAnimationFrame(rafId);
2063
+ history.pushState = originalPushState;
2064
+ history.replaceState = originalReplaceState;
2065
+ window.removeEventListener("popstate", onNavigate);
2066
+ window.removeEventListener("scroll", onScroll);
2067
+ document.removeEventListener("visibilitychange", onVisibilityChange);
2068
+ window.removeEventListener("pagehide", onPageHide);
2069
+ };
2070
+ }
2071
+
2072
+ // ../tracking-core/src/triggers/cta-click-capture.ts
2073
+ var DEFAULT_CTA_SELECTOR = "[data-aranova-cta]";
2074
+ var CTA_NAME_MAX_LENGTH = 120;
2075
+ function describeElement(el) {
2076
+ const tag = el.tagName.toLowerCase();
2077
+ return el.id ? `${tag}#${el.id}` : tag;
2078
+ }
2079
+ function resolveCtaName(el) {
2080
+ const explicit = el.getAttribute("data-aranova-cta");
2081
+ if (explicit && explicit.trim().length > 0) return explicit.trim();
2082
+ const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
2083
+ if (text.length > 0) return text.slice(0, CTA_NAME_MAX_LENGTH);
2084
+ return describeElement(el);
2085
+ }
2086
+ function attachCtaClickCapture(client, config) {
2087
+ if (typeof window === "undefined" || typeof document === "undefined") {
2088
+ return () => {
2089
+ };
2090
+ }
2091
+ const autoCapture = config.autoCapture;
2092
+ if (!autoCapture) {
2093
+ return () => {
2094
+ };
2095
+ }
2096
+ const selector = autoCapture.selector ?? DEFAULT_CTA_SELECTOR;
2097
+ function onClick(event) {
2098
+ const target = event.target;
2099
+ if (!(target instanceof Element)) return;
2100
+ let matched = null;
2101
+ try {
2102
+ matched = target.closest(selector);
2103
+ } catch {
2104
+ return;
2105
+ }
2106
+ if (matched === null) return;
2107
+ const href = matched instanceof HTMLAnchorElement ? matched.href || null : matched.getAttribute("href");
2108
+ client.trackEvent({
2109
+ eventType: "cta_click",
2110
+ metadata: {
2111
+ cta_name: resolveCtaName(matched),
2112
+ page: { path: window.location.pathname },
2113
+ section: matched.getAttribute("data-aranova-section"),
2114
+ destination_url: href,
2115
+ href,
2116
+ element: describeElement(matched)
2117
+ },
2118
+ pageUrl: window.location.href,
2119
+ occurredAt: null
2120
+ });
2121
+ }
2122
+ document.addEventListener("click", onClick, true);
2123
+ return () => {
2124
+ document.removeEventListener("click", onClick, true);
2125
+ };
2126
+ }
2127
+
1841
2128
  // ../tracking-core/src/resources/sales/errors.ts
1842
2129
  var AranovaApiError = class extends Error {
1843
2130
  constructor(message, options) {
@@ -2044,42 +2331,42 @@ function createSalesClient(config) {
2044
2331
  }
2045
2332
 
2046
2333
  // ../tracking-core/src/resources/sales/schema.ts
2047
- var import_zod11 = require("zod");
2334
+ var import_zod12 = require("zod");
2048
2335
  var SUPPORTED_CURRENCIES = ["USD", "CAD"];
2049
2336
  var TRACKING_ENVIRONMENTS = ["production", "development"];
2050
- var currencySchema = import_zod11.z.enum(SUPPORTED_CURRENCIES);
2051
- var centsSchema = import_zod11.z.number().int().nonnegative();
2052
- var quantitySchema = import_zod11.z.string().regex(/^\d+(\.\d{1,3})?$/);
2053
- var metadataSchema = import_zod11.z.record(import_zod11.z.unknown());
2054
- var saleItemSchema = import_zod11.z.object({
2055
- external_item_id: import_zod11.z.string().nullable().optional(),
2056
- name: import_zod11.z.string().nullable().optional(),
2057
- category: import_zod11.z.string().nullable().optional(),
2337
+ var currencySchema = import_zod12.z.enum(SUPPORTED_CURRENCIES);
2338
+ var centsSchema = import_zod12.z.number().int().nonnegative();
2339
+ var quantitySchema = import_zod12.z.string().regex(/^\d+(\.\d{1,3})?$/);
2340
+ var metadataSchema = import_zod12.z.record(import_zod12.z.unknown());
2341
+ var saleItemSchema = import_zod12.z.object({
2342
+ external_item_id: import_zod12.z.string().nullable().optional(),
2343
+ name: import_zod12.z.string().nullable().optional(),
2344
+ category: import_zod12.z.string().nullable().optional(),
2058
2345
  quantity: quantitySchema,
2059
2346
  unit_price_cents: centsSchema,
2060
2347
  // Non-negativity validated on the wire — same contract as the other cents
2061
2348
  // fields — and backstopped by the DB CHECK.
2062
2349
  unit_cost_cents: centsSchema.nullable().optional()
2063
2350
  }).strict();
2064
- var saleServiceSchema = import_zod11.z.object({
2065
- service: import_zod11.z.string(),
2351
+ var saleServiceSchema = import_zod12.z.object({
2352
+ service: import_zod12.z.string(),
2066
2353
  amount_cents: centsSchema
2067
2354
  }).strict();
2068
- var customerNameSchema = import_zod11.z.string().max(200);
2069
- var customerPhoneSchema = import_zod11.z.string().max(64);
2070
- var customerEmailSchema = import_zod11.z.string().max(320).email();
2355
+ var customerNameSchema = import_zod12.z.string().max(200);
2356
+ var customerPhoneSchema = import_zod12.z.string().max(64);
2357
+ var customerEmailSchema = import_zod12.z.string().max(320).email();
2071
2358
  function refineServiceXor(val, ctx, { requireAmount }) {
2072
2359
  if (val.services != null) {
2073
2360
  if (val.service != null) {
2074
2361
  ctx.addIssue({
2075
- code: import_zod11.z.ZodIssueCode.custom,
2362
+ code: import_zod12.z.ZodIssueCode.custom,
2076
2363
  message: "pass either `service` or `services`, not both",
2077
2364
  path: ["services"]
2078
2365
  });
2079
2366
  }
2080
2367
  if (val.services.length === 0) {
2081
2368
  ctx.addIssue({
2082
- code: import_zod11.z.ZodIssueCode.custom,
2369
+ code: import_zod12.z.ZodIssueCode.custom,
2083
2370
  message: "`services` must not be empty",
2084
2371
  path: ["services"]
2085
2372
  });
@@ -2087,7 +2374,7 @@ function refineServiceXor(val, ctx, { requireAmount }) {
2087
2374
  const keys = val.services.map((s) => s.service);
2088
2375
  if (new Set(keys).size !== keys.length) {
2089
2376
  ctx.addIssue({
2090
- code: import_zod11.z.ZodIssueCode.custom,
2377
+ code: import_zod12.z.ZodIssueCode.custom,
2091
2378
  message: "`services` must not list the same service more than once",
2092
2379
  path: ["services"]
2093
2380
  });
@@ -2096,7 +2383,7 @@ function refineServiceXor(val, ctx, { requireAmount }) {
2096
2383
  const sum = val.services.reduce((acc, s) => acc + s.amount_cents, 0);
2097
2384
  if (val.amount_total_cents !== sum) {
2098
2385
  ctx.addIssue({
2099
- code: import_zod11.z.ZodIssueCode.custom,
2386
+ code: import_zod12.z.ZodIssueCode.custom,
2100
2387
  message: "amount_total_cents must equal the sum of the services amounts (omit it to derive it automatically)",
2101
2388
  path: ["amount_total_cents"]
2102
2389
  });
@@ -2104,37 +2391,37 @@ function refineServiceXor(val, ctx, { requireAmount }) {
2104
2391
  }
2105
2392
  } else if (requireAmount && val.amount_total_cents == null) {
2106
2393
  ctx.addIssue({
2107
- code: import_zod11.z.ZodIssueCode.custom,
2394
+ code: import_zod12.z.ZodIssueCode.custom,
2108
2395
  message: "amount_total_cents is required unless `services` is provided",
2109
2396
  path: ["amount_total_cents"]
2110
2397
  });
2111
2398
  }
2112
2399
  }
2113
- var saleCreateSchema = import_zod11.z.object({
2114
- external_id: import_zod11.z.string().nullable().optional(),
2115
- description: import_zod11.z.string().nullable().optional(),
2116
- service: import_zod11.z.string().nullable().optional(),
2117
- services: import_zod11.z.array(saleServiceSchema).nullable().optional(),
2400
+ var saleCreateSchema = import_zod12.z.object({
2401
+ external_id: import_zod12.z.string().nullable().optional(),
2402
+ description: import_zod12.z.string().nullable().optional(),
2403
+ service: import_zod12.z.string().nullable().optional(),
2404
+ services: import_zod12.z.array(saleServiceSchema).nullable().optional(),
2118
2405
  currency: currencySchema,
2119
2406
  // Optional only because the plural `services` form derives it from the sum
2120
2407
  // (see refineServiceXor); the singular/serviceless path still requires it.
2121
2408
  amount_total_cents: centsSchema.nullable().optional(),
2122
- occurred_at: import_zod11.z.string().datetime(),
2123
- environment: import_zod11.z.enum(TRACKING_ENVIRONMENTS).default("production"),
2124
- items: import_zod11.z.array(saleItemSchema).default([]),
2409
+ occurred_at: import_zod12.z.string().datetime(),
2410
+ environment: import_zod12.z.enum(TRACKING_ENVIRONMENTS).default("production"),
2411
+ items: import_zod12.z.array(saleItemSchema).default([]),
2125
2412
  metadata: metadataSchema.nullable().optional(),
2126
2413
  customer_name: customerNameSchema.nullable().optional(),
2127
2414
  customer_phone: customerPhoneSchema.nullable().optional(),
2128
2415
  customer_email: customerEmailSchema.nullable().optional()
2129
2416
  }).strict().superRefine((val, ctx) => refineServiceXor(val, ctx, { requireAmount: true }));
2130
- var saleUpdateSchema = import_zod11.z.object({
2131
- description: import_zod11.z.string().nullable().optional(),
2132
- service: import_zod11.z.string().nullable().optional(),
2133
- services: import_zod11.z.array(saleServiceSchema).nullable().optional(),
2417
+ var saleUpdateSchema = import_zod12.z.object({
2418
+ description: import_zod12.z.string().nullable().optional(),
2419
+ service: import_zod12.z.string().nullable().optional(),
2420
+ services: import_zod12.z.array(saleServiceSchema).nullable().optional(),
2134
2421
  currency: currencySchema.optional(),
2135
2422
  amount_total_cents: centsSchema.optional(),
2136
- occurred_at: import_zod11.z.string().datetime().optional(),
2137
- items: import_zod11.z.array(saleItemSchema).optional(),
2423
+ occurred_at: import_zod12.z.string().datetime().optional(),
2424
+ items: import_zod12.z.array(saleItemSchema).optional(),
2138
2425
  metadata: metadataSchema.nullable().optional(),
2139
2426
  customer_name: customerNameSchema.nullable().optional(),
2140
2427
  customer_phone: customerPhoneSchema.nullable().optional(),
@@ -2220,36 +2507,68 @@ function useTrackingParams() {
2220
2507
  }, []);
2221
2508
  return trackingParams;
2222
2509
  }
2223
- function useConsentState() {
2224
- return useConsent().state;
2225
- }
2226
- function useConsent() {
2227
- const [state, setState] = (0, import_react.useState)("pending");
2510
+ var DEFAULT_CHOICE2 = {
2511
+ state: "granted",
2512
+ source: "default",
2513
+ updatedAt: null,
2514
+ expiresAt: null
2515
+ };
2516
+ function useCookiePreferences(options) {
2517
+ const [choice, setChoice] = (0, import_react.useState)(DEFAULT_CHOICE2);
2518
+ const ttlDays = options?.declineTtlDays;
2228
2519
  (0, import_react.useEffect)(() => {
2229
- setState(getConsentState());
2520
+ const sync = () => setChoice(getConsentChoice());
2521
+ sync();
2230
2522
  const handleStorage = (event) => {
2231
- if (event.key === CONSENT_STATE_KEY) setState(getConsentState());
2523
+ if (event.key === null || event.key === CONSENT_STATE_KEY || event.key === CONSENT_EXPIRES_AT_KEY || event.key === CONSENT_TIMESTAMP_KEY)
2524
+ sync();
2232
2525
  };
2233
2526
  window.addEventListener("storage", handleStorage);
2234
- return () => window.removeEventListener("storage", handleStorage);
2235
- }, []);
2236
- const accept = (0, import_react.useCallback)(() => {
2237
- setConsentState("granted");
2238
- setState("granted");
2527
+ const unsubscribe = onConsentChange(sync);
2528
+ return () => {
2529
+ window.removeEventListener("storage", handleStorage);
2530
+ unsubscribe();
2531
+ };
2239
2532
  }, []);
2240
- const decline = (0, import_react.useCallback)(() => {
2241
- setConsentState("denied");
2242
- setState("denied");
2533
+ const optOutAction = (0, import_react.useCallback)(() => {
2534
+ optOut(ttlDays != null ? { declineTtlDays: ttlDays } : void 0);
2535
+ }, [ttlDays]);
2536
+ const optInAction = (0, import_react.useCallback)(() => {
2537
+ optIn();
2243
2538
  }, []);
2244
2539
  const reset = (0, import_react.useCallback)(() => {
2245
2540
  resetConsent();
2246
- setState("pending");
2247
2541
  }, []);
2542
+ return {
2543
+ state: choice.state,
2544
+ source: choice.source,
2545
+ isDefault: choice.source === "default",
2546
+ isGranted: choice.state === "granted",
2547
+ isDenied: choice.state === "denied",
2548
+ updatedAt: choice.updatedAt,
2549
+ expiresAt: choice.expiresAt,
2550
+ optOut: optOutAction,
2551
+ optIn: optInAction,
2552
+ reset
2553
+ };
2554
+ }
2555
+ function useConsentState() {
2556
+ return useConsent().state;
2557
+ }
2558
+ function useConsent() {
2559
+ const {
2560
+ state,
2561
+ isGranted,
2562
+ isDenied,
2563
+ optIn: accept,
2564
+ optOut: decline,
2565
+ reset
2566
+ } = useCookiePreferences();
2248
2567
  return {
2249
2568
  state,
2250
- isPending: state === "pending",
2251
- isGranted: state === "granted",
2252
- isDenied: state === "denied",
2569
+ isPending: false,
2570
+ isGranted,
2571
+ isDenied,
2253
2572
  accept,
2254
2573
  decline,
2255
2574
  reset
@@ -2490,7 +2809,7 @@ function GoogleAdsTracking(props) {
2490
2809
  var import_react6 = require("react");
2491
2810
 
2492
2811
  // package.json
2493
- var version = "0.14.2";
2812
+ var version = "0.16.0";
2494
2813
 
2495
2814
  // ../tracking-core/src/phone-react.tsx
2496
2815
  var import_react5 = require("react");
@@ -2671,6 +2990,7 @@ function createTracking(options) {
2671
2990
  const detectorClient = conversionStore ? withConversionAutoFire(rawClient, createConversionAutoFire(conversionStore)) : rawClient;
2672
2991
  detachers.push(attachAutoPageView(detectorClient));
2673
2992
  detachers.push(attachBfcacheRestore(detectorClient));
2993
+ detachers.push(attachPageExit(detectorClient));
2674
2994
  const timeOnSite = triggers.automatic.time_on_site;
2675
2995
  if (timeOnSite) {
2676
2996
  detachers.push(attachTimeOnSite(detectorClient, timeOnSite));
@@ -2691,6 +3011,10 @@ function createTracking(options) {
2691
3011
  if (formStart) {
2692
3012
  detachers.push(attachFormStart(detectorClient, formStart));
2693
3013
  }
3014
+ const ctaClick = triggers.manual?.cta_click;
3015
+ if (ctaClick) {
3016
+ detachers.push(attachCtaClickCapture(detectorClient, ctaClick));
3017
+ }
2694
3018
  return () => {
2695
3019
  for (let i = detachers.length - 1; i >= 0; i--) {
2696
3020
  detachers[i]();
@@ -2715,6 +3039,7 @@ function createTracking(options) {
2715
3039
  AdPlatformTracking,
2716
3040
  AranovaApiError,
2717
3041
  ConsentBanner,
3042
+ DEFAULT_DECLINE_TTL_DAYS,
2718
3043
  DEFAULT_PHONE_COUNTRY,
2719
3044
  GoogleAdsTracking,
2720
3045
  NAMED_RANGES,
@@ -2734,7 +3059,11 @@ function createTracking(options) {
2734
3059
  formatPhone,
2735
3060
  formatPhoneAsTyped,
2736
3061
  fromMinor,
3062
+ getConsentChoice,
2737
3063
  getConsentState,
3064
+ onConsentChange,
3065
+ optIn,
3066
+ optOut,
2738
3067
  parsePhone,
2739
3068
  phoneField,
2740
3069
  resetConsent,
@@ -2749,6 +3078,7 @@ function createTracking(options) {
2749
3078
  toMinor,
2750
3079
  useConsent,
2751
3080
  useConsentState,
3081
+ useCookiePreferences,
2752
3082
  useGclid,
2753
3083
  usePhoneConfig,
2754
3084
  usePhoneField,