@callforge/tracking-client 0.6.3 → 0.7.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
@@ -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,13 @@ 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); // { city: "Woodstock", state: "Georgia", stateCode: "GA" } or null
47
+
48
+ // Phone session data (deterministic token + phone number)
49
+ console.log(await session); // { sessionToken, leaseId, phoneNumber }
46
50
  ```
47
51
 
48
52
  ### 3. Deterministic click/callback attribution (optional)
@@ -134,18 +138,13 @@ interface CallForgeConfig {
134
138
 
135
139
  ### `client.getSession()`
136
140
 
137
- Get tracking session data. Returns cached data if valid, otherwise fetches from the API.
141
+ Get tracking session data (phone number + deterministic session token). Returns cached data if valid, otherwise fetches from the API.
138
142
 
139
143
  ```typescript
140
144
  interface TrackingSession {
141
145
  sessionToken: string; // Signed, opaque token used to refresh the session
142
146
  leaseId: string | null; // Deterministic assignment lease ID (when available)
143
147
  phoneNumber: string | null;
144
- location: {
145
- city: string;
146
- state: string; // Full name: "Georgia"
147
- stateCode: string; // Abbreviation: "GA"
148
- } | null;
149
148
  }
150
149
  ```
151
150
 
@@ -155,6 +154,31 @@ Behavior:
155
154
  - If `loc_physical_ms` is present in the URL, cached sessions are only reused when it matches the cached `locId`.
156
155
  - Throws on network errors or API errors.
157
156
 
157
+ ### `client.getLocation()`
158
+
159
+ Get location data only. Returns cached data if valid, otherwise fetches from the API.
160
+
161
+ ```typescript
162
+ const location = await client.getLocation();
163
+ // { city, state, stateCode } or null
164
+ ```
165
+
166
+ ### `client.getSessionAsync()`
167
+
168
+ Kick off both requests and use each as soon as it resolves.
169
+
170
+ ```typescript
171
+ const { session, location } = client.getSessionAsync();
172
+
173
+ location.then((loc) => {
174
+ // show city/state ASAP
175
+ });
176
+
177
+ session.then((sess) => {
178
+ // show phone number when ready
179
+ });
180
+ ```
181
+
158
182
  ### `client.createCallIntent()`
159
183
 
160
184
  Create a short-lived call intent token for click/callback deterministic attribution.
@@ -174,6 +198,16 @@ client.onReady((session) => {
174
198
  });
175
199
  ```
176
200
 
201
+ ### `client.onLocationReady(callback)`
202
+
203
+ Subscribe to location ready event. Callback is called once location data is available.
204
+
205
+ ```typescript
206
+ client.onLocationReady((location) => {
207
+ // location is { city, state, stateCode } or null
208
+ });
209
+ ```
210
+
177
211
  ### `client.setParams(params)`
178
212
 
179
213
  Set custom tracking parameters for conversion attribution.
@@ -230,7 +264,8 @@ Parameters are sent as a sorted query string for cache consistency:
230
264
 
231
265
  ## Caching Behavior
232
266
 
233
- - Cache key: `cf_tracking_v1_<siteKey>_<categoryId>`
267
+ - Session cache key: `cf_tracking_v1_<siteKey>_<categoryId>`
268
+ - Location cache key: `cf_location_v1_<siteKey>`
234
269
  - TTL: controlled by the server `expiresAt` response (currently 30 minutes)
235
270
  - Storage: localStorage (falls back to memory if unavailable)
236
271
 
@@ -261,6 +296,7 @@ import type {
261
296
  TrackingLocation,
262
297
  TrackingParams,
263
298
  ReadyCallback,
299
+ LocationReadyCallback,
264
300
  CallIntentResponse,
265
301
  } from '@callforge/tracking-client';
