@callforge/tracking-client 0.6.2 → 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)
@@ -83,6 +87,7 @@ Requirements:
83
87
 
84
88
  How it works:
85
89
  - Extracts the GA4 `client_id` from the `_ga` cookie and sends it to CallForge as `ga4ClientId`.
90
+ - Polls briefly after init to capture the cookie if Google Analytics sets it slightly later.
86
91
  - If `ga4MeasurementId` is configured and `gtag` is available, also uses `gtag('get', measurementId, 'client_id', ...)` (with a short retry window).
87
92
  - If `ga4ClientId` becomes available after a session is created, the client will refresh once to sync it to CallForge.
88
93
 
@@ -133,18 +138,13 @@ interface CallForgeConfig {
133
138
 
134
139
  ### `client.getSession()`
135
140
 
136
- 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.
137
142
 
138
143
  ```typescript
139
144
  interface TrackingSession {
140
145
  sessionToken: string; // Signed, opaque token used to refresh the session
141
146
  leaseId: string | null; // Deterministic assignment lease ID (when available)
142
147
  phoneNumber: string | null;
143
- location: {
144
- city: string;
145
- state: string; // Full name: "Georgia"
146
- stateCode: string; // Abbreviation: "GA"
147
- } | null;
148
148
  }
149
149
  ```
150
150
 
@@ -154,6 +154,31 @@ Behavior:
154
154
  - If `loc_physical_ms` is present in the URL, cached sessions are only reused when it matches the cached `locId`.
155
155
  - Throws on network errors or API errors.
156
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
+
157
182
  ### `client.createCallIntent()`
158
183
 
159
184
  Create a short-lived call intent token for click/callback deterministic attribution.
@@ -173,6 +198,16 @@ client.onReady((session) => {
173
198
  });
174
199
  ```
175
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
+
176
211
  ### `client.setParams(params)`
177
212
 
178
213
  Set custom tracking parameters for conversion attribution.
@@ -187,6 +222,7 @@ client.setParams({
187
222
 
188
223
  Behavior:
189
224
  - Merges with existing params (later calls override earlier values).
225
+ - If a session already exists (cached `sessionToken`), the client will refresh once to sync updated params server-side (best-effort).
190
226
  - Parameters are sent with every `getSession()` API request.
191
227
  - Persisted in localStorage alongside session data.
192
228
 
@@ -228,7 +264,8 @@ Parameters are sent as a sorted query string for cache consistency:
228
264
 
229
265
  ## Caching Behavior
230
266
 
231
- - Cache key: `cf_tracking_v1_<siteKey>_<categoryId>`
267
+ - Session cache key: `cf_tracking_v1_<siteKey>_<categoryId>`
268
+ - Location cache key: `cf_location_v1_<siteKey>`
232
269
  - TTL: controlled by the server `expiresAt` response (currently 30 minutes)
233
270
  - Storage: localStorage (falls back to memory if unavailable)
234
271
 
@@ -259,6 +296,7 @@ import type {
259
296
  TrackingLocation,
260
297
  TrackingParams,
261
298
  ReadyCallback,
299
+ LocationReadyCallback,
262
300
  CallIntentResponse,
263
301
  } from '@callforge/tracking-client';
264
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
  */
@@ -113,7 +136,15 @@ declare class CallForge {
113
136
  */
114
137
  onReady(callback: ReadyCallback): void;
115
138
  /**
116
- * Set custom tracking parameters for the next session fetch.
139
+ * Subscribe to location ready event.
140
+ * Callback is called once location data is available.
141
+ */
142
+ onLocationReady(callback: LocationReadyCallback): void;
143
+ /**
144
+ * Set custom tracking parameters for attribution.
145
+ *
146
+ * When possible, the client will also refresh the session once to sync
147
+ * the updated params to CallForge (so server-side events can use them).
117
148
  */
118
149
  setParams(params: Record<string, string>): Promise<void>;
119
150
  /**
@@ -136,14 +167,18 @@ declare class CallForge {
136
167
  */
137
168
  private getGA4ClientIdFromCookie;
138
169
  private fetchSession;
170
+ private fetchLocation;
139
171
  private getLocationId;
140
172
  private getAutoParams;
141
173
  private fetchFromApi;
174
+ private fetchLocationFromApi;
142
175
  private saveToCache;
176
+ private saveLocationToCache;
143
177
  private formatSession;
144
178
  private formatApiResponse;
145
- private syncGA4ClientIdIfNeeded;
179
+ private syncParamsToCallForgeIfPossible;
146
180
  private buildUrl;
181
+ private buildLocationUrl;
147
182
  }
148
183
 
149
184
  /**
@@ -152,4 +187,4 @@ declare class CallForge {
152
187
  */
153
188
  declare function getPreloadSnippet(config: CallForgeConfig): string;
154
189
 
155
- 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
  */
@@ -113,7 +136,15 @@ declare class CallForge {
113
136
  */
114
137
  onReady(callback: ReadyCallback): void;
115
138
  /**
116
- * Set custom tracking parameters for the next session fetch.
139
+ * Subscribe to location ready event.
140
+ * Callback is called once location data is available.
141
+ */
142
+ onLocationReady(callback: LocationReadyCallback): void;
143
+ /**
144
+ * Set custom tracking parameters for attribution.
145
+ *
146
+ * When possible, the client will also refresh the session once to sync
147
+ * the updated params to CallForge (so server-side events can use them).
117
148
  */
118
149
  setParams(params: Record<string, string>): Promise<void>;
119
150
  /**
@@ -136,14 +167,18 @@ declare class CallForge {
136
167
  */
137
168
  private getGA4ClientIdFromCookie;
138
169
  private fetchSession;
170
+ private fetchLocation;
139
171
  private getLocationId;
140
172
  private getAutoParams;
141
173
  private fetchFromApi;
174
+ private fetchLocationFromApi;
142
175
  private saveToCache;
176
+ private saveLocationToCache;
143
177
  private formatSession;
144
178
  private formatApiResponse;
145
- private syncGA4ClientIdIfNeeded;
179
+ private syncParamsToCallForgeIfPossible;
146
180
  private buildUrl;
181
+ private buildLocationUrl;
147
182
  }
148
183
 
149
184
  /**
@@ -152,4 +187,4 @@ declare class CallForge {
152
187
  */
153
188
  declare function getPreloadSnippet(config: CallForgeConfig): string;
154
189
 
155
- 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
  */
@@ -210,23 +294,35 @@ var CallForge = class _CallForge {
210
294
  });
211
295
  }
212
296
  /**
213
- * Set custom tracking parameters for the next session fetch.
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
+ }
304
+ /**
305
+ * Set custom tracking parameters for attribution.
306
+ *
307
+ * When possible, the client will also refresh the session once to sync
308
+ * the updated params to CallForge (so server-side events can use them).
214
309
  */
215
310
  async setParams(params) {
216
311
  this.customParams = __spreadValues(__spreadValues({}, this.customParams), params);
217
- if (params.ga4ClientId) {
218
- const sync = async () => {
219
- try {
220
- await this.syncGA4ClientIdIfNeeded();
221
- } catch (e) {
222
- }
223
- };
224
- if (this.sessionPromise) {
225
- void this.sessionPromise.then(sync).catch(() => {
226
- });
227
- } else {
228
- void sync();
312
+ const sync = async () => {
313
+ try {
314
+ await this.syncParamsToCallForgeIfPossible();
315
+ } catch (e) {
316
+ }
317
+ };
318
+ if (this.sessionPromise) {
319
+ try {
320
+ await this.sessionPromise;
321
+ } catch (e) {
229
322
  }
323
+ await sync();
324
+ } else {
325
+ await sync();
230
326
  }
231
327
  }
232
328
  /**
@@ -259,7 +355,6 @@ var CallForge = class _CallForge {
259
355
  });
260
356
  }
261
357
  startGA4ClientIdPolling() {
262
- if (!this.config.ga4MeasurementId) return;
263
358
  if (typeof window === "undefined") return;
264
359
  if (this.customParams.ga4ClientId) return;
265
360
  const MAX_ATTEMPTS = 20;
@@ -328,6 +423,37 @@ var CallForge = class _CallForge {
328
423
  this.saveToCache(locationId, data, params);
329
424
  return this.formatApiResponse(data);
330
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
+ }
331
457
  getLocationId() {
332
458
  if (typeof window === "undefined") return null;
333
459
  const params = new URLSearchParams(window.location.search);
@@ -362,47 +488,64 @@ var CallForge = class _CallForge {
362
488
  clearTimeout(timeoutId);
363
489
  }
364
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
+ }
365
508
  saveToCache(locationId, data, params) {
366
509
  const cached = {
367
510
  locId: locationId != null ? locationId : null,
368
511
  sessionToken: data.sessionToken,
369
512
  leaseId: data.leaseId,
370
513
  phoneNumber: data.phoneNumber,
371
- location: data.location,
372
514
  expiresAt: data.expiresAt,
373
515
  tokenVersion: "v1",
374
516
  params
375
517
  };
376
518
  this.cache.set(cached);
377
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
+ }
378
528
  formatSession(cached) {
379
529
  return {
380
530
  sessionToken: cached.sessionToken,
381
531
  leaseId: cached.leaseId,
382
- phoneNumber: cached.phoneNumber,
383
- location: cached.location
532
+ phoneNumber: cached.phoneNumber
384
533
  };
385
534
  }
386
535
  formatApiResponse(data) {
387
536
  return {
388
537
  sessionToken: data.sessionToken,
389
538
  leaseId: data.leaseId,
390
- phoneNumber: data.phoneNumber,
391
- location: data.location
539
+ phoneNumber: data.phoneNumber
392
540
  };
393
541
  }
394
- async syncGA4ClientIdIfNeeded() {
395
- const ga4ClientId = this.customParams.ga4ClientId;
396
- if (!ga4ClientId) return;
542
+ async syncParamsToCallForgeIfPossible() {
397
543
  const locationId = this.getLocationId();
398
544
  const sessionToken = this.cache.getSessionToken(locationId);
399
545
  if (!sessionToken) return;
400
546
  const autoParams = this.getAutoParams();
401
547
  const cachedParams = this.cache.getParams();
402
548
  const params = __spreadValues(__spreadValues(__spreadValues({}, autoParams), cachedParams), this.customParams);
403
- if (cachedParams.ga4ClientId === params.ga4ClientId) {
404
- return;
405
- }
406
549
  const data = await this.fetchFromApi(locationId, sessionToken, params);
407
550
  this.saveToCache(locationId, data, params);
408
551
  }
@@ -426,6 +569,13 @@ var CallForge = class _CallForge {
426
569
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
427
570
  return `${endpoint}/v1/tracking/session?${qs}`;
428
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
+ }
429
579
  };
430
580
 
431
581
  // src/preload.ts
@@ -455,6 +605,17 @@ if(m){try{var g=decodeURIComponent(m[1]||'');var s=g.split('.');if(s.length>=2){
455
605
  for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
456
606
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
457
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
+ }
458
619
  var token=null;
459
620
  try{
460
621
  var c=JSON.parse(localStorage.getItem(key));
@@ -469,7 +630,7 @@ if(loc)url+='&loc_physical_ms='+loc;
469
630
  if(token)url+='&sessionToken='+encodeURIComponent(token);
470
631
  var ks=Object.keys(p).sort();
471
632
  for(var j=0;j<ks.length;j++)url+='&'+ks[j]+'='+encodeURIComponent(p[ks[j]]);
472
- 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});
473
634
  })();`.replace(/\n/g, "");
474
635
  return `<link rel="preconnect" href="${endpoint}">
475
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
  */
@@ -186,23 +270,35 @@ var CallForge = class _CallForge {
186
270
  });
187
271
  }
188
272
  /**
189
- * Set custom tracking parameters for the next session fetch.
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
+ }
280
+ /**
281
+ * Set custom tracking parameters for attribution.
282
+ *
283
+ * When possible, the client will also refresh the session once to sync
284
+ * the updated params to CallForge (so server-side events can use them).
190
285
  */
191
286
  async setParams(params) {
192
287
  this.customParams = __spreadValues(__spreadValues({}, this.customParams), params);
193
- if (params.ga4ClientId) {
194
- const sync = async () => {
195
- try {
196
- await this.syncGA4ClientIdIfNeeded();
197
- } catch (e) {
198
- }
199
- };
200
- if (this.sessionPromise) {
201
- void this.sessionPromise.then(sync).catch(() => {
202
- });
203
- } else {
204
- void sync();
288
+ const sync = async () => {
289
+ try {
290
+ await this.syncParamsToCallForgeIfPossible();
291
+ } catch (e) {
292
+ }
293
+ };
294
+ if (this.sessionPromise) {
295
+ try {
296
+ await this.sessionPromise;
297
+ } catch (e) {
205
298
  }
299
+ await sync();
300
+ } else {
301
+ await sync();
206
302
  }
207
303
  }
208
304
  /**
@@ -235,7 +331,6 @@ var CallForge = class _CallForge {
235
331
  });
236
332
  }
237
333
  startGA4ClientIdPolling() {
238
- if (!this.config.ga4MeasurementId) return;
239
334
  if (typeof window === "undefined") return;
240
335
  if (this.customParams.ga4ClientId) return;
241
336
  const MAX_ATTEMPTS = 20;
@@ -304,6 +399,37 @@ var CallForge = class _CallForge {
304
399
  this.saveToCache(locationId, data, params);
305
400
  return this.formatApiResponse(data);
306
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
+ }
307
433
  getLocationId() {
308
434
  if (typeof window === "undefined") return null;
309
435
  const params = new URLSearchParams(window.location.search);
@@ -338,47 +464,64 @@ var CallForge = class _CallForge {
338
464
  clearTimeout(timeoutId);
339
465
  }
340
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
+ }
341
484
  saveToCache(locationId, data, params) {
342
485
  const cached = {
343
486
  locId: locationId != null ? locationId : null,
344
487
  sessionToken: data.sessionToken,
345
488
  leaseId: data.leaseId,
346
489
  phoneNumber: data.phoneNumber,
347
- location: data.location,
348
490
  expiresAt: data.expiresAt,
349
491
  tokenVersion: "v1",
350
492
  params
351
493
  };
352
494
  this.cache.set(cached);
353
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
+ }
354
504
  formatSession(cached) {
355
505
  return {
356
506
  sessionToken: cached.sessionToken,
357
507
  leaseId: cached.leaseId,
358
- phoneNumber: cached.phoneNumber,
359
- location: cached.location
508
+ phoneNumber: cached.phoneNumber
360
509
  };
361
510
  }
362
511
  formatApiResponse(data) {
363
512
  return {
364
513
  sessionToken: data.sessionToken,
365
514
  leaseId: data.leaseId,
366
- phoneNumber: data.phoneNumber,
367
- location: data.location
515
+ phoneNumber: data.phoneNumber
368
516
  };
369
517
  }
370
- async syncGA4ClientIdIfNeeded() {
371
- const ga4ClientId = this.customParams.ga4ClientId;
372
- if (!ga4ClientId) return;
518
+ async syncParamsToCallForgeIfPossible() {
373
519
  const locationId = this.getLocationId();
374
520
  const sessionToken = this.cache.getSessionToken(locationId);
375
521
  if (!sessionToken) return;
376
522
  const autoParams = this.getAutoParams();
377
523
  const cachedParams = this.cache.getParams();
378
524
  const params = __spreadValues(__spreadValues(__spreadValues({}, autoParams), cachedParams), this.customParams);
379
- if (cachedParams.ga4ClientId === params.ga4ClientId) {
380
- return;
381
- }
382
525
  const data = await this.fetchFromApi(locationId, sessionToken, params);
383
526
  this.saveToCache(locationId, data, params);
384
527
  }
@@ -402,6 +545,13 @@ var CallForge = class _CallForge {
402
545
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
403
546
  return `${endpoint}/v1/tracking/session?${qs}`;
404
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
+ }
405
555
  };
406
556
 
407
557
  // src/preload.ts
@@ -431,6 +581,17 @@ if(m){try{var g=decodeURIComponent(m[1]||'');var s=g.split('.');if(s.length>=2){
431
581
  for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
432
582
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
433
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
+ }
434
595
  var token=null;
435
596
  try{
436
597
  var c=JSON.parse(localStorage.getItem(key));
@@ -445,7 +606,7 @@ if(loc)url+='&loc_physical_ms='+loc;
445
606
  if(token)url+='&sessionToken='+encodeURIComponent(token);
446
607
  var ks=Object.keys(p).sort();
447
608
  for(var j=0;j<ks.length;j++)url+='&'+ks[j]+'='+encodeURIComponent(p[ks[j]]);
448
- 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});
449
610
  })();`.replace(/\n/g, "");
450
611
  return `<link rel="preconnect" href="${endpoint}">
451
612
  <script>${script}</script>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callforge/tracking-client",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
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
+ },
20
27
  "scripts": {
21
28
  "build": "tsup src/index.ts --format esm,cjs --dts",
22
29
  "clean": "rm -rf dist",
23
30
  "test": "vitest run",
24
31
  "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
+ }