@callforge/tracking-client 0.7.1 → 0.9.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @callforge/tracking-client
2
2
 
3
- Lightweight client library for the CallForge tracking API. Handles location-aware phone number assignment with aggressive caching and optional preload optimization.
3
+ Lightweight client library for the CallForge tracking API. Handles location-aware phone number assignment with aggressive caching and preload optimization.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,11 +10,33 @@ npm install @callforge/tracking-client
10
10
  pnpm add @callforge/tracking-client
11
11
  ```
12
12
 
13
+ ## Lease Hardening Migration (v0.8+)
14
+
15
+ This release adds bootstrap-token support for lease hardening and bot suppression.
16
+
17
+ Client integration requirements:
18
+ - Add the generated preload snippet in `<head>`. This prefetches bootstrap tokens and keeps session lookup fast.
19
+ - Keep handling `phoneNumber` and `leaseId` separately. A request can return a phone number with `leaseId: null` when lease assignment is intentionally suppressed.
20
+ - For attribution/scale metrics, treat `leaseId` as the source of truth for lease-backed traffic.
21
+
22
+ ## Realtime Data Layer Upgrade (v0.9+)
23
+
24
+ This release adds explicit browser helpers for the new realtime layer:
25
+ - `client.linkPhoneCall({ phoneNumber })` for strict web-click to phone-call linkage.
26
+ - `client.setWebZip(zipCode, { source })` for immediate web ZIP availability to call processing.
27
+
28
+ Integration checklist:
29
+ - Call `linkPhoneCall` immediately before opening a `tel:` link.
30
+ - Pass the exact dialed number string (`+1...`) used by the link.
31
+ - Continue dialing even if `linkPhoneCall` fails (best-effort attribution assist, not UX-blocking).
32
+ - Call `setWebZip` when the visitor selects or types a ZIP.
33
+ - Keep `setParams` for broader attribution params; use `setWebZip` for fast ZIP propagation.
34
+
13
35
  ## Quick Start
14
36
 
15
- ### 1. Add preload snippet to `<head>` (optional but recommended)
37
+ ### 1. Add preload snippet to `<head>` (required for deterministic leases)
16
38
 
17
- For optimal performance on static sites, add this snippet to your HTML `<head>`:
39
+ For optimal performance and lease hardening, add this snippet to your HTML `<head>`:
18
40
 
19
41
  ```typescript
20
42
  import { getPreloadSnippet } from '@callforge/tracking-client';
@@ -75,7 +97,33 @@ Notes:
75
97
  - `callIntentToken` is short-lived and single-use.
76
98
  - Treat it as an opaque secret (do not log it).
77
99
 
78
- ### 4. GA4 Integration
100
+ ### 4. Realtime call-link + web ZIP helpers (recommended)
101
+
102
+ Use explicit helpers to write realtime data from the browser.
103
+
104
+ ```typescript
105
+ const client = CallForge.init({ categoryId: 'your-category-id' });
106
+
107
+ async function dialTrackedNumber(phoneNumber: string) {
108
+ try {
109
+ // Default TTL is 30s (clamped server-side to 5-120s).
110
+ await client.linkPhoneCall({ phoneNumber });
111
+ } finally {
112
+ // Do not block dialing on telemetry failure.
113
+ window.location.href = `tel:${phoneNumber}`;
114
+ }
115
+ }
116
+
117
+ // Example ZIP picker handler
118
+ await client.setWebZip('30309', { source: 'manual' });
119
+ ```
120
+
121
+ Notes:
122
+ - `linkPhoneCall` is optimized for mobile tap-to-call timing.
123
+ - `setWebZip` accepts `manual` (typed) and `suggested` (picked from options).
124
+ - `setWebZip` validates ZIP format (`12345`) client-side before sending.
125
+
126
+ ### 5. GA4 Integration
79
127
 
80
128
  To enable GA4 call event tracking, CallForge needs the GA4 `client_id` for the visitor (from the `_ga` cookie).
81
129
  Optionally provide your GA4 Measurement ID to improve client ID capture reliability when Google Analytics loads late.
@@ -105,7 +153,7 @@ client.setParams({
105
153
  });
106
154
  ```
107
155
 
108
- ### 5. Track conversion parameters (optional)
156
+ ### 6. Track conversion parameters (optional)
109
157
 
110
158
  The client automatically captures ad platform click IDs from the URL:
111
159
 
@@ -158,6 +206,7 @@ Behavior:
158
206
  - Returns cached data if valid.
159
207
  - Fetches fresh data when cache is missing/expired.
160
208
  - If `loc_physical_ms` is present in the URL, cached sessions are only reused when it matches the cached `locId`.
209
+ - If lease assignment is suppressed (for example bot traffic or missing/invalid bootstrap), `phoneNumber` may be present while `leaseId` is `null`.
161
210
  - Throws on network errors or API errors.
162
211
 
163
212
  ### `client.getLocation()`
@@ -204,6 +253,41 @@ const intent = await client.createCallIntent();
204
253
  console.log(intent.callIntentToken);
205
254
  ```
206
255
 
256
+ ### `client.linkPhoneCall(input)`
257
+
258
+ Create a short-lived realtime call-link intent keyed by dialed number.
259
+
260
+ ```typescript
261
+ const result = await client.linkPhoneCall({
262
+ phoneNumber: '+13105551234',
263
+ ttlSeconds: 30, // Optional, default 30s
264
+ });
265
+
266
+ console.log(result.status); // 'ready'
267
+ ```
268
+
269
+ Use this right before `tel:` navigation so inbound call handling can perform strict 1:1 consume.
270
+
271
+ ### `client.setWebZip(zipCode, options?)`
272
+
273
+ Store visitor-selected ZIP in the realtime profile layer for immediate call-side enrichment.
274
+
275
+ ```typescript
276
+ const result = await client.setWebZip('30309', {
277
+ source: 'manual', // or 'suggested'
278
+ ttlSeconds: 3600, // Optional
279
+ });
280
+
281
+ if (result.status === 'ready') {
282
+ console.log(result.profile.webZipCode);
283
+ }
284
+ ```
285
+
286
+ Behavior:
287
+ - Rejects invalid ZIP values unless they are 5 digits.
288
+ - Writes profile data keyed by `sessionId`.
289
+ - Also mirrors `webZip` and `webZipSource` into queued params for subsequent session refreshes.
290
+
207
291
  ### `client.onReady(callback)`
208
292
 
209
293
  Subscribe to session ready event. Callback is called once session data is available.
@@ -282,6 +366,7 @@ Parameters are sent as a sorted query string for cache consistency:
282
366
 
283
367
  - Session cache key: `cf_tracking_v1_<siteKey>_<categoryId>`
284
368
  - Location cache key: `cf_location_v1_<siteKey>`
369
+ - Bootstrap cache key: `cf_bootstrap_v1_<siteKey>_<categoryId>`
285
370
  - TTL: controlled by the server `expiresAt` response (currently 30 minutes)
286
371
  - Storage: localStorage (falls back to memory if unavailable)
287
372
 
@@ -314,6 +399,9 @@ import type {
314
399
  ReadyCallback,
315
400
  LocationReadyCallback,
316
401
  CallIntentResponse,
402
+ CallLinkIntentResponse,
403
+ RealtimeProfileResponse,
404
+ RealtimeProfileSource,
317
405
  } from '@callforge/tracking-client';
318
406
  ```
319
407
 
package/dist/index.d.mts CHANGED
@@ -83,6 +83,27 @@ interface CallIntentResponse {
83
83
  expiresAt: string;
84
84
  attributionVersion: 'v1';
85
85
  }
86
+ interface CallLinkIntentResponse {
87
+ status: 'ready';
88
+ intentId: string;
89
+ sessionId: string;
90
+ categoryId: string;
91
+ phoneNumber: string;
92
+ expiresAt: string;
93
+ }
94
+ type RealtimeProfileSource = 'manual' | 'suggested';
95
+ type RealtimeProfileResponse = {
96
+ status: 'ready';
97
+ profile: {
98
+ sessionId: string;
99
+ webZipCode: string;
100
+ webZipSource: RealtimeProfileSource;
101
+ webZipUpdatedAt: string;
102
+ expiresAt: string;
103
+ };
104
+ } | {
105
+ status: 'unavailable';
106
+ };
86
107
  /**
87
108
  * Callback function for onReady subscription.
88
109
  */
@@ -103,6 +124,8 @@ declare class CallForge {
103
124
  private readonly config;
104
125
  private readonly cache;
105
126
  private readonly locationCache;
127
+ private readonly bootstrapCacheKey;
128
+ private bootstrapMemoryCache;
106
129
  private sessionPromise;
107
130
  private locationPromise;
108
131
  private customParams;
@@ -133,6 +156,21 @@ declare class CallForge {
133
156
  * Create a short-lived call intent token for click/callback deterministic attribution.
134
157
  */
135
158
  createCallIntent(): Promise<CallIntentResponse>;
159
+ /**
160
+ * Create a short-lived realtime call-link intent for a specific dialed number.
161
+ * Use this immediately before opening a `tel:` link.
162
+ */
163
+ linkPhoneCall(input: {
164
+ phoneNumber: string;
165
+ ttlSeconds?: number;
166
+ }): Promise<CallLinkIntentResponse>;
167
+ /**
168
+ * Write visitor-selected web ZIP to the realtime data layer.
169
+ */
170
+ setWebZip(zipCode: string, options?: {
171
+ source?: RealtimeProfileSource;
172
+ ttlSeconds?: number;
173
+ }): Promise<RealtimeProfileResponse>;
136
174
  /**
137
175
  * Subscribe to session ready event.
138
176
  * Callback is called once session data is available.
@@ -174,7 +212,10 @@ declare class CallForge {
174
212
  private getLocationId;
175
213
  private getAutoParams;
176
214
  private fetchFromApi;
215
+ private getBootstrapToken;
177
216
  private fetchLocationFromApi;
217
+ private getCachedBootstrapToken;
218
+ private saveBootstrapToken;
178
219
  private saveToCache;
179
220
  private saveLocationToCache;
180
221
  private toTrackingLocation;
@@ -183,6 +224,7 @@ declare class CallForge {
183
224
  private formatApiResponse;
184
225
  private syncParamsToCallForgeIfPossible;
185
226
  private buildUrl;
227
+ private buildBootstrapUrl;
186
228
  private buildLocationUrl;
187
229
  }
188
230
 
@@ -192,4 +234,4 @@ declare class CallForge {
192
234
  */
193
235
  declare function getPreloadSnippet(config: CallForgeConfig): string;
194
236
 
195
- export { CallForge, type CallForgeConfig, type CallIntentResponse, type LocationReadyCallback, type ReadyCallback, type TrackingLocation, type TrackingLocationSource, type TrackingParams, type TrackingSession, getPreloadSnippet };
237
+ export { CallForge, type CallForgeConfig, type CallIntentResponse, type CallLinkIntentResponse, type LocationReadyCallback, type ReadyCallback, type RealtimeProfileResponse, type RealtimeProfileSource, type TrackingLocation, type TrackingLocationSource, type TrackingParams, type TrackingSession, getPreloadSnippet };
package/dist/index.d.ts CHANGED
@@ -83,6 +83,27 @@ interface CallIntentResponse {
83
83
  expiresAt: string;
84
84
  attributionVersion: 'v1';
85
85
  }
86
+ interface CallLinkIntentResponse {
87
+ status: 'ready';
88
+ intentId: string;
89
+ sessionId: string;
90
+ categoryId: string;
91
+ phoneNumber: string;
92
+ expiresAt: string;
93
+ }
94
+ type RealtimeProfileSource = 'manual' | 'suggested';
95
+ type RealtimeProfileResponse = {
96
+ status: 'ready';
97
+ profile: {
98
+ sessionId: string;
99
+ webZipCode: string;
100
+ webZipSource: RealtimeProfileSource;
101
+ webZipUpdatedAt: string;
102
+ expiresAt: string;
103
+ };
104
+ } | {
105
+ status: 'unavailable';
106
+ };
86
107
  /**
87
108
  * Callback function for onReady subscription.
88
109
  */
@@ -103,6 +124,8 @@ declare class CallForge {
103
124
  private readonly config;
104
125
  private readonly cache;
105
126
  private readonly locationCache;
127
+ private readonly bootstrapCacheKey;
128
+ private bootstrapMemoryCache;
106
129
  private sessionPromise;
107
130
  private locationPromise;
108
131
  private customParams;
@@ -133,6 +156,21 @@ declare class CallForge {
133
156
  * Create a short-lived call intent token for click/callback deterministic attribution.
134
157
  */
135
158
  createCallIntent(): Promise<CallIntentResponse>;
159
+ /**
160
+ * Create a short-lived realtime call-link intent for a specific dialed number.
161
+ * Use this immediately before opening a `tel:` link.
162
+ */
163
+ linkPhoneCall(input: {
164
+ phoneNumber: string;
165
+ ttlSeconds?: number;
166
+ }): Promise<CallLinkIntentResponse>;
167
+ /**
168
+ * Write visitor-selected web ZIP to the realtime data layer.
169
+ */
170
+ setWebZip(zipCode: string, options?: {
171
+ source?: RealtimeProfileSource;
172
+ ttlSeconds?: number;
173
+ }): Promise<RealtimeProfileResponse>;
136
174
  /**
137
175
  * Subscribe to session ready event.
138
176
  * Callback is called once session data is available.
@@ -174,7 +212,10 @@ declare class CallForge {
174
212
  private getLocationId;
175
213
  private getAutoParams;
176
214
  private fetchFromApi;
215
+ private getBootstrapToken;
177
216
  private fetchLocationFromApi;
217
+ private getCachedBootstrapToken;
218
+ private saveBootstrapToken;
178
219
  private saveToCache;
179
220
  private saveLocationToCache;
180
221
  private toTrackingLocation;
@@ -183,6 +224,7 @@ declare class CallForge {
183
224
  private formatApiResponse;
184
225
  private syncParamsToCallForgeIfPossible;
185
226
  private buildUrl;
227
+ private buildBootstrapUrl;
186
228
  private buildLocationUrl;
187
229
  }
188
230
 
@@ -192,4 +234,4 @@ declare class CallForge {
192
234
  */
193
235
  declare function getPreloadSnippet(config: CallForgeConfig): string;
194
236
 
195
- export { CallForge, type CallForgeConfig, type CallIntentResponse, type LocationReadyCallback, type ReadyCallback, type TrackingLocation, type TrackingLocationSource, type TrackingParams, type TrackingSession, getPreloadSnippet };
237
+ export { CallForge, type CallForgeConfig, type CallIntentResponse, type CallLinkIntentResponse, type LocationReadyCallback, type ReadyCallback, type RealtimeProfileResponse, type RealtimeProfileSource, type TrackingLocation, type TrackingLocationSource, type TrackingParams, type TrackingSession, getPreloadSnippet };
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  var __defProp = Object.defineProperty;
3
+ var __defProps = Object.defineProperties;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
4
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
7
  var __getOwnPropSymbols = Object.getOwnPropertySymbols;
6
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
@@ -17,6 +19,7 @@ var __spreadValues = (a, b) => {
17
19
  }
18
20
  return a;
19
21
  };
22
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
20
23
  var __export = (target, all) => {
21
24
  for (var name in all)
22
25
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -203,9 +206,14 @@ var LocationCache = class {
203
206
  var DEFAULT_ENDPOINT = "https://tracking.callforge.io";
204
207
  var FETCH_TIMEOUT_MS = 1e4;
205
208
  var CALL_INTENT_TIMEOUT_MS = 8e3;
209
+ var REALTIME_CALL_LINK_TIMEOUT_MS = 5e3;
210
+ var REALTIME_PROFILE_TIMEOUT_MS = 5e3;
211
+ var BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS = 1e4;
206
212
  var AUTO_PARAMS = ["gclid", "gbraid", "wbraid", "msclkid", "fbclid", "gad_campaignid", "gad_source"];
213
+ var ZIP_CODE_PATTERN = /^\d{5}$/;
207
214
  var CallForge = class _CallForge {
208
215
  constructor(config) {
216
+ this.bootstrapMemoryCache = null;
209
217
  this.sessionPromise = null;
210
218
  this.locationPromise = null;
211
219
  this.customParams = {};
@@ -215,8 +223,10 @@ var CallForge = class _CallForge {
215
223
  ga4MeasurementId: config.ga4MeasurementId,
216
224
  siteKey: config.siteKey
217
225
  };
226
+ const resolvedSiteKey = config.siteKey || (typeof window !== "undefined" ? window.location.hostname : "unknown-site");
218
227
  this.cache = new TrackingCache(config.categoryId, config.siteKey);
219
228
  this.locationCache = new LocationCache(config.siteKey);
229
+ this.bootstrapCacheKey = `cf_bootstrap_v1_${resolvedSiteKey}_${config.categoryId}`;
220
230
  this.captureGA4ClientId();
221
231
  this.startGA4ClientIdPolling();
222
232
  }
@@ -285,6 +295,77 @@ var CallForge = class _CallForge {
285
295
  clearTimeout(timeoutId);
286
296
  }
287
297
  }
298
+ /**
299
+ * Create a short-lived realtime call-link intent for a specific dialed number.
300
+ * Use this immediately before opening a `tel:` link.
301
+ */
302
+ async linkPhoneCall(input) {
303
+ const session = await this.getSession();
304
+ const controller = new AbortController();
305
+ const timeoutId = setTimeout(() => controller.abort(), REALTIME_CALL_LINK_TIMEOUT_MS);
306
+ try {
307
+ const response = await fetch(`${this.config.endpoint}/v1/tracking/call-link-intent`, {
308
+ method: "POST",
309
+ headers: {
310
+ "Content-Type": "application/json"
311
+ },
312
+ credentials: "omit",
313
+ signal: controller.signal,
314
+ body: JSON.stringify({
315
+ sessionToken: session.sessionToken,
316
+ phoneNumber: input.phoneNumber,
317
+ ttlSeconds: input.ttlSeconds
318
+ })
319
+ });
320
+ if (!response.ok) {
321
+ throw new Error(`Realtime call-link API error: ${response.status} ${response.statusText}`);
322
+ }
323
+ return await response.json();
324
+ } finally {
325
+ clearTimeout(timeoutId);
326
+ }
327
+ }
328
+ /**
329
+ * Write visitor-selected web ZIP to the realtime data layer.
330
+ */
331
+ async setWebZip(zipCode, options) {
332
+ const normalizedZip = zipCode.trim();
333
+ if (!ZIP_CODE_PATTERN.test(normalizedZip)) {
334
+ throw new Error("Invalid ZIP code. Expected a 5-digit ZIP.");
335
+ }
336
+ const session = await this.getSession();
337
+ const controller = new AbortController();
338
+ const timeoutId = setTimeout(() => controller.abort(), REALTIME_PROFILE_TIMEOUT_MS);
339
+ try {
340
+ const response = await fetch(`${this.config.endpoint}/v1/tracking/realtime-profile`, {
341
+ method: "POST",
342
+ headers: {
343
+ "Content-Type": "application/json"
344
+ },
345
+ credentials: "omit",
346
+ signal: controller.signal,
347
+ body: JSON.stringify({
348
+ sessionToken: session.sessionToken,
349
+ webZip: normalizedZip,
350
+ source: options == null ? void 0 : options.source,
351
+ ttlSeconds: options == null ? void 0 : options.ttlSeconds
352
+ })
353
+ });
354
+ if (!response.ok) {
355
+ throw new Error(`Realtime profile API error: ${response.status} ${response.statusText}`);
356
+ }
357
+ const result = await response.json();
358
+ if (result.status === "ready") {
359
+ this.customParams = __spreadProps(__spreadValues({}, this.customParams), {
360
+ webZip: result.profile.webZipCode,
361
+ webZipSource: result.profile.webZipSource
362
+ });
363
+ }
364
+ return result;
365
+ } finally {
366
+ clearTimeout(timeoutId);
367
+ }
368
+ }
288
369
  /**
289
370
  * Subscribe to session ready event.
290
371
  * Callback is called once session data is available.
@@ -464,7 +545,43 @@ var CallForge = class _CallForge {
464
545
  return params;
465
546
  }
466
547
  async fetchFromApi(locationId, sessionToken, params) {
467
- const url = this.buildUrl(locationId, sessionToken, params);
548
+ const controller = new AbortController();
549
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
550
+ try {
551
+ let bootstrapToken = this.getCachedBootstrapToken();
552
+ let response = await fetch(
553
+ this.buildUrl(locationId, sessionToken, params, bootstrapToken),
554
+ {
555
+ credentials: "omit",
556
+ signal: controller.signal
557
+ }
558
+ );
559
+ if (response.status === 401) {
560
+ bootstrapToken = await this.getBootstrapToken(true);
561
+ if (bootstrapToken) {
562
+ response = await fetch(
563
+ this.buildUrl(locationId, sessionToken, params, bootstrapToken),
564
+ {
565
+ credentials: "omit",
566
+ signal: controller.signal
567
+ }
568
+ );
569
+ }
570
+ }
571
+ if (!response.ok) {
572
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
573
+ }
574
+ return await response.json();
575
+ } finally {
576
+ clearTimeout(timeoutId);
577
+ }
578
+ }
579
+ async getBootstrapToken(forceRefresh = false) {
580
+ const cached = this.getCachedBootstrapToken();
581
+ if (!forceRefresh && cached) {
582
+ return cached;
583
+ }
584
+ const url = this.buildBootstrapUrl();
468
585
  const controller = new AbortController();
469
586
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
470
587
  try {
@@ -473,9 +590,14 @@ var CallForge = class _CallForge {
473
590
  signal: controller.signal
474
591
  });
475
592
  if (!response.ok) {
476
- throw new Error(`API error: ${response.status} ${response.statusText}`);
593
+ return null;
477
594
  }
478
- return await response.json();
595
+ const data = await response.json();
596
+ if (typeof data.bootstrapToken !== "string" || typeof data.expiresAt !== "number") {
597
+ return null;
598
+ }
599
+ this.saveBootstrapToken(data.bootstrapToken, data.expiresAt);
600
+ return data.bootstrapToken;
479
601
  } finally {
480
602
  clearTimeout(timeoutId);
481
603
  }
@@ -497,6 +619,40 @@ var CallForge = class _CallForge {
497
619
  clearTimeout(timeoutId);
498
620
  }
499
621
  }
622
+ getCachedBootstrapToken() {
623
+ var _a;
624
+ const now = Date.now();
625
+ const fromMemory = this.bootstrapMemoryCache;
626
+ if (fromMemory && fromMemory.expiresAt - BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS > now) {
627
+ return fromMemory.token;
628
+ }
629
+ try {
630
+ const raw = localStorage.getItem(this.bootstrapCacheKey);
631
+ if (!raw) return null;
632
+ const cached = JSON.parse(raw);
633
+ if (typeof cached.token !== "string" || typeof cached.expiresAt !== "number") {
634
+ return null;
635
+ }
636
+ if (cached.expiresAt - BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS <= now) {
637
+ return null;
638
+ }
639
+ return cached.token;
640
+ } catch (e) {
641
+ return (_a = fromMemory == null ? void 0 : fromMemory.token) != null ? _a : null;
642
+ }
643
+ }
644
+ saveBootstrapToken(token, expiresAt) {
645
+ const cached = {
646
+ token,
647
+ expiresAt,
648
+ tokenVersion: "b1"
649
+ };
650
+ this.bootstrapMemoryCache = cached;
651
+ try {
652
+ localStorage.setItem(this.bootstrapCacheKey, JSON.stringify(cached));
653
+ } catch (e) {
654
+ }
655
+ }
500
656
  saveToCache(locationId, data, params) {
501
657
  const cached = {
502
658
  locId: locationId != null ? locationId : null,
@@ -562,7 +718,7 @@ var CallForge = class _CallForge {
562
718
  const data = await this.fetchFromApi(locationId, sessionToken, params);
563
719
  this.saveToCache(locationId, data, params);
564
720
  }
565
- buildUrl(locationId, sessionToken, params) {
721
+ buildUrl(locationId, sessionToken, params, bootstrapToken) {
566
722
  const { categoryId, endpoint } = this.config;
567
723
  const queryParams = {
568
724
  categoryId
@@ -573,6 +729,9 @@ var CallForge = class _CallForge {
573
729
  if (sessionToken) {
574
730
  queryParams.sessionToken = sessionToken;
575
731
  }
732
+ if (bootstrapToken) {
733
+ queryParams.bootstrapToken = bootstrapToken;
734
+ }
576
735
  for (const [key, value] of Object.entries(params)) {
577
736
  if (value !== void 0) {
578
737
  queryParams[key] = value;
@@ -582,6 +741,10 @@ var CallForge = class _CallForge {
582
741
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
583
742
  return `${endpoint}/v1/tracking/session?${qs}`;
584
743
  }
744
+ buildBootstrapUrl() {
745
+ const { endpoint, categoryId } = this.config;
746
+ return `${endpoint}/v1/tracking/bootstrap?categoryId=${encodeURIComponent(categoryId)}`;
747
+ }
585
748
  buildLocationUrl(locationId) {
586
749
  const { endpoint } = this.config;
587
750
  if (!locationId) {
@@ -619,6 +782,7 @@ for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
619
782
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
620
783
  var key='cf_tracking_v1_'+site+'_${categoryId}';
621
784
  var lkey='cf_location_v1_'+site;
785
+ var bkey='cf_bootstrap_v1_'+site+'_${categoryId}';
622
786
  try{
623
787
  var cl=JSON.parse(localStorage.getItem(lkey));
624
788
  if(cl&&cl.expiresAt>Date.now()+30000){
@@ -638,12 +802,18 @@ token=(!loc||c.locId===loc)?c.sessionToken:null;
638
802
  var cp=c.params||{};
639
803
  p=Object.assign({},cp,p);
640
804
  }}catch(e){}
805
+ var bt=null;
806
+ try{
807
+ var cb=JSON.parse(localStorage.getItem(bkey));
808
+ if(cb&&typeof cb.token==='string'&&cb.expiresAt>Date.now()+10000)bt=cb.token;
809
+ }catch(e){}
810
+ var bp=bt?Promise.resolve({bootstrapToken:bt}):fetch('${endpoint}/v1/tracking/bootstrap?categoryId=${categoryId}',{credentials:'omit'}).then(function(r){if(!r.ok)return null;return r.json()}).then(function(b){if(!b||typeof b.bootstrapToken!=='string'||typeof b.expiresAt!=='number')return null;try{localStorage.setItem(bkey,JSON.stringify({token:b.bootstrapToken,expiresAt:b.expiresAt,tokenVersion:'b1'}))}catch(e){}return b}).catch(function(){return null});
641
811
  var url='${endpoint}/v1/tracking/session?categoryId=${categoryId}';
642
812
  if(loc)url+='&loc_physical_ms='+loc;
643
813
  if(token)url+='&sessionToken='+encodeURIComponent(token);
644
814
  var ks=Object.keys(p).sort();
645
815
  for(var j=0;j<ks.length;j++)url+='&'+ks[j]+'='+encodeURIComponent(p[ks[j]]);
646
- window.__cfTracking=fetch(url,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload failed');return r.json()}).then(function(d){d.params=p;d.locId=loc;try{localStorage.setItem(key,JSON.stringify({locId:loc,sessionToken:d.sessionToken,leaseId:d.leaseId,phoneNumber:d.phoneNumber,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
816
+ window.__cfTracking=bp.then(function(b){if(b&&b.bootstrapToken)url+='&bootstrapToken='+encodeURIComponent(b.bootstrapToken);return fetch(url,{credentials:'omit'})}).then(function(r){if(!r.ok)throw new Error('tracking preload failed');return r.json()}).then(function(d){d.params=p;d.locId=loc;try{localStorage.setItem(key,JSON.stringify({locId:loc,sessionToken:d.sessionToken,leaseId:d.leaseId,phoneNumber:d.phoneNumber,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
647
817
  })();`.replace(/\n/g, "");
648
818
  return `<link rel="preconnect" href="${endpoint}">
649
819
  <script>${script}</script>`;
package/dist/index.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  var __defProp = Object.defineProperty;
2
+ var __defProps = Object.defineProperties;
3
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
2
4
  var __getOwnPropSymbols = Object.getOwnPropertySymbols;
3
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
4
6
  var __propIsEnum = Object.prototype.propertyIsEnumerable;
@@ -14,6 +16,7 @@ var __spreadValues = (a, b) => {
14
16
  }
15
17
  return a;
16
18
  };
19
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
17
20
 
18
21
  // src/cache.ts
19
22
  var EXPIRY_BUFFER_MS = 3e4;
@@ -179,9 +182,14 @@ var LocationCache = class {
179
182
  var DEFAULT_ENDPOINT = "https://tracking.callforge.io";
180
183
  var FETCH_TIMEOUT_MS = 1e4;
181
184
  var CALL_INTENT_TIMEOUT_MS = 8e3;
185
+ var REALTIME_CALL_LINK_TIMEOUT_MS = 5e3;
186
+ var REALTIME_PROFILE_TIMEOUT_MS = 5e3;
187
+ var BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS = 1e4;
182
188
  var AUTO_PARAMS = ["gclid", "gbraid", "wbraid", "msclkid", "fbclid", "gad_campaignid", "gad_source"];
189
+ var ZIP_CODE_PATTERN = /^\d{5}$/;
183
190
  var CallForge = class _CallForge {
184
191
  constructor(config) {
192
+ this.bootstrapMemoryCache = null;
185
193
  this.sessionPromise = null;
186
194
  this.locationPromise = null;
187
195
  this.customParams = {};
@@ -191,8 +199,10 @@ var CallForge = class _CallForge {
191
199
  ga4MeasurementId: config.ga4MeasurementId,
192
200
  siteKey: config.siteKey
193
201
  };
202
+ const resolvedSiteKey = config.siteKey || (typeof window !== "undefined" ? window.location.hostname : "unknown-site");
194
203
  this.cache = new TrackingCache(config.categoryId, config.siteKey);
195
204
  this.locationCache = new LocationCache(config.siteKey);
205
+ this.bootstrapCacheKey = `cf_bootstrap_v1_${resolvedSiteKey}_${config.categoryId}`;
196
206
  this.captureGA4ClientId();
197
207
  this.startGA4ClientIdPolling();
198
208
  }
@@ -261,6 +271,77 @@ var CallForge = class _CallForge {
261
271
  clearTimeout(timeoutId);
262
272
  }
263
273
  }
274
+ /**
275
+ * Create a short-lived realtime call-link intent for a specific dialed number.
276
+ * Use this immediately before opening a `tel:` link.
277
+ */
278
+ async linkPhoneCall(input) {
279
+ const session = await this.getSession();
280
+ const controller = new AbortController();
281
+ const timeoutId = setTimeout(() => controller.abort(), REALTIME_CALL_LINK_TIMEOUT_MS);
282
+ try {
283
+ const response = await fetch(`${this.config.endpoint}/v1/tracking/call-link-intent`, {
284
+ method: "POST",
285
+ headers: {
286
+ "Content-Type": "application/json"
287
+ },
288
+ credentials: "omit",
289
+ signal: controller.signal,
290
+ body: JSON.stringify({
291
+ sessionToken: session.sessionToken,
292
+ phoneNumber: input.phoneNumber,
293
+ ttlSeconds: input.ttlSeconds
294
+ })
295
+ });
296
+ if (!response.ok) {
297
+ throw new Error(`Realtime call-link API error: ${response.status} ${response.statusText}`);
298
+ }
299
+ return await response.json();
300
+ } finally {
301
+ clearTimeout(timeoutId);
302
+ }
303
+ }
304
+ /**
305
+ * Write visitor-selected web ZIP to the realtime data layer.
306
+ */
307
+ async setWebZip(zipCode, options) {
308
+ const normalizedZip = zipCode.trim();
309
+ if (!ZIP_CODE_PATTERN.test(normalizedZip)) {
310
+ throw new Error("Invalid ZIP code. Expected a 5-digit ZIP.");
311
+ }
312
+ const session = await this.getSession();
313
+ const controller = new AbortController();
314
+ const timeoutId = setTimeout(() => controller.abort(), REALTIME_PROFILE_TIMEOUT_MS);
315
+ try {
316
+ const response = await fetch(`${this.config.endpoint}/v1/tracking/realtime-profile`, {
317
+ method: "POST",
318
+ headers: {
319
+ "Content-Type": "application/json"
320
+ },
321
+ credentials: "omit",
322
+ signal: controller.signal,
323
+ body: JSON.stringify({
324
+ sessionToken: session.sessionToken,
325
+ webZip: normalizedZip,
326
+ source: options == null ? void 0 : options.source,
327
+ ttlSeconds: options == null ? void 0 : options.ttlSeconds
328
+ })
329
+ });
330
+ if (!response.ok) {
331
+ throw new Error(`Realtime profile API error: ${response.status} ${response.statusText}`);
332
+ }
333
+ const result = await response.json();
334
+ if (result.status === "ready") {
335
+ this.customParams = __spreadProps(__spreadValues({}, this.customParams), {
336
+ webZip: result.profile.webZipCode,
337
+ webZipSource: result.profile.webZipSource
338
+ });
339
+ }
340
+ return result;
341
+ } finally {
342
+ clearTimeout(timeoutId);
343
+ }
344
+ }
264
345
  /**
265
346
  * Subscribe to session ready event.
266
347
  * Callback is called once session data is available.
@@ -440,7 +521,43 @@ var CallForge = class _CallForge {
440
521
  return params;
441
522
  }
442
523
  async fetchFromApi(locationId, sessionToken, params) {
443
- const url = this.buildUrl(locationId, sessionToken, params);
524
+ const controller = new AbortController();
525
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
526
+ try {
527
+ let bootstrapToken = this.getCachedBootstrapToken();
528
+ let response = await fetch(
529
+ this.buildUrl(locationId, sessionToken, params, bootstrapToken),
530
+ {
531
+ credentials: "omit",
532
+ signal: controller.signal
533
+ }
534
+ );
535
+ if (response.status === 401) {
536
+ bootstrapToken = await this.getBootstrapToken(true);
537
+ if (bootstrapToken) {
538
+ response = await fetch(
539
+ this.buildUrl(locationId, sessionToken, params, bootstrapToken),
540
+ {
541
+ credentials: "omit",
542
+ signal: controller.signal
543
+ }
544
+ );
545
+ }
546
+ }
547
+ if (!response.ok) {
548
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
549
+ }
550
+ return await response.json();
551
+ } finally {
552
+ clearTimeout(timeoutId);
553
+ }
554
+ }
555
+ async getBootstrapToken(forceRefresh = false) {
556
+ const cached = this.getCachedBootstrapToken();
557
+ if (!forceRefresh && cached) {
558
+ return cached;
559
+ }
560
+ const url = this.buildBootstrapUrl();
444
561
  const controller = new AbortController();
445
562
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
446
563
  try {
@@ -449,9 +566,14 @@ var CallForge = class _CallForge {
449
566
  signal: controller.signal
450
567
  });
451
568
  if (!response.ok) {
452
- throw new Error(`API error: ${response.status} ${response.statusText}`);
569
+ return null;
453
570
  }
454
- return await response.json();
571
+ const data = await response.json();
572
+ if (typeof data.bootstrapToken !== "string" || typeof data.expiresAt !== "number") {
573
+ return null;
574
+ }
575
+ this.saveBootstrapToken(data.bootstrapToken, data.expiresAt);
576
+ return data.bootstrapToken;
455
577
  } finally {
456
578
  clearTimeout(timeoutId);
457
579
  }
@@ -473,6 +595,40 @@ var CallForge = class _CallForge {
473
595
  clearTimeout(timeoutId);
474
596
  }
475
597
  }
598
+ getCachedBootstrapToken() {
599
+ var _a;
600
+ const now = Date.now();
601
+ const fromMemory = this.bootstrapMemoryCache;
602
+ if (fromMemory && fromMemory.expiresAt - BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS > now) {
603
+ return fromMemory.token;
604
+ }
605
+ try {
606
+ const raw = localStorage.getItem(this.bootstrapCacheKey);
607
+ if (!raw) return null;
608
+ const cached = JSON.parse(raw);
609
+ if (typeof cached.token !== "string" || typeof cached.expiresAt !== "number") {
610
+ return null;
611
+ }
612
+ if (cached.expiresAt - BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS <= now) {
613
+ return null;
614
+ }
615
+ return cached.token;
616
+ } catch (e) {
617
+ return (_a = fromMemory == null ? void 0 : fromMemory.token) != null ? _a : null;
618
+ }
619
+ }
620
+ saveBootstrapToken(token, expiresAt) {
621
+ const cached = {
622
+ token,
623
+ expiresAt,
624
+ tokenVersion: "b1"
625
+ };
626
+ this.bootstrapMemoryCache = cached;
627
+ try {
628
+ localStorage.setItem(this.bootstrapCacheKey, JSON.stringify(cached));
629
+ } catch (e) {
630
+ }
631
+ }
476
632
  saveToCache(locationId, data, params) {
477
633
  const cached = {
478
634
  locId: locationId != null ? locationId : null,
@@ -538,7 +694,7 @@ var CallForge = class _CallForge {
538
694
  const data = await this.fetchFromApi(locationId, sessionToken, params);
539
695
  this.saveToCache(locationId, data, params);
540
696
  }
541
- buildUrl(locationId, sessionToken, params) {
697
+ buildUrl(locationId, sessionToken, params, bootstrapToken) {
542
698
  const { categoryId, endpoint } = this.config;
543
699
  const queryParams = {
544
700
  categoryId
@@ -549,6 +705,9 @@ var CallForge = class _CallForge {
549
705
  if (sessionToken) {
550
706
  queryParams.sessionToken = sessionToken;
551
707
  }
708
+ if (bootstrapToken) {
709
+ queryParams.bootstrapToken = bootstrapToken;
710
+ }
552
711
  for (const [key, value] of Object.entries(params)) {
553
712
  if (value !== void 0) {
554
713
  queryParams[key] = value;
@@ -558,6 +717,10 @@ var CallForge = class _CallForge {
558
717
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
559
718
  return `${endpoint}/v1/tracking/session?${qs}`;
560
719
  }
720
+ buildBootstrapUrl() {
721
+ const { endpoint, categoryId } = this.config;
722
+ return `${endpoint}/v1/tracking/bootstrap?categoryId=${encodeURIComponent(categoryId)}`;
723
+ }
561
724
  buildLocationUrl(locationId) {
562
725
  const { endpoint } = this.config;
563
726
  if (!locationId) {
@@ -595,6 +758,7 @@ for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
595
758
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
596
759
  var key='cf_tracking_v1_'+site+'_${categoryId}';
597
760
  var lkey='cf_location_v1_'+site;
761
+ var bkey='cf_bootstrap_v1_'+site+'_${categoryId}';
598
762
  try{
599
763
  var cl=JSON.parse(localStorage.getItem(lkey));
600
764
  if(cl&&cl.expiresAt>Date.now()+30000){
@@ -614,12 +778,18 @@ token=(!loc||c.locId===loc)?c.sessionToken:null;
614
778
  var cp=c.params||{};
615
779
  p=Object.assign({},cp,p);
616
780
  }}catch(e){}
781
+ var bt=null;
782
+ try{
783
+ var cb=JSON.parse(localStorage.getItem(bkey));
784
+ if(cb&&typeof cb.token==='string'&&cb.expiresAt>Date.now()+10000)bt=cb.token;
785
+ }catch(e){}
786
+ var bp=bt?Promise.resolve({bootstrapToken:bt}):fetch('${endpoint}/v1/tracking/bootstrap?categoryId=${categoryId}',{credentials:'omit'}).then(function(r){if(!r.ok)return null;return r.json()}).then(function(b){if(!b||typeof b.bootstrapToken!=='string'||typeof b.expiresAt!=='number')return null;try{localStorage.setItem(bkey,JSON.stringify({token:b.bootstrapToken,expiresAt:b.expiresAt,tokenVersion:'b1'}))}catch(e){}return b}).catch(function(){return null});
617
787
  var url='${endpoint}/v1/tracking/session?categoryId=${categoryId}';
618
788
  if(loc)url+='&loc_physical_ms='+loc;
619
789
  if(token)url+='&sessionToken='+encodeURIComponent(token);
620
790
  var ks=Object.keys(p).sort();
621
791
  for(var j=0;j<ks.length;j++)url+='&'+ks[j]+'='+encodeURIComponent(p[ks[j]]);
622
- window.__cfTracking=fetch(url,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload failed');return r.json()}).then(function(d){d.params=p;d.locId=loc;try{localStorage.setItem(key,JSON.stringify({locId:loc,sessionToken:d.sessionToken,leaseId:d.leaseId,phoneNumber:d.phoneNumber,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
792
+ window.__cfTracking=bp.then(function(b){if(b&&b.bootstrapToken)url+='&bootstrapToken='+encodeURIComponent(b.bootstrapToken);return fetch(url,{credentials:'omit'})}).then(function(r){if(!r.ok)throw new Error('tracking preload failed');return r.json()}).then(function(d){d.params=p;d.locId=loc;try{localStorage.setItem(key,JSON.stringify({locId:loc,sessionToken:d.sessionToken,leaseId:d.leaseId,phoneNumber:d.phoneNumber,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
623
793
  })();`.replace(/\n/g, "");
624
794
  return `<link rel="preconnect" href="${endpoint}">
625
795
  <script>${script}</script>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callforge/tracking-client",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",