266
302
  ```
package/dist/index.d.mts CHANGED
@@ -22,6 +22,7 @@ interface TrackingLocation {
22
22
  /** State abbreviation (e.g., "GA") */
23
23
  stateCode: string;
24
24
  }
25
+ type TrackingLocationSource = 'google_criteria' | 'cloudflare_geo';
25
26
  /**
26
27
  * Tracking parameters for attribution (auto-captured + custom).
27
28
  */
@@ -49,20 +50,25 @@ interface TrackingSession {
49
50
  leaseId: string | null;
50
51
  /** Assigned phone number, or null if no number available */
51
52
  phoneNumber: string | null;
52
- /** Location data, or null if location could not be resolved */
53
- location: TrackingLocation | null;
54
53
  }
55
54
  /**
56
- * Internal API response format (includes expiresAt).
55
+ * Session API response format (includes expiresAt).
57
56
  */
58
- interface ApiResponse {
57
+ interface ApiSessionResponse {
59
58
  sessionToken: string;
60
59
  leaseId: string | null;
61
60
  phoneNumber: string | null;
61
+ expiresAt: number;
62
+ }
63
+ /**
64
+ * Location API response format (includes expiresAt).
65
+ */
66
+ interface ApiLocationResponse {
62
67
  location: {
63
68
  city: string;
64
69
  state: string;
65
70
  stateCode: string;
71
+ source: TrackingLocationSource;
66
72
  } | null;
67
73
  expiresAt: number;
68
74
  }
@@ -78,12 +84,14 @@ interface CallIntentResponse {
78
84
  * Callback function for onReady subscription.
79
85
  */
80
86
  type ReadyCallback = (session: TrackingSession) => void;
87
+ type LocationReadyCallback = (location: TrackingLocation | null) => void;
81
88
  /**
82
89
  * Global window extension for preload promise and gtag.
83
90
  */
84
91
  declare global {
85
92
  interface Window {
86
- __cfTracking?: Promise<ApiResponse>;
93
+ __cfTracking?: Promise<ApiSessionResponse>;
94
+ __cfTrackingLocation?: Promise<ApiLocationResponse>;
87
95
  gtag?: (command: 'get', targetId: string, fieldName: string, callback: (value: string) => void) => void;
88
96
  }
89
97
  }
@@ -91,7 +99,9 @@ declare global {
91
99
  declare class CallForge {
92
100
  private readonly config;
93
101
  private readonly cache;
102
+ private readonly locationCache;
94
103
  private sessionPromise;
104
+ private locationPromise;
95
105
  private customParams;
96
106
  private constructor();
97
107
  /**
@@ -103,6 +113,19 @@ declare class CallForge {
103
113
  * Returns cached data if valid, otherwise fetches from API.
104
114
  */
105
115
  getSession(): Promise<TrackingSession>;
116
+ /**
117
+ * Get location data only.
118
+ * Returns cached data if valid, otherwise fetches from API.
119
+ */
120
+ getLocation(): Promise<TrackingLocation | null>;
121
+ /**
122
+ * Kick off both session and location requests.
123
+ * Returns separate Promises so consumers can use each as soon as it resolves.
124
+ */
125
+ getSessionAsync(): {
126
+ session: Promise<TrackingSession>;
127
+ location: Promise<TrackingLocation | null>;
128
+ };
106
129
  /**
107
130
  * Create a short-lived call intent token for click/callback deterministic attribution.
108
131
  */
@@ -112,6 +135,11 @@ declare class CallForge {
112
135
  * Callback is called once session data is available.
113
136
  */
114
137
  onReady(callback: ReadyCallback): void;
138
+ /**
139
+ * Subscribe to location ready event.
140
+ * Callback is called once location data is available.
141
+ */
142
+ onLocationReady(callback: LocationReadyCallback): void;
115
143
  /**
116
144
  * Set custom tracking parameters for attribution.
117
145
  *
@@ -139,14 +167,18 @@ declare class CallForge {
139
167
  */
140
168
  private getGA4ClientIdFromCookie;
141
169
  private fetchSession;
170
+ private fetchLocation;
142
171
  private getLocationId;
143
172
  private getAutoParams;
144
173
  private fetchFromApi;
174
+ private fetchLocationFromApi;
145
175
  private saveToCache;
176
+ private saveLocationToCache;
146
177
  private formatSession;
147
178
  private formatApiResponse;
148
179
  private syncParamsToCallForgeIfPossible;
149
180
  private buildUrl;
181
+ private buildLocationUrl;
150
182
  }
151
183
 
152
184
  /**
@@ -155,4 +187,4 @@ declare class CallForge {
155
187
  */
156
188
  declare function getPreloadSnippet(config: CallForgeConfig): string;
157
189
 
158
- export { CallForge, type CallForgeConfig, type CallIntentResponse, type ReadyCallback, type TrackingLocation, type TrackingParams, type TrackingSession, getPreloadSnippet };
190
+ 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
@@ -22,6 +22,7 @@ interface TrackingLocation {
22
22
  /** State abbreviation (e.g., "GA") */
23
23
  stateCode: string;
24
24
  }
25
+ type TrackingLocationSource = 'google_criteria' | 'cloudflare_geo';
25
26
  /**
26
27
  * Tracking parameters for attribution (auto-captured + custom).
27
28
  */
@@ -49,20 +50,25 @@ interface TrackingSession {
49
50
  leaseId: string | null;
50
51
  /** Assigned phone number, or null if no number available */
51
52
  phoneNumber: string | null;
52
- /** Location data, or null if location could not be resolved */
53
- location: TrackingLocation | null;
54
53
  }
55
54
  /**
56
- * Internal API response format (includes expiresAt).
55
+ * Session API response format (includes expiresAt).
57
56
  */
58
- interface ApiResponse {
57
+ interface ApiSessionResponse {
59
58
  sessionToken: string;
60
59
  leaseId: string | null;
61
60
  phoneNumber: string | null;
61
+ expiresAt: number;
62
+ }
63
+ /**
64
+ * Location API response format (includes expiresAt).
65
+ */
66
+ interface ApiLocationResponse {
62
67
  location: {
63
68
  city: string;
64
69
  state: string;
65
70
  stateCode: string;
71
+ source: TrackingLocationSource;
66
72
  } | null;
67
73
  expiresAt: number;
68
74
  }
@@ -78,12 +84,14 @@ interface CallIntentResponse {
78
84
  * Callback function for onReady subscription.
79
85
  */
80
86
  type ReadyCallback = (session: TrackingSession) => void;
87
+ type LocationReadyCallback = (location: TrackingLocation | null) => void;
81
88
  /**
82
89
  * Global window extension for preload promise and gtag.
83
90
  */
84
91
  declare global {
85
92
  interface Window {
86
- __cfTracking?: Promise<ApiResponse>;
93
+ __cfTracking?: Promise<ApiSessionResponse>;
94
+ __cfTrackingLocation?: Promise<ApiLocationResponse>;
87
95
  gtag?: (command: 'get', targetId: string, fieldName: string, callback: (value: string) => void) => void;
88
96
  }
89
97
  }
@@ -91,7 +99,9 @@ declare global {
91
99
  declare class CallForge {
92
100
  private readonly config;
93
101
  private readonly cache;
102
+ private readonly locationCache;
94
103
  private sessionPromise;
104
+ private locationPromise;
95
105
  private customParams;
96
106
  private constructor();
97
107
  /**
@@ -103,6 +113,19 @@ declare class CallForge {
103
113
  * Returns cached data if valid, otherwise fetches from API.
104
114
  */
105
115
  getSession(): Promise<TrackingSession>;
116
+ /**
117
+ * Get location data only.
118
+ * Returns cached data if valid, otherwise fetches from API.
119
+ */
120
+ getLocation(): Promise<TrackingLocation | null>;
121
+ /**
122
+ * Kick off both session and location requests.
123
+ * Returns separate Promises so consumers can use each as soon as it resolves.
124
+ */
125
+ getSessionAsync(): {
126
+ session: Promise<TrackingSession>;
127
+ location: Promise<TrackingLocation | null>;
128
+ };
106
129
  /**
107
130
  * Create a short-lived call intent token for click/callback deterministic attribution.
108
131
  */
@@ -112,6 +135,11 @@ declare class CallForge {
112
135
  * Callback is called once session data is available.
113
136
  */
114
137
  onReady(callback: ReadyCallback): void;
138
+ /**
139
+ * Subscribe to location ready event.
140
+ * Callback is called once location data is available.
141
+ */
142
+ onLocationReady(callback: LocationReadyCallback): void;
115
143
  /**
116
144
  * Set custom tracking parameters for attribution.
117
145
  *
@@ -139,14 +167,18 @@ declare class CallForge {
139
167
  */
140
168
  private getGA4ClientIdFromCookie;
141
169
  private fetchSession;
170
+ private fetchLocation;
142
171
  private getLocationId;
143
172
  private getAutoParams;
144
173
  private fetchFromApi;
174
+ private fetchLocationFromApi;
145
175
  private saveToCache;
176
+ private saveLocationToCache;
146
177
  private formatSession;
147
178
  private formatApiResponse;
148
179
  private syncParamsToCallForgeIfPossible;
149
180
  private buildUrl;
181
+ private buildLocationUrl;
150
182
  }
151
183
 
152
184
  /**
@@ -155,4 +187,4 @@ declare class CallForge {
155
187
  */
156
188
  declare function getPreloadSnippet(config: CallForgeConfig): string;
157
189
 
158
- export { CallForge, type CallForgeConfig, type CallIntentResponse, type ReadyCallback, type TrackingLocation, type TrackingParams, type TrackingSession, getPreloadSnippet };
190
+ 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,37 @@ 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;
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 = data2.location ? {
435
+ city: data2.location.city,
436
+ state: data2.location.state,
437
+ stateCode: data2.location.stateCode
438
+ } : null;
439
+ this.saveLocationToCache(effectiveLocId, location2, data2.expiresAt);
440
+ return location2;
441
+ } catch (e) {
442
+ }
443
+ }
444
+ const cached = this.locationCache.get(locationId);
445
+ if (cached) {
446
+ return cached.location;
447
+ }
448
+ const data = await this.fetchLocationFromApi(locationId);
449
+ const location = data.location ? {
450
+ city: data.location.city,
451
+ state: data.location.state,
452
+ stateCode: data.location.stateCode
453
+ } : null;
454
+ this.saveLocationToCache(locationId, location, data.expiresAt);
455
+ return location;
456
+ }
334
457
  getLocationId() {
335
458
  if (typeof window === "undefined") return null;
336
459
  const params = new URLSearchParams(window.location.search);
@@ -365,33 +488,55 @@ var CallForge = class _CallForge {
365
488
  clearTimeout(timeoutId);
366
489
  }
367
490
  }
491
+ async fetchLocationFromApi(locationId) {
492
+ const url = this.buildLocationUrl(locationId);
493
+ const controller = new AbortController();
494
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
495
+ try {
496
+ const response = await fetch(url, {
497
+ credentials: "omit",
498
+ signal: controller.signal
499
+ });
500
+ if (!response.ok) {
501
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
502
+ }
503
+ return await response.json();
504
+ } finally {
505
+ clearTimeout(timeoutId);
506
+ }
507
+ }
368
508
  saveToCache(locationId, data, params) {
369
509
  const cached = {
370
510
  locId: locationId != null ? locationId : null,
371
511
  sessionToken: data.sessionToken,
372
512
  leaseId: data.leaseId,
373
513
  phoneNumber: data.phoneNumber,
374
- location: data.location,
375
514
  expiresAt: data.expiresAt,
376
515
  tokenVersion: "v1",
377
516
  params
378
517
  };
379
518
  this.cache.set(cached);
380
519
  }
520
+ saveLocationToCache(locationId, location, expiresAt) {
521
+ this.locationCache.set({
522
+ locId: locationId != null ? locationId : null,
523
+ location,
524
+ expiresAt,
525
+ tokenVersion: "v1"
526
+ });
527
+ }
381
528
  formatSession(cached) {
382
529
  return {
383
530
  sessionToken: cached.sessionToken,
384
531
  leaseId: cached.leaseId,
385
- phoneNumber: cached.phoneNumber,
386
- location: cached.location
532
+ phoneNumber: cached.phoneNumber
387
533
  };
388
534
  }
389
535
  formatApiResponse(data) {
390
536
  return {
391
537
  sessionToken: data.sessionToken,
392
538
  leaseId: data.leaseId,
393
- phoneNumber: data.phoneNumber,
394
- location: data.location
539
+ phoneNumber: data.phoneNumber
395
540
  };
396
541
  }
397
542
  async syncParamsToCallForgeIfPossible() {
@@ -424,6 +569,13 @@ var CallForge = class _CallForge {
424
569
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
425
570
  return `${endpoint}/v1/tracking/session?${qs}`;
426
571
  }
572
+ buildLocationUrl(locationId) {
573
+ const { endpoint } = this.config;
574
+ if (!locationId) {
575
+ return `${endpoint}/v1/tracking/location`;
576
+ }
577
+ return `${endpoint}/v1/tracking/location?loc_physical_ms=${encodeURIComponent(locationId)}`;
578
+ }
427
579
  };
428
580
 
429
581
  // src/preload.ts
@@ -453,6 +605,17 @@ if(m){try{var g=decodeURIComponent(m[1]||'');var s=g.split('.');if(s.length>=2){
453
605
  for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
454
606
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
455
607
  var key='cf_tracking_v1_'+site+'_${categoryId}';
608
+ var lkey='cf_location_v1_'+site;
609
+ try{
610
+ var cl=JSON.parse(localStorage.getItem(lkey));
611
+ if(cl&&cl.expiresAt>Date.now()+30000){
612
+ if(!loc||(loc&&cl.locId===loc)){window.__cfTrackingLocation=Promise.resolve(cl)}
613
+ }}catch(e){}
614
+ if(!window.__cfTrackingLocation){
615
+ var lurl='${endpoint}/v1/tracking/location';
616
+ if(loc)lurl+='?loc_physical_ms='+encodeURIComponent(loc);
617
+ 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){d.locId=loc;try{localStorage.setItem(lkey,JSON.stringify({locId:loc,location:d.location?{city:d.location.city,state:d.location.state,stateCode:d.location.stateCode}:null,expiresAt:d.expiresAt,tokenVersion:'v1'}))}catch(e){}return d});
618
+ }
456
619
  var token=null;
457
620
  try{
458
621
  var c=JSON.parse(localStorage.getItem(key));
@@ -467,7 +630,7 @@ if(loc)url+='&loc_physical_ms='+loc;
467
630
  if(token)url+='&sessionToken='+encodeURIComponent(token);
468
631
  var ks=Object.keys(p).sort();
469
632
  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});
633
+ 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
634
  })();`.replace(/\n/g, "");
472
635
  return `<link rel="preconnect" href="${endpoint}">
473
636
  <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,37 @@ 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;
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 = data2.location ? {
411
+ city: data2.location.city,
412
+ state: data2.location.state,
413
+ stateCode: data2.location.stateCode
414
+ } : null;
415
+ this.saveLocationToCache(effectiveLocId, location2, data2.expiresAt);
416
+ return location2;
417
+ } catch (e) {
418
+ }
419
+ }
420
+ const cached = this.locationCache.get(locationId);
421
+ if (cached) {
422
+ return cached.location;
423
+ }
424
+ const data = await this.fetchLocationFromApi(locationId);
425
+ const location = data.location ? {
426
+ city: data.location.city,
427
+ state: data.location.state,
428
+ stateCode: data.location.stateCode
429
+ } : null;
430
+ this.saveLocationToCache(locationId, location, data.expiresAt);
431
+ return location;
432
+ }
310
433
  getLocationId() {
311
434
  if (typeof window === "undefined") return null;
312
435
  const params = new URLSearchParams(window.location.search);
@@ -341,33 +464,55 @@ var CallForge = class _CallForge {
341
464
  clearTimeout(timeoutId);
342
465
  }
343
466
  }
467
+ async fetchLocationFromApi(locationId) {
468
+ const url = this.buildLocationUrl(locationId);
469
+ const controller = new AbortController();
470
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
471
+ try {
472
+ const response = await fetch(url, {
473
+ credentials: "omit",
474
+ signal: controller.signal
475
+ });
476
+ if (!response.ok) {
477
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
478
+ }
479
+ return await response.json();
480
+ } finally {
481
+ clearTimeout(timeoutId);
482
+ }
483
+ }
344
484
  saveToCache(locationId, data, params) {
345
485
  const cached = {
346
486
  locId: locationId != null ? locationId : null,
347
487
  sessionToken: data.sessionToken,
348
488
  leaseId: data.leaseId,
349
489
  phoneNumber: data.phoneNumber,
350
- location: data.location,
351
490
  expiresAt: data.expiresAt,
352
491
  tokenVersion: "v1",
353
492
  params
354
493
  };
355
494
  this.cache.set(cached);
356
495
  }
496
+ saveLocationToCache(locationId, location, expiresAt) {
497
+ this.locationCache.set({
498
+ locId: locationId != null ? locationId : null,
499
+ location,
500
+ expiresAt,
501
+ tokenVersion: "v1"
502
+ });
503
+ }
357
504
  formatSession(cached) {
358
505
  return {
359
506
  sessionToken: cached.sessionToken,
360
507
  leaseId: cached.leaseId,
361
- phoneNumber: cached.phoneNumber,
362
- location: cached.location
508
+ phoneNumber: cached.phoneNumber
363
509
  };
364
510
  }
365
511
  formatApiResponse(data) {
366
512
  return {
367
513
  sessionToken: data.sessionToken,
368
514
  leaseId: data.leaseId,
369
- phoneNumber: data.phoneNumber,
370
- location: data.location
515
+ phoneNumber: data.phoneNumber
371
516
  };
372
517
  }
373
518
  async syncParamsToCallForgeIfPossible() {
@@ -400,6 +545,13 @@ var CallForge = class _CallForge {
400
545
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
401
546
  return `${endpoint}/v1/tracking/session?${qs}`;
402
547
  }
548
+ buildLocationUrl(locationId) {
549
+ const { endpoint } = this.config;
550
+ if (!locationId) {
551
+ return `${endpoint}/v1/tracking/location`;
552
+ }
553
+ return `${endpoint}/v1/tracking/location?loc_physical_ms=${encodeURIComponent(locationId)}`;
554
+ }
403
555
  };
404
556
 
405
557
  // src/preload.ts
@@ -429,6 +581,17 @@ if(m){try{var g=decodeURIComponent(m[1]||'');var s=g.split('.');if(s.length>=2){
429
581
  for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
430
582
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
431
583
  var key='cf_tracking_v1_'+site+'_${categoryId}';
584
+ var lkey='cf_location_v1_'+site;
585
+ try{
586
+ var cl=JSON.parse(localStorage.getItem(lkey));
587
+ if(cl&&cl.expiresAt>Date.now()+30000){
588
+ if(!loc||(loc&&cl.locId===loc)){window.__cfTrackingLocation=Promise.resolve(cl)}
589
+ }}catch(e){}
590
+ if(!window.__cfTrackingLocation){
591
+ var lurl='${endpoint}/v1/tracking/location';
592
+ if(loc)lurl+='?loc_physical_ms='+encodeURIComponent(loc);
593
+ 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){d.locId=loc;try{localStorage.setItem(lkey,JSON.stringify({locId:loc,location:d.location?{city:d.location.city,state:d.location.state,stateCode:d.location.stateCode}:null,expiresAt:d.expiresAt,tokenVersion:'v1'}))}catch(e){}return d});
594
+ }
432
595
  var token=null;
433
596
  try{
434
597
  var c=JSON.parse(localStorage.getItem(key));
@@ -443,7 +606,7 @@ if(loc)url+='&loc_physical_ms='+loc;
443
606
  if(token)url+='&sessionToken='+encodeURIComponent(token);
444
607
  var ks=Object.keys(p).sort();
445
608
  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});
609
+ 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
610
  })();`.replace(/\n/g, "");
448
611
  return `<link rel="preconnect" href="${endpoint}">
449
612
  <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.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",