@reservamos/browser-analytics 1.0.21 → 1.0.22-alpha.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/CHANGELOG.md +12 -0
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +13 -0
- package/dist/browser-analytics.esm.js +160 -22
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +1 -1
- package/src/events/geolocationSelected/geolocationSelectedSchema.ts +13 -0
- package/src/events/geolocationSelected/index.ts +7 -0
- package/src/events/geolocationSelected/trackGeolocationSelected.ts +21 -0
- package/src/index.ts +4 -0
- package/src/init.ts +2 -2
- package/src/services/fingerprint.ts +4 -0
- package/src/services/geolocation.test.ts +261 -0
- package/src/services/geolocation.ts +270 -24
- package/src/services/mixpanel.ts +13 -0
- package/src/services/validator/validator.ts +2 -0
|
@@ -1,18 +1,157 @@
|
|
|
1
1
|
import trackCustomEvent from '@/events/customEvent';
|
|
2
2
|
import { isTrackerReady } from '@/init';
|
|
3
|
+
import mixpanelService from '@/services/mixpanel';
|
|
3
4
|
|
|
4
|
-
// Add interface for coordinates
|
|
5
5
|
interface Coordinates {
|
|
6
6
|
lat: number;
|
|
7
7
|
long: number;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
interface GeolocationCache {
|
|
11
|
+
lat: number;
|
|
12
|
+
long: number;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
accuracy?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GeolocationTrackingData {
|
|
18
|
+
status: GeolocationStatus;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
accuracy?: number;
|
|
21
|
+
errorCode?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Outcome of a geolocation request, sent to Mixpanel on the Geolocation Requested event
|
|
26
|
+
* and synced to the user profile.
|
|
27
|
+
*
|
|
28
|
+
* - `granted` — The browser returned fresh coordinates successfully.
|
|
29
|
+
* - `denied` — The user blocked location access (PERMISSION_DENIED).
|
|
30
|
+
* - `error` — The request failed for another reason (timeout, position unavailable, or geolocation unsupported).
|
|
31
|
+
* - `skipped_cache` — Permission was granted within the last 7 days; cached coordinates were reused without a new browser request.
|
|
32
|
+
*/
|
|
33
|
+
type GeolocationStatus = 'granted' | 'denied' | 'error' | 'skipped_cache';
|
|
34
|
+
|
|
11
35
|
let currentCoordinates: Coordinates | null = null;
|
|
12
36
|
|
|
13
|
-
// Constants for local storage keys and time limits
|
|
14
37
|
const GEOLOCATION_PERMISSION_KEY = 'geolocationPermissionTimestamp';
|
|
38
|
+
const GEOLOCATION_CACHE_KEY = 'geolocationCache';
|
|
39
|
+
const GEOLOCATION_TRACK_EVENT = 'Geolocation Requested';
|
|
15
40
|
const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
41
|
+
/** Max wait for a high-accuracy position fix before the browser reports TIMEOUT. */
|
|
42
|
+
const GEOLOCATION_REQUEST_TIMEOUT_MS = 10_000;
|
|
43
|
+
const GEOLOCATION_OPTIONS: PositionOptions = {
|
|
44
|
+
enableHighAccuracy: true,
|
|
45
|
+
timeout: GEOLOCATION_REQUEST_TIMEOUT_MS,
|
|
46
|
+
maximumAge: 0,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads a value from localStorage, returning null when storage is unavailable.
|
|
51
|
+
* @param {string} key - Storage key to read.
|
|
52
|
+
* @returns {string | null} Stored value or null.
|
|
53
|
+
*/
|
|
54
|
+
function safeGetItem(key: string): string | null {
|
|
55
|
+
try {
|
|
56
|
+
return localStorage.getItem(key);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Writes a value to localStorage, ignoring failures when storage is unavailable.
|
|
64
|
+
* @param {string} key - Storage key to write.
|
|
65
|
+
* @param {string} value - Value to persist.
|
|
66
|
+
*/
|
|
67
|
+
function safeSetItem(key: string, value: string): void {
|
|
68
|
+
try {
|
|
69
|
+
localStorage.setItem(key, value);
|
|
70
|
+
} catch {
|
|
71
|
+
// Storage may be blocked (private mode, quota exceeded, etc.)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Converts a Unix timestamp to an ISO 8601 string.
|
|
77
|
+
* @param {number} timestamp - Unix timestamp in milliseconds.
|
|
78
|
+
* @returns {string} ISO 8601 formatted timestamp.
|
|
79
|
+
*/
|
|
80
|
+
function toISO(timestamp: number): string {
|
|
81
|
+
return new Date(timestamp).toISOString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validates parsed localStorage data against the geolocation cache schema.
|
|
86
|
+
* @param {unknown} value - Parsed JSON value.
|
|
87
|
+
* @returns {value is GeolocationCache} Whether the value is a valid cache entry.
|
|
88
|
+
*/
|
|
89
|
+
function isGeolocationCache(value: unknown): value is GeolocationCache {
|
|
90
|
+
if (typeof value !== 'object' || value === null) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cache = value as Record<string, unknown>;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
typeof cache.lat === 'number' &&
|
|
98
|
+
typeof cache.long === 'number' &&
|
|
99
|
+
typeof cache.updatedAt === 'string' &&
|
|
100
|
+
(cache.accuracy === undefined || typeof cache.accuracy === 'number')
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Loads persisted geolocation coordinates from local storage.
|
|
106
|
+
* @returns {GeolocationCache | null} Cached coordinates or null if unavailable or invalid.
|
|
107
|
+
*/
|
|
108
|
+
function loadCoordinatesFromStorage(): GeolocationCache | null {
|
|
109
|
+
const raw = safeGetItem(GEOLOCATION_CACHE_KEY);
|
|
110
|
+
|
|
111
|
+
if (!raw) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const parsed: unknown = JSON.parse(raw);
|
|
117
|
+
return isGeolocationCache(parsed) ? parsed : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Persists geolocation coordinates and their last update timestamp.
|
|
125
|
+
* @param {Coordinates} coords - The coordinates to persist.
|
|
126
|
+
* @param {string} updatedAt - ISO timestamp of the last update.
|
|
127
|
+
* @param {number} [accuracy] - Accuracy radius in meters reported by the browser.
|
|
128
|
+
*/
|
|
129
|
+
function saveCoordinatesToStorage(
|
|
130
|
+
coords: Coordinates,
|
|
131
|
+
updatedAt: string,
|
|
132
|
+
accuracy?: number,
|
|
133
|
+
): void {
|
|
134
|
+
safeSetItem(
|
|
135
|
+
GEOLOCATION_CACHE_KEY,
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
lat: coords.lat,
|
|
138
|
+
long: coords.long,
|
|
139
|
+
updatedAt,
|
|
140
|
+
...(accuracy !== undefined && { accuracy }),
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Restores in-memory coordinates from local storage when available.
|
|
147
|
+
*/
|
|
148
|
+
function restoreCoordinatesFromStorage(): void {
|
|
149
|
+
const cache = loadCoordinatesFromStorage();
|
|
150
|
+
|
|
151
|
+
if (cache) {
|
|
152
|
+
currentCoordinates = { lat: cache.lat, long: cache.long };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
16
155
|
|
|
17
156
|
/**
|
|
18
157
|
* Checks if the geolocation permission timestamp is still valid.
|
|
@@ -32,60 +171,167 @@ function isPermissionTimestampValid(
|
|
|
32
171
|
* @param {number} timestamp - The current timestamp to store.
|
|
33
172
|
*/
|
|
34
173
|
function updatePermissionTimestamp(timestamp: number): void {
|
|
35
|
-
|
|
174
|
+
safeSetItem(GEOLOCATION_PERMISSION_KEY, timestamp.toString());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Maps a GeolocationPositionError to a geolocation status.
|
|
179
|
+
* Permission denial maps to `denied`; timeout and position unavailable map to `error`.
|
|
180
|
+
* @param {GeolocationPositionError} error - The geolocation error returned by the browser.
|
|
181
|
+
* @returns {GeolocationStatus} The normalized geolocation status.
|
|
182
|
+
*/
|
|
183
|
+
function getGeolocationStatus(
|
|
184
|
+
error: GeolocationPositionError,
|
|
185
|
+
): GeolocationStatus {
|
|
186
|
+
return error.code === error.PERMISSION_DENIED ? 'denied' : 'error';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Builds the geolocation properties sent with the Geolocation Requested event.
|
|
191
|
+
* @param {GeolocationTrackingData} data - Geolocation outcome metadata.
|
|
192
|
+
* @returns {Record<string, string | number>} Properties to send to Mixpanel.
|
|
193
|
+
*/
|
|
194
|
+
function buildGeolocationEventProperties(
|
|
195
|
+
data: GeolocationTrackingData,
|
|
196
|
+
): Record<string, string | number> {
|
|
197
|
+
const properties: Record<string, string | number> = {
|
|
198
|
+
'Geolocation Updated At': data.updatedAt,
|
|
199
|
+
'Geolocation Status': data.status,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
if (data.accuracy !== undefined) {
|
|
203
|
+
properties['Geolocation Accuracy'] = data.accuracy;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (data.errorCode !== undefined) {
|
|
207
|
+
properties['Geolocation Error Code'] = data.errorCode;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const coordinates = getCoordinates();
|
|
211
|
+
if (coordinates) {
|
|
212
|
+
properties.$latitude = coordinates.lat;
|
|
213
|
+
properties.$longitude = coordinates.long;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return properties;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Builds the geolocation properties synced to the Mixpanel user profile.
|
|
221
|
+
* Diagnostic fields such as accuracy and error codes are excluded.
|
|
222
|
+
* @param {GeolocationTrackingData} data - Geolocation outcome metadata.
|
|
223
|
+
* @returns {Record<string, string | number>} Profile properties to set on the user.
|
|
224
|
+
*/
|
|
225
|
+
function buildGeolocationProfileProperties(
|
|
226
|
+
data: GeolocationTrackingData,
|
|
227
|
+
): Record<string, string | number> {
|
|
228
|
+
const properties: Record<string, string | number> = {
|
|
229
|
+
'Geolocation Updated At': data.updatedAt,
|
|
230
|
+
'Geolocation Status': data.status,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const coordinates = getCoordinates();
|
|
234
|
+
if (coordinates) {
|
|
235
|
+
properties.$latitude = coordinates.lat;
|
|
236
|
+
properties.$longitude = coordinates.long;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return properties;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Tracks the geolocation outcome and updates the Mixpanel user profile.
|
|
244
|
+
* @param {GeolocationTrackingData} data - Geolocation outcome metadata.
|
|
245
|
+
*/
|
|
246
|
+
function syncGeolocationToMixpanel(data: GeolocationTrackingData): void {
|
|
247
|
+
if (!isTrackerReady()) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const eventProperties = buildGeolocationEventProperties(data);
|
|
252
|
+
const profileProperties = buildGeolocationProfileProperties(data);
|
|
253
|
+
trackCustomEvent(GEOLOCATION_TRACK_EVENT, eventProperties);
|
|
254
|
+
mixpanelService.setPeopleProperties(profileProperties);
|
|
36
255
|
}
|
|
37
256
|
|
|
38
257
|
/**
|
|
39
|
-
* Initializes geolocation
|
|
40
|
-
*
|
|
258
|
+
* Initializes geolocation, requests coordinates when the cache window expired,
|
|
259
|
+
* and syncs the outcome to Mixpanel. The returned promise always resolves.
|
|
260
|
+
* @returns {Promise<Coordinates | null>} Cached or fresh coordinates, or null on failure.
|
|
41
261
|
*/
|
|
42
262
|
function init(): Promise<Coordinates | null> {
|
|
263
|
+
restoreCoordinatesFromStorage();
|
|
264
|
+
|
|
43
265
|
return new Promise((resolve) => {
|
|
44
|
-
const lastPermissionTimestamp =
|
|
45
|
-
GEOLOCATION_PERMISSION_KEY,
|
|
46
|
-
);
|
|
266
|
+
const lastPermissionTimestamp = safeGetItem(GEOLOCATION_PERMISSION_KEY);
|
|
47
267
|
|
|
48
268
|
if (isPermissionTimestampValid(lastPermissionTimestamp)) {
|
|
269
|
+
const cache = loadCoordinatesFromStorage();
|
|
270
|
+
const updatedAt = cache?.updatedAt
|
|
271
|
+
?? (lastPermissionTimestamp
|
|
272
|
+
? toISO(parseInt(lastPermissionTimestamp, 10))
|
|
273
|
+
: new Date().toISOString());
|
|
274
|
+
|
|
275
|
+
syncGeolocationToMixpanel({
|
|
276
|
+
status: 'skipped_cache',
|
|
277
|
+
updatedAt,
|
|
278
|
+
accuracy: cache?.accuracy,
|
|
279
|
+
});
|
|
49
280
|
resolve(currentCoordinates);
|
|
50
281
|
return;
|
|
51
282
|
}
|
|
52
283
|
|
|
53
|
-
const GEOLOCATION_TRACK_EVENT = 'Geolocation Requested';
|
|
54
284
|
const now = Date.now();
|
|
285
|
+
const updatedAt = toISO(now);
|
|
286
|
+
|
|
287
|
+
if (!navigator.geolocation?.getCurrentPosition) {
|
|
288
|
+
syncGeolocationToMixpanel({ status: 'error', updatedAt });
|
|
289
|
+
resolve(null);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
55
292
|
|
|
56
293
|
navigator.geolocation.getCurrentPosition(
|
|
57
294
|
(geolocation) => {
|
|
58
|
-
const { latitude: lat, longitude: long } = geolocation.coords;
|
|
295
|
+
const { latitude: lat, longitude: long, accuracy } = geolocation.coords;
|
|
59
296
|
currentCoordinates = { lat, long };
|
|
297
|
+
saveCoordinatesToStorage(currentCoordinates, updatedAt, accuracy);
|
|
60
298
|
updatePermissionTimestamp(now);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
299
|
+
|
|
300
|
+
syncGeolocationToMixpanel({
|
|
301
|
+
status: 'granted',
|
|
302
|
+
updatedAt,
|
|
303
|
+
accuracy,
|
|
304
|
+
});
|
|
66
305
|
resolve(currentCoordinates);
|
|
67
306
|
},
|
|
68
|
-
() => {
|
|
307
|
+
(error) => {
|
|
308
|
+
const status = getGeolocationStatus(error);
|
|
69
309
|
updatePermissionTimestamp(now);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
310
|
+
|
|
311
|
+
syncGeolocationToMixpanel({
|
|
312
|
+
status,
|
|
313
|
+
updatedAt,
|
|
314
|
+
errorCode: error.code,
|
|
315
|
+
});
|
|
75
316
|
resolve(null);
|
|
76
317
|
},
|
|
318
|
+
GEOLOCATION_OPTIONS,
|
|
77
319
|
);
|
|
78
320
|
});
|
|
79
321
|
}
|
|
80
322
|
|
|
81
323
|
/**
|
|
82
|
-
* Returns the currently cached coordinates or null if no coordinates are available
|
|
83
|
-
* @returns {Coordinates | null} The cached coordinates or null if unavailable
|
|
324
|
+
* Returns the currently cached coordinates or null if no coordinates are available.
|
|
325
|
+
* @returns {Coordinates | null} The cached coordinates or null if unavailable.
|
|
84
326
|
*/
|
|
85
327
|
function getCoordinates(): Coordinates | null {
|
|
86
328
|
return currentCoordinates;
|
|
87
329
|
}
|
|
88
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Geolocation service for tracking user location with caching support.
|
|
333
|
+
* Handles permission requests, coordinate retrieval, and analytics sync to Mixpanel.
|
|
334
|
+
*/
|
|
89
335
|
const geolocationService = {
|
|
90
336
|
init,
|
|
91
337
|
getCoordinates,
|
package/src/services/mixpanel.ts
CHANGED
|
@@ -62,6 +62,18 @@ function attachProperty(key: string, value: unknown): void {
|
|
|
62
62
|
mixpanel.people.union(key, [value]);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Sets people properties for the current Mixpanel user without changing their distinct ID.
|
|
67
|
+
* @param {Record<string, unknown>} properties - Profile properties to set on the current user.
|
|
68
|
+
*/
|
|
69
|
+
function setPeopleProperties(properties: Record<string, unknown>): void {
|
|
70
|
+
if (!isReady()) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
mixpanel.people.set(properties);
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
/**
|
|
66
78
|
* Checks if the Mixpanel tracker is ready.
|
|
67
79
|
* @returns {boolean} Returns true if the Mixpanel tracker is ready, otherwise false.
|
|
@@ -118,6 +130,7 @@ const mixpanelService = {
|
|
|
118
130
|
track,
|
|
119
131
|
identify,
|
|
120
132
|
attachProperty,
|
|
133
|
+
setPeopleProperties,
|
|
121
134
|
getMixpanelDistinctId,
|
|
122
135
|
};
|
|
123
136
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z, ZodError, ZodSchema } from 'zod';
|
|
2
2
|
import { customEventSchema } from '@/events/customEvent/customEventSchema';
|
|
3
|
+
import { geolocationSelectedSchema } from '@/events/geolocationSelected';
|
|
3
4
|
import IdentifySchema from '@/events/identify/identifySchema';
|
|
4
5
|
import { interestInHomeSchema } from '@/events/interestInHome';
|
|
5
6
|
import { interestInSearchSchema } from '@/events/interestInSearch';
|
|
@@ -94,6 +95,7 @@ const eventSchemas: Record<string, z.ZodSchema> = {
|
|
|
94
95
|
'View Results': viewResultsSchema,
|
|
95
96
|
'Purchase Attempt': purchaseAttemptSchema,
|
|
96
97
|
'Picked Departure': pickedDepartureSchema,
|
|
98
|
+
'Geolocation Selected': geolocationSelectedSchema,
|
|
97
99
|
};
|
|
98
100
|
|
|
99
101
|
type EventDataSchema = z.infer<
|