@callforge/tracking-client 0.6.3 → 0.7.1

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
@@ -30,7 +30,7 @@ Generated HTML:
30
30
  <script>/* preload script */</script>
31
31
  ```
32
32
 
33
- ### 2. Initialize and fetch a tracking session
33
+ ### 2. Initialize and start session + location requests
34
34
 
35
35
  ```typescript
36
36
  import { CallForge } from '@callforge/tracking-client';
@@ -40,9 +40,19 @@ const client = CallForge.init({
40
40
  // endpoint: 'https://tracking-dev.callforge.io', // Optional: override for dev
41
41
  });
42
42
 
43
- const session = await client.getSession();
44
- console.log(session.phoneNumber); // "+17705550000" or null
45
- console.log(session.location); // { city: "Woodstock", state: "Georgia", stateCode: "GA" } or null
43
+ const { session, location } = client.getSessionAsync();
44
+
45
+ // Location is delivered independently (often faster than phone number assignment)
46
+ console.log(await location);
47
+ // {
48
+ // city: "Woodstock",
49
+ // state: "Georgia",
50
+ // stateCode: "GA",
51
+ // zipOptions: ["30188", "30189", "30066", ...] // may be []
52
+ // } or null
53
+
54
+ // Phone session data (deterministic token + phone number)
55
+ console.log(await session); // { sessionToken, leaseId, phoneNumber }
46
56
  ```
47
57
 
48
58
  ### 3. Deterministic click/callback attribution (optional)
@@ -134,18 +144,13 @@ interface CallForgeConfig {
134
144
 
135
145
  ### `client.getSession()`
136
146
 
137
- Get tracking session data. Returns cached data if valid, otherwise fetches from the API.
147
+ Get tracking session data (phone number + deterministic session token). Returns cached data if valid, otherwise fetches from the API.
138
148
 
139
149
  ```typescript
140
150
  interface TrackingSession {
141
151
  sessionToken: string; // Signed, opaque token used to refresh the session
142
152
  leaseId: string | null; // Deterministic assignment lease ID (when available)
143
153
  phoneNumber: string | null;
144
- location: {
145
- city: string;
146
- state: string; // Full name: "Georgia"
147
- stateCode: string; // Abbreviation: "GA"
148
- } | null;
149
154
  }
150
155
  ```
151
156
 
@@ -155,6 +160,41 @@ Behavior:
155
160
  - If `loc_physical_ms` is present in the URL, cached sessions are only reused when it matches the cached `locId`.
156
161
  - Throws on network errors or API errors.
157
162
 
163
+ ### `client.getLocation()`
164
+
165
+ Get location data only. Returns cached data if valid, otherwise fetches from the API.
166
+
167
+ ```typescript
168
+ const location = await client.getLocation();
169
+ // { city, state, stateCode, zipOptions } or null
170
+ ```
171
+
172
+ ```typescript
173
+ interface TrackingLocation {
174
+ city: string;
175
+ state: string;
176
+ stateCode: string;
177
+ zipOptions?: string[]; // ordered by proximity, may be []
178
+ }
179
+ ```
180
+
181
+ ### `client.getSessionAsync()`
182
+
183
+ Kick off both requests and use each as soon as it resolves.
184
+
185
+ ```typescript
186
+ const { session, location } = client.getSessionAsync();
187
+
188
+ location.then((loc) => {
189
+ // show city/state ASAP
190
+ // optionally render loc?.zipOptions in a ZIP picker
191
+ });
192
+
193
+ session.then((sess) => {
194
+ // show phone number when ready
195
+ });
196
+ ```
197
+
158
198
  ### `client.createCallIntent()`
159
199
 
160
200
  Create a short-lived call intent token for click/callback deterministic attribution.
@@ -174,6 +214,16 @@ client.onReady((session) => {
174
214
  });
175
215
  ```
176
216
 
217
+ ### `client.onLocationReady(callback)`
218
+
219
+ Subscribe to location ready event. Callback is called once location data is available.
220
+
221
+ ```typescript
222
+ client.onLocationReady((location) => {
223
+ // location is { city, state, stateCode, zipOptions } or null
224
+ });
225
+ ```
226
+
177
227
  ### `client.setParams(params)`
178
228
 
179
229
  Set custom tracking parameters for conversion attribution.
@@ -230,7 +280,8 @@ Parameters are sent as a sorted query string for cache consistency:
230
280
 
231
281
  ## Caching Behavior
232
282
 
233
- - Cache key: `cf_tracking_v1_<siteKey>_<categoryId>`
283
+ - Session cache key: `cf_tracking_v1_<siteKey>_<categoryId>`
284
+ - Location cache key: `cf_location_v1_<siteKey>`
234
285
  - TTL: controlled by the server `expiresAt` response (currently 30 minutes)
235
286
  - Storage: localStorage (falls back to memory if unavailable)
236
287
 
@@ -261,6 +312,7 @@ import type {
261
312
  TrackingLocation,
262
313
  TrackingParams,
263
314
  ReadyCallback,
315
+ LocationReadyCallback,
264
316
  CallIntentResponse,
265
317
  } from '@callforge/tracking-client';
266
318
  ```
package/dist/index.d.mts CHANGED
@@ -21,7 +21,10 @@ interface TrackingLocation {
21
21
  state: string;
22
22
  /** State abbreviation (e.g., "GA") */
23
23
  stateCode: string;
24
+ /** Suggested ZIPs ordered by proximity (may be empty) */
25
+ zipOptions?: string[];
24
26
  }
27
+ type TrackingLocationSource = 'google_criteria' | 'cloudflare_geo';
25
28
  /**
26
29
  * Tracking parameters for attribution (auto-captured + custom).
27
30
  */
@@ -49,21 +52,27 @@ interface TrackingSession {
49
52
  leaseId: string | null;
50
53
  /** Assigned phone number, or null if no number available */
51
54
  phoneNumber: string | null;
52
- /** Location data, or null if location could not be resolved */
53
- location: TrackingLocation | null;
54
55
  }
55
56
  /**
56
- * Internal API response format (includes expiresAt).
57
+ * Session API response format (includes expiresAt).
57
58
  */
58
- interface ApiResponse {
59
+ interface ApiSessionResponse {
59
60
  sessionToken: string;
60
61
  leaseId: string | null;
61
62
  phoneNumber: string | null;
63
+ expiresAt: number;
64
+ }
65
+ /**
66
+ * Location API response format (includes expiresAt).
67
+ */
68
+ interface ApiLocationResponse {
62
69
  location: {
63
70
  city: string;
64
71
  state: string;
65
72
  stateCode: string;
73
+ source: TrackingLocationSource;
66
74
  } | null;
75
+ zipOptions?: string[];
67
76
  expiresAt: number;
68
77
  }
69
78
  interface CallIntentResponse {
@@ -78,12 +87,14 @@ interface CallIntentResponse {
78
87
  * Callback function for onReady subscription.
79
88
  */
80
89
  type ReadyCallback = (session: TrackingSession) => void;
90
+ type LocationReadyCallback = (location: TrackingLocation | null) => void;
81
91
  /**
82
92
  * Global window extension for preload promise and gtag.
83
93
  */
84
94
  declare global {
85
95
  interface Window {
86
- __cfTracking?: Promise<ApiResponse>;
96
+ __cfTracking?: Promise<ApiSessionResponse>;
97
+ __cfTrackingLocation?: Promise<ApiLocationResponse>;
87
98
  gtag?: (command: 'get', targetId: string, fieldName: string, callback: (value: string) => void) => void;
88
99
  }
89
100
  }
@@ -91,7 +102,9 @@ declare global {
91
102
  declare class CallForge {
92
103
  private readonly config;
93
104
  private readonly cache;
105
+ private readonly locationCache;
94
106
  private sessionPromise;
107
+ private locationPromise;
95
108
  private customParams;
96
109
  private constructor();
97
110
  /**
@@ -103,6 +116,19 @@ declare class CallForge {
103
116
  * Returns cached data if valid, otherwise fetches from API.
104
117
  */
105
118
  getSession(): Promise<TrackingSession>;
119
+ /**
120
+ * Get location data only.
121
+ * Returns cached data if valid, otherwise fetches from API.
122
+ */
123
+ getLocation(): Promise<TrackingLocation | null>;
124
+ /**
125
+ * Kick off both session and location requests.
126
+ * Returns separate Promises so consumers can use each as soon as it resolves.
127
+ */
128
+ getSessionAsync(): {
129
+ session: Promise<TrackingSession>;
130
+ location: Promise<TrackingLocation | null>;
131
+ };
106
132
  /**
107
133
  * Create a short-lived call intent token for click/callback deterministic attribution.
108
134
  */
@@ -112,6 +138,11 @@ declare class CallForge {
112
138
  * Callback is called once session data is available.
113
139
  */
114
140
  onReady(callback: ReadyCallback): void;
141
+ /**
142
+ * Subscribe to location ready event.
143
+ * Callback is called once location data is available.
144
+ */
145
+ onLocationReady(callback: LocationReadyCallback): void;
115
146
  /**
116
147
  * Set custom tracking parameters for attribution.
117
148
  *
@@ -139,14 +170,20 @@ declare class CallForge {
139
170
  */
140
171
  private getGA4ClientIdFromCookie;
141
172
  private fetchSession;
173
+ private fetchLocation;
142
174
  private getLocationId;
143
175
  private getAutoParams;
144
176
  private fetchFromApi;
177
+ private fetchLocationFromApi;
145
178
  private saveToCache;
179
+ private saveLocationToCache;
180
+ private toTrackingLocation;
181
+ private normalizeZipOptions;
146
182
  private formatSession;
147
183
  private formatApiResponse;
148
184
  private syncParamsToCallForgeIfPossible;
149
185
  private buildUrl;
186
+ private buildLocationUrl;
150
187
  }
151
188
 
152
189
  /**
@@ -155,4 +192,4 @@ declare class CallForge {
155
192
  */
156
193
  declare function getPreloadSnippet(config: CallForgeConfig): string;
157
194
 
158
- export { CallForge, type CallForgeConfig, type CallIntentResponse, type ReadyCallback, type TrackingLocation, type TrackingParams, type TrackingSession, getPreloadSnippet };
195
+ export { CallForge, type CallForgeConfig, type CallIntentResponse, type LocationReadyCallback, type ReadyCallback, type TrackingLocation, type TrackingLocationSource, type TrackingParams, type TrackingSession, getPreloadSnippet };
package/dist/index.d.ts CHANGED
@@ -21,7 +21,10 @@ interface TrackingLocation {
21
21
  state: string;
22
22
  /** State abbreviation (e.g., "GA") */
23
23
  stateCode: string;
24
+ /** Suggested ZIPs ordered by proximity (may be empty) */
25
+ zipOptions?: string[];
24
26
  }
27
+ type TrackingLocationSource = 'google_criteria' | 'cloudflare_geo';
25
28
  /**
26
29
  * Tracking parameters for attribution (auto-captured + custom).
27
30
  */
@@ -49,21 +52,27 @@ interface TrackingSession {
49
52
  leaseId: string | null;
50
53
  /** Assigned phone number, or null if no number available */
51
54
  phoneNumber: string | null;
52
- /** Location data, or null if location could not be resolved */
53
- location: TrackingLocation | null;
54
55
  }
55
56
  /**
56
- * Internal API response format (includes expiresAt).
57
+ * Session API response format (includes expiresAt).
57
58
  */
58
- interface ApiResponse {
59
+ interface ApiSessionResponse {
59
60
  sessionToken: string;
60
61
  leaseId: string | null;
61
62
  phoneNumber: string | null;
63
+ expiresAt: number;
64
+ }
65
+ /**
66
+ * Location API response format (includes expiresAt).
67
+ */
68
+ interface ApiLocationResponse {
62
69
  location: {
63
70
  city: string;
64
71
  state: string;
65
72
  stateCode: string;
73
+ source: TrackingLocationSource;
66
74
  } | null;
75
+ zipOptions?: string[];
67
76
  expiresAt: number;
68
77
  }
69
78
  interface CallIntentResponse {
@@ -78,12 +87,14 @@ interface CallIntentResponse {
78
87
  * Callback function for onReady subscription.
79
88
  */
80
89
  type ReadyCallback = (session: TrackingSession) => void;
90
+ type LocationReadyCallback = (location: TrackingLocation | null) => void;
81
91
  /**
82
92
  * Global window extension for preload promise and gtag.
83
93
  */
84
94
  declare global {
85
95
  interface Window {
86
- __cfTracking?: Promise<ApiResponse>;
96
+ __cfTracking?: Promise<ApiSessionResponse>;
97
+ __cfTrackingLocation?: Promise<ApiLocationResponse>;
87
98
  gtag?: (command: 'get', targetId: string, fieldName: string, callback: (value: string) => void) => void;
88
99
  }
89
100
  }
@@ -91,7 +102,9 @@ declare global {
91
102
  declare class CallForge {
92
103
  private readonly config;
93
104
  private readonly cache;
105
+ private readonly locationCache;
94
106
  private sessionPromise;
107
+ private locationPromise;
95
108
  private customParams;
96
109
  private constructor();
97
110
  /**
@@ -103,6 +116,19 @@ declare class CallForge {
103
116
  * Returns cached data if valid, otherwise fetches from API.
104
117
  */
105
118
  getSession(): Promise<TrackingSession>;
119
+ /**
120
+ * Get location data only.
121
+ * Returns cached data if valid, otherwise fetches from API.
122
+ */
123
+ getLocation(): Promise<TrackingLocation | null>;
124
+ /**
125
+ * Kick off both session and location requests.
126
+ * Returns separate Promises so consumers can use each as soon as it resolves.
127
+ */
128
+ getSessionAsync(): {
129
+ session: Promise<TrackingSession>;
130
+ location: Promise<TrackingLocation | null>;
131
+ };
106
132
  /**
107
133
  * Create a short-lived call intent token for click/callback deterministic attribution.
108
134
  */
@@ -112,6 +138,11 @@ declare class CallForge {
112
138
  * Callback is called once session data is available.
113
139
  */
114
140
  onReady(callback: ReadyCallback): void;
141
+ /**
142
+ * Subscribe to location ready event.
143
+ * Callback is called once location data is available.
144
+ */
145
+ onLocationReady(callback: LocationReadyCallback): void;
115
146
  /**
116
147
  * Set custom tracking parameters for attribution.
117
148
  *
@@ -139,14 +170,20 @@ declare class CallForge {
139
170
  */
140
171
  private getGA4ClientIdFromCookie;
141
172
  private fetchSession;
173
+ private fetchLocation;
142
174
  private getLocationId;
143
175
  private getAutoParams;
144
176
  private fetchFromApi;
177
+ private fetchLocationFromApi;
145
178
  private saveToCache;
179
+ private saveLocationToCache;
180
+ private toTrackingLocation;
181
+ private normalizeZipOptions;
146
182
  private formatSession;
147
183
  private formatApiResponse;
148
184
  private syncParamsToCallForgeIfPossible;
149
185
  private buildUrl;
186
+ private buildLocationUrl;
150
187
  }
151
188
 
152
189
  /**
@@ -155,4 +192,4 @@ declare class CallForge {
155
192
  */
156
193
  declare function getPreloadSnippet(config: CallForgeConfig): string;
157
194
 
158
- export { CallForge, type CallForgeConfig, type CallIntentResponse, type ReadyCallback, type TrackingLocation, type TrackingParams, type TrackingSession, getPreloadSnippet };
195
+ export { CallForge, type CallForgeConfig, type CallIntentResponse, type LocationReadyCallback, type ReadyCallback, type TrackingLocation, type TrackingLocationSource, type TrackingParams, type TrackingSession, getPreloadSnippet };
package/dist/index.js CHANGED
@@ -138,6 +138,67 @@ var TrackingCache = class {
138
138
  }
139
139
  };
140
140
 
141
+ // src/location-cache.ts
142
+ var EXPIRY_BUFFER_MS2 = 3e4;
143
+ var LocationCache = class {
144
+ constructor(siteKey) {
145
+ this.memoryCache = null;
146
+ this.useMemory = false;
147
+ const resolvedSiteKey = siteKey || (typeof window !== "undefined" ? window.location.hostname : "unknown-site");
148
+ this.key = `cf_location_v1_${resolvedSiteKey}`;
149
+ this.useMemory = !this.isLocalStorageAvailable();
150
+ }
151
+ isLocalStorageAvailable() {
152
+ try {
153
+ localStorage.setItem("__cf_test__", "1");
154
+ localStorage.removeItem("__cf_test__");
155
+ return true;
156
+ } catch (e) {
157
+ return false;
158
+ }
159
+ }
160
+ get(locationId) {
161
+ const cached = this.read();
162
+ if (!cached) return null;
163
+ if (cached.expiresAt - EXPIRY_BUFFER_MS2 <= Date.now()) return null;
164
+ if (!locationId) return cached;
165
+ if (cached.locId !== locationId) return null;
166
+ return cached;
167
+ }
168
+ set(value) {
169
+ if (this.useMemory) {
170
+ this.memoryCache = value;
171
+ return;
172
+ }
173
+ try {
174
+ localStorage.setItem(this.key, JSON.stringify(value));
175
+ } catch (e) {
176
+ this.memoryCache = value;
177
+ }
178
+ }
179
+ clear() {
180
+ this.memoryCache = null;
181
+ if (!this.useMemory) {
182
+ try {
183
+ localStorage.removeItem(this.key);
184
+ } catch (e) {
185
+ }
186
+ }
187
+ }
188
+ read() {
189
+ if (this.useMemory) {
190
+ return this.memoryCache;
191
+ }
192
+ try {
193
+ const raw = localStorage.getItem(this.key);
194
+ if (!raw) return null;
195
+ return JSON.parse(raw);
196
+ } catch (e) {
197
+ return this.memoryCache;
198
+ }
199
+ }
200
+ };
201
+
141
202
  // src/client.ts
142
203
  var DEFAULT_ENDPOINT = "https://tracking.callforge.io";
143
204
  var FETCH_TIMEOUT_MS = 1e4;
@@ -146,6 +207,7 @@ var AUTO_PARAMS = ["gclid", "gbraid", "wbraid", "msclkid", "fbclid", "gad_campai
146
207
  var CallForge = class _CallForge {
147
208
  constructor(config) {
148
209
  this.sessionPromise = null;
210
+ this.locationPromise = null;
149
211
  this.customParams = {};
150
212
  this.config = {
151
213
  categoryId: config.categoryId,
@@ -154,6 +216,7 @@ var CallForge = class _CallForge {
154
216
  siteKey: config.siteKey
155
217
  };
156
218
  this.cache = new TrackingCache(config.categoryId, config.siteKey);
219
+ this.locationCache = new LocationCache(config.siteKey);
157
220
  this.captureGA4ClientId();
158
221
  this.startGA4ClientIdPolling();
159
222
  }
@@ -174,6 +237,27 @@ var CallForge = class _CallForge {
174
237
  this.sessionPromise = this.fetchSession();
175
238
  return this.sessionPromise;
176
239
  }
240
+ /**
241
+ * Get location data only.
242
+ * Returns cached data if valid, otherwise fetches from API.
243
+ */
244
+ async getLocation() {
245
+ if (this.locationPromise) {
246
+ return this.locationPromise;
247
+ }
248
+ this.locationPromise = this.fetchLocation();
249
+ return this.locationPromise;
250
+ }
251
+ /**
252
+ * Kick off both session and location requests.
253
+ * Returns separate Promises so consumers can use each as soon as it resolves.
254
+ */
255
+ getSessionAsync() {
256
+ return {
257
+ session: this.getSession(),
258
+ location: this.getLocation()
259
+ };
260
+ }
177
261
  /**
178
262
  * Create a short-lived call intent token for click/callback deterministic attribution.
179
263
  */
@@ -209,6 +293,14 @@ var CallForge = class _CallForge {
209
293
  this.getSession().then(callback).catch(() => {
210
294
  });
211
295
  }
296
+ /**
297
+ * Subscribe to location ready event.
298
+ * Callback is called once location data is available.
299
+ */
300
+ onLocationReady(callback) {
301
+ this.getLocation().then(callback).catch(() => {
302
+ });
303
+ }
212
304
  /**
213
305
  * Set custom tracking parameters for attribution.
214
306
  *
@@ -331,6 +423,29 @@ var CallForge = class _CallForge {
331
423
  this.saveToCache(locationId, data, params);
332
424
  return this.formatApiResponse(data);
333
425
  }
426
+ async fetchLocation() {
427
+ var _a, _b;
428
+ const locationId = this.getLocationId();
429
+ if (typeof window !== "undefined" && window.__cfTrackingLocation) {
430
+ try {
431
+ const data2 = await window.__cfTrackingLocation;
432
+ const dataWithExtras = data2;
433
+ const effectiveLocId = (_a = dataWithExtras.locId) != null ? _a : locationId;
434
+ const location2 = this.toTrackingLocation(data2.location, dataWithExtras.zipOptions);
435
+ this.saveLocationToCache(effectiveLocId, location2, data2.expiresAt);
436
+ return location2;
437
+ } catch (e) {
438
+ }
439
+ }
440
+ const cached = this.locationCache.get(locationId);
441
+ if (cached) {
442
+ return this.toTrackingLocation(cached.location, (_b = cached.location) == null ? void 0 : _b.zipOptions);
443
+ }
444
+ const data = await this.fetchLocationFromApi(locationId);
445
+ const location = this.toTrackingLocation(data.location, data.zipOptions);
446
+ this.saveLocationToCache(locationId, location, data.expiresAt);
447
+ return location;
448
+ }
334
449
  getLocationId() {
335
450
  if (typeof window === "undefined") return null;
336
451
  const params = new URLSearchParams(window.location.search);
@@ -365,33 +480,76 @@ var CallForge = class _CallForge {
365
480
  clearTimeout(timeoutId);
366
481
  }
367
482
  }
483
+ async fetchLocationFromApi(locationId) {
484
+ const url = this.buildLocationUrl(locationId);
485
+ const controller = new AbortController();
486
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
487
+ try {
488
+ const response = await fetch(url, {
489
+ credentials: "omit",
490
+ signal: controller.signal
491
+ });
492
+ if (!response.ok) {
493
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
494
+ }
495
+ return await response.json();
496
+ } finally {
497
+ clearTimeout(timeoutId);
498
+ }
499
+ }
368
500
  saveToCache(locationId, data, params) {
369
501
  const cached = {
370
502
  locId: locationId != null ? locationId : null,
371
503
  sessionToken: data.sessionToken,
372
504
  leaseId: data.leaseId,
373
505
  phoneNumber: data.phoneNumber,
374
- location: data.location,
375
506
  expiresAt: data.expiresAt,
376
507
  tokenVersion: "v1",
377
508
  params
378
509
  };
379
510
  this.cache.set(cached);
380
511
  }
512
+ saveLocationToCache(locationId, location, expiresAt) {
513
+ this.locationCache.set({
514
+ locId: locationId != null ? locationId : null,
515
+ location,
516
+ expiresAt,
517
+ tokenVersion: "v1"
518
+ });
519
+ }
520
+ toTrackingLocation(location, zipOptions) {
521
+ if (!location) {
522
+ return null;
523
+ }
524
+ return {
525
+ city: location.city,
526
+ state: location.state,
527
+ stateCode: location.stateCode,
528
+ zipOptions: this.normalizeZipOptions(zipOptions)
529
+ };
530
+ }
531
+ normalizeZipOptions(value) {
532
+ if (!Array.isArray(value)) {
533
+ return [];
534
+ }
535
+ return Array.from(
536
+ new Set(
537
+ value.filter((zip) => typeof zip === "string" && /^\d{5}$/.test(zip))
538
+ )
539
+ );
540
+ }
381
541
  formatSession(cached) {
382
542
  return {
383
543
  sessionToken: cached.sessionToken,
384
544
  leaseId: cached.leaseId,
385
- phoneNumber: cached.phoneNumber,
386
- location: cached.location
545
+ phoneNumber: cached.phoneNumber
387
546
  };
388
547
  }
389
548
  formatApiResponse(data) {
390
549
  return {
391
550
  sessionToken: data.sessionToken,
392
551
  leaseId: data.leaseId,
393
- phoneNumber: data.phoneNumber,
394
- location: data.location
552
+ phoneNumber: data.phoneNumber
395
553
  };
396
554
  }
397
555
  async syncParamsToCallForgeIfPossible() {
@@ -424,6 +582,13 @@ var CallForge = class _CallForge {
424
582
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
425
583
  return `${endpoint}/v1/tracking/session?${qs}`;
426
584
  }
585
+ buildLocationUrl(locationId) {
586
+ const { endpoint } = this.config;
587
+ if (!locationId) {
588
+ return `${endpoint}/v1/tracking/location`;
589
+ }
590
+ return `${endpoint}/v1/tracking/location?loc_physical_ms=${encodeURIComponent(locationId)}`;
591
+ }
427
592
  };
428
593
 
429
594
  // src/preload.ts
@@ -453,6 +618,17 @@ if(m){try{var g=decodeURIComponent(m[1]||'');var s=g.split('.');if(s.length>=2){
453
618
  for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
454
619
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
455
620
  var key='cf_tracking_v1_'+site+'_${categoryId}';
621
+ var lkey='cf_location_v1_'+site;
622
+ try{
623
+ var cl=JSON.parse(localStorage.getItem(lkey));
624
+ if(cl&&cl.expiresAt>Date.now()+30000){
625
+ if(!loc||(loc&&cl.locId===loc)){if(cl.location&&!Array.isArray(cl.location.zipOptions))cl.location.zipOptions=[];window.__cfTrackingLocation=Promise.resolve(cl)}
626
+ }}catch(e){}
627
+ if(!window.__cfTrackingLocation){
628
+ var lurl='${endpoint}/v1/tracking/location';
629
+ if(loc)lurl+='?loc_physical_ms='+encodeURIComponent(loc);
630
+ window.__cfTrackingLocation=fetch(lurl,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload location failed');return r.json()}).then(function(d){var z=Array.isArray(d.zipOptions)?d.zipOptions.filter(function(x){return /^\\d{5}$/.test(x)}):[];d.locId=loc;d.zipOptions=z;try{localStorage.setItem(lkey,JSON.stringify({locId:loc,location:d.location?{city:d.location.city,state:d.location.state,stateCode:d.location.stateCode,zipOptions:z}:null,expiresAt:d.expiresAt,tokenVersion:'v1'}))}catch(e){}return d});
631
+ }
456
632
  var token=null;
457
633
  try{
458
634
  var c=JSON.parse(localStorage.getItem(key));
@@ -467,7 +643,7 @@ if(loc)url+='&loc_physical_ms='+loc;
467
643
  if(token)url+='&sessionToken='+encodeURIComponent(token);
468
644
  var ks=Object.keys(p).sort();
469
645
  for(var j=0;j<ks.length;j++)url+='&'+ks[j]+'='+encodeURIComponent(p[ks[j]]);
470
- 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,location:d.location,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
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});
471
647
  })();`.replace(/\n/g, "");
472
648
  return `<link rel="preconnect" href="${endpoint}">
473
649
  <script>${script}</script>`;
package/dist/index.mjs CHANGED
@@ -114,6 +114,67 @@ var TrackingCache = class {
114
114
  }
115
115
  };
116
116
 
117
+ // src/location-cache.ts
118
+ var EXPIRY_BUFFER_MS2 = 3e4;
119
+ var LocationCache = class {
120
+ constructor(siteKey) {
121
+ this.memoryCache = null;
122
+ this.useMemory = false;
123
+ const resolvedSiteKey = siteKey || (typeof window !== "undefined" ? window.location.hostname : "unknown-site");
124
+ this.key = `cf_location_v1_${resolvedSiteKey}`;
125
+ this.useMemory = !this.isLocalStorageAvailable();
126
+ }
127
+ isLocalStorageAvailable() {
128
+ try {
129
+ localStorage.setItem("__cf_test__", "1");
130
+ localStorage.removeItem("__cf_test__");
131
+ return true;
132
+ } catch (e) {
133
+ return false;
134
+ }
135
+ }
136
+ get(locationId) {
137
+ const cached = this.read();
138
+ if (!cached) return null;
139
+ if (cached.expiresAt - EXPIRY_BUFFER_MS2 <= Date.now()) return null;
140
+ if (!locationId) return cached;
141
+ if (cached.locId !== locationId) return null;
142
+ return cached;
143
+ }
144
+ set(value) {
145
+ if (this.useMemory) {
146
+ this.memoryCache = value;
147
+ return;
148
+ }
149
+ try {
150
+ localStorage.setItem(this.key, JSON.stringify(value));
151
+ } catch (e) {
152
+ this.memoryCache = value;
153
+ }
154
+ }
155
+ clear() {
156
+ this.memoryCache = null;
157
+ if (!this.useMemory) {
158
+ try {
159
+ localStorage.removeItem(this.key);
160
+ } catch (e) {
161
+ }
162
+ }
163
+ }
164
+ read() {
165
+ if (this.useMemory) {
166
+ return this.memoryCache;
167
+ }
168
+ try {
169
+ const raw = localStorage.getItem(this.key);
170
+ if (!raw) return null;
171
+ return JSON.parse(raw);
172
+ } catch (e) {
173
+ return this.memoryCache;
174
+ }
175
+ }
176
+ };
177
+
117
178
  // src/client.ts
118
179
  var DEFAULT_ENDPOINT = "https://tracking.callforge.io";
119
180
  var FETCH_TIMEOUT_MS = 1e4;
@@ -122,6 +183,7 @@ var AUTO_PARAMS = ["gclid", "gbraid", "wbraid", "msclkid", "fbclid", "gad_campai
122
183
  var CallForge = class _CallForge {
123
184
  constructor(config) {
124
185
  this.sessionPromise = null;
186
+ this.locationPromise = null;
125
187
  this.customParams = {};
126
188
  this.config = {
127
189
  categoryId: config.categoryId,
@@ -130,6 +192,7 @@ var CallForge = class _CallForge {
130
192
  siteKey: config.siteKey
131
193
  };
132
194
  this.cache = new TrackingCache(config.categoryId, config.siteKey);
195
+ this.locationCache = new LocationCache(config.siteKey);
133
196
  this.captureGA4ClientId();
134
197
  this.startGA4ClientIdPolling();
135
198
  }
@@ -150,6 +213,27 @@ var CallForge = class _CallForge {
150
213
  this.sessionPromise = this.fetchSession();
151
214
  return this.sessionPromise;
152
215
  }
216
+ /**
217
+ * Get location data only.
218
+ * Returns cached data if valid, otherwise fetches from API.
219
+ */
220
+ async getLocation() {
221
+ if (this.locationPromise) {
222
+ return this.locationPromise;
223
+ }
224
+ this.locationPromise = this.fetchLocation();
225
+ return this.locationPromise;
226
+ }
227
+ /**
228
+ * Kick off both session and location requests.
229
+ * Returns separate Promises so consumers can use each as soon as it resolves.
230
+ */
231
+ getSessionAsync() {
232
+ return {
233
+ session: this.getSession(),
234
+ location: this.getLocation()
235
+ };
236
+ }
153
237
  /**
154
238
  * Create a short-lived call intent token for click/callback deterministic attribution.
155
239
  */
@@ -185,6 +269,14 @@ var CallForge = class _CallForge {
185
269
  this.getSession().then(callback).catch(() => {
186
270
  });
187
271
  }
272
+ /**
273
+ * Subscribe to location ready event.
274
+ * Callback is called once location data is available.
275
+ */
276
+ onLocationReady(callback) {
277
+ this.getLocation().then(callback).catch(() => {
278
+ });
279
+ }
188
280
  /**
189
281
  * Set custom tracking parameters for attribution.
190
282
  *
@@ -307,6 +399,29 @@ var CallForge = class _CallForge {
307
399
  this.saveToCache(locationId, data, params);
308
400
  return this.formatApiResponse(data);
309
401
  }
402
+ async fetchLocation() {
403
+ var _a, _b;
404
+ const locationId = this.getLocationId();
405
+ if (typeof window !== "undefined" && window.__cfTrackingLocation) {
406
+ try {
407
+ const data2 = await window.__cfTrackingLocation;
408
+ const dataWithExtras = data2;
409
+ const effectiveLocId = (_a = dataWithExtras.locId) != null ? _a : locationId;
410
+ const location2 = this.toTrackingLocation(data2.location, dataWithExtras.zipOptions);
411
+ this.saveLocationToCache(effectiveLocId, location2, data2.expiresAt);
412
+ return location2;
413
+ } catch (e) {
414
+ }
415
+ }
416
+ const cached = this.locationCache.get(locationId);
417
+ if (cached) {
418
+ return this.toTrackingLocation(cached.location, (_b = cached.location) == null ? void 0 : _b.zipOptions);
419
+ }
420
+ const data = await this.fetchLocationFromApi(locationId);
421
+ const location = this.toTrackingLocation(data.location, data.zipOptions);
422
+ this.saveLocationToCache(locationId, location, data.expiresAt);
423
+ return location;
424
+ }
310
425
  getLocationId() {
311
426
  if (typeof window === "undefined") return null;
312
427
  const params = new URLSearchParams(window.location.search);
@@ -341,33 +456,76 @@ var CallForge = class _CallForge {
341
456
  clearTimeout(timeoutId);
342
457
  }
343
458
  }
459
+ async fetchLocationFromApi(locationId) {
460
+ const url = this.buildLocationUrl(locationId);
461
+ const controller = new AbortController();
462
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
463
+ try {
464
+ const response = await fetch(url, {
465
+ credentials: "omit",
466
+ signal: controller.signal
467
+ });
468
+ if (!response.ok) {
469
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
470
+ }
471
+ return await response.json();
472
+ } finally {
473
+ clearTimeout(timeoutId);
474
+ }
475
+ }
344
476
  saveToCache(locationId, data, params) {
345
477
  const cached = {
346
478
  locId: locationId != null ? locationId : null,
347
479
  sessionToken: data.sessionToken,
348
480
  leaseId: data.leaseId,
349
481
  phoneNumber: data.phoneNumber,
350
- location: data.location,
351
482
  expiresAt: data.expiresAt,
352
483
  tokenVersion: "v1",
353
484
  params
354
485
  };
355
486
  this.cache.set(cached);
356
487
  }
488
+ saveLocationToCache(locationId, location, expiresAt) {
489
+ this.locationCache.set({
490
+ locId: locationId != null ? locationId : null,
491
+ location,
492
+ expiresAt,
493
+ tokenVersion: "v1"
494
+ });
495
+ }
496
+ toTrackingLocation(location, zipOptions) {
497
+ if (!location) {
498
+ return null;
499
+ }
500
+ return {
501
+ city: location.city,
502
+ state: location.state,
503
+ stateCode: location.stateCode,
504
+ zipOptions: this.normalizeZipOptions(zipOptions)
505
+ };
506
+ }
507
+ normalizeZipOptions(value) {
508
+ if (!Array.isArray(value)) {
509
+ return [];
510
+ }
511
+ return Array.from(
512
+ new Set(
513
+ value.filter((zip) => typeof zip === "string" && /^\d{5}$/.test(zip))
514
+ )
515
+ );
516
+ }
357
517
  formatSession(cached) {
358
518
  return {
359
519
  sessionToken: cached.sessionToken,
360
520
  leaseId: cached.leaseId,
361
- phoneNumber: cached.phoneNumber,
362
- location: cached.location
521
+ phoneNumber: cached.phoneNumber
363
522
  };
364
523
  }
365
524
  formatApiResponse(data) {
366
525
  return {
367
526
  sessionToken: data.sessionToken,
368
527
  leaseId: data.leaseId,
369
- phoneNumber: data.phoneNumber,
370
- location: data.location
528
+ phoneNumber: data.phoneNumber
371
529
  };
372
530
  }
373
531
  async syncParamsToCallForgeIfPossible() {
@@ -400,6 +558,13 @@ var CallForge = class _CallForge {
400
558
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
401
559
  return `${endpoint}/v1/tracking/session?${qs}`;
402
560
  }
561
+ buildLocationUrl(locationId) {
562
+ const { endpoint } = this.config;
563
+ if (!locationId) {
564
+ return `${endpoint}/v1/tracking/location`;
565
+ }
566
+ return `${endpoint}/v1/tracking/location?loc_physical_ms=${encodeURIComponent(locationId)}`;
567
+ }
403
568
  };
404
569
 
405
570
  // src/preload.ts
@@ -429,6 +594,17 @@ if(m){try{var g=decodeURIComponent(m[1]||'');var s=g.split('.');if(s.length>=2){
429
594
  for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
430
595
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
431
596
  var key='cf_tracking_v1_'+site+'_${categoryId}';
597
+ var lkey='cf_location_v1_'+site;
598
+ try{
599
+ var cl=JSON.parse(localStorage.getItem(lkey));
600
+ if(cl&&cl.expiresAt>Date.now()+30000){
601
+ if(!loc||(loc&&cl.locId===loc)){if(cl.location&&!Array.isArray(cl.location.zipOptions))cl.location.zipOptions=[];window.__cfTrackingLocation=Promise.resolve(cl)}
602
+ }}catch(e){}
603
+ if(!window.__cfTrackingLocation){
604
+ var lurl='${endpoint}/v1/tracking/location';
605
+ if(loc)lurl+='?loc_physical_ms='+encodeURIComponent(loc);
606
+ window.__cfTrackingLocation=fetch(lurl,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload location failed');return r.json()}).then(function(d){var z=Array.isArray(d.zipOptions)?d.zipOptions.filter(function(x){return /^\\d{5}$/.test(x)}):[];d.locId=loc;d.zipOptions=z;try{localStorage.setItem(lkey,JSON.stringify({locId:loc,location:d.location?{city:d.location.city,state:d.location.state,stateCode:d.location.stateCode,zipOptions:z}:null,expiresAt:d.expiresAt,tokenVersion:'v1'}))}catch(e){}return d});
607
+ }
432
608
  var token=null;
433
609
  try{
434
610
  var c=JSON.parse(localStorage.getItem(key));
@@ -443,7 +619,7 @@ if(loc)url+='&loc_physical_ms='+loc;
443
619
  if(token)url+='&sessionToken='+encodeURIComponent(token);
444
620
  var ks=Object.keys(p).sort();
445
621
  for(var j=0;j<ks.length;j++)url+='&'+ks[j]+'='+encodeURIComponent(p[ks[j]]);
446
- 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,location:d.location,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
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});
447
623
  })();`.replace(/\n/g, "");
448
624
  return `<link rel="preconnect" href="${endpoint}">
449
625
  <script>${script}</script>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callforge/tracking-client",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -17,17 +17,17 @@
17
17
  "files": [
18
18
  "dist"
19
19
  ],
20
- "devDependencies": {
21
- "jsdom": "^27.4.0",
22
- "tsup": "^8.0.0",
23
- "typescript": "^5.3.0",
24
- "vitest": "^1.6.0",
25
- "@callforge/tsconfig": "0.0.0"
26
- },
27
20
  "scripts": {
28
21
  "build": "tsup src/index.ts --format esm,cjs --dts",
29
22
  "clean": "rm -rf dist",
30
23
  "test": "vitest run",
31
24
  "test:watch": "vitest"
25
+ },
26
+ "devDependencies": {
27
+ "@callforge/tsconfig": "workspace:*",
28
+ "jsdom": "^27.4.0",
29
+ "tsup": "^8.0.0",
30
+ "typescript": "^5.3.0",
31
+ "vitest": "^1.6.0"
32
32
  }
33
- }
33
+ }