@xray-analytics/analytics-react 0.0.2

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.
@@ -0,0 +1,84 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { PropsWithChildren } from 'react';
3
+
4
+ type Transport = 'auto' | 'bff' | 'direct';
5
+ type AnalyticsEnvironment = 'local' | 'dev' | 'production';
6
+ type TrackProps = Record<string, unknown>;
7
+ type TrackTags = string[];
8
+ type TrackEventName = 'page_view' | 'click_link' | 'redirect' | 'click_button' | 'scroll' | 'element_view' | (string & {});
9
+ type TrackCatalogEntry = {
10
+ trackName: string;
11
+ validateOn?: 'props' | 'event';
12
+ version?: number;
13
+ description?: string;
14
+ tags?: string[];
15
+ deprecated?: boolean;
16
+ schema?: Record<string, unknown>;
17
+ };
18
+ type AnalyticsTrackMetadataConfig = {
19
+ enabled?: boolean;
20
+ includeIp?: boolean;
21
+ includeUserAgent?: boolean;
22
+ includeDevice?: boolean;
23
+ includeLanguage?: boolean;
24
+ includeScreen?: boolean;
25
+ staticIp?: string;
26
+ resolveIp?: () => Promise<string | undefined>;
27
+ };
28
+ type AnalyticsTrackClientMetadata = {
29
+ ip?: string;
30
+ userAgent?: string;
31
+ isMobile?: boolean;
32
+ os?: 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
33
+ platform?: string;
34
+ language?: string;
35
+ screen?: {
36
+ width: number;
37
+ height: number;
38
+ };
39
+ };
40
+ type TrackOptions = {
41
+ metadata?: boolean | AnalyticsTrackMetadataConfig;
42
+ };
43
+ type AnalyticsProviderProps = PropsWithChildren<{
44
+ appId: string;
45
+ transport?: Transport;
46
+ bffEndpoint?: string;
47
+ directEndpoint?: string;
48
+ writeKey?: string;
49
+ environment?: AnalyticsEnvironment | string;
50
+ autoPageViews?: boolean;
51
+ debug?: boolean;
52
+ preferSendBeacon?: boolean;
53
+ metadata?: boolean | AnalyticsTrackMetadataConfig;
54
+ catalog?: TrackCatalogEntry[];
55
+ catalogEndpoint?: string;
56
+ strictCatalog?: boolean;
57
+ }>;
58
+ type SendTrack = (name: TrackEventName, props?: TrackProps, tags?: TrackTags, options?: TrackOptions) => void;
59
+ type AnalyticsContextValue = {
60
+ track: SendTrack;
61
+ sendTrack: SendTrack;
62
+ trackPageView: (props?: TrackProps, tags?: TrackTags) => void;
63
+ trackClickLink: (props?: TrackProps, tags?: TrackTags) => void;
64
+ trackRedirect: (props?: TrackProps, tags?: TrackTags) => void;
65
+ trackClickButton: (props?: TrackProps, tags?: TrackTags) => void;
66
+ trackScroll: (props?: TrackProps, tags?: TrackTags) => void;
67
+ trackElementView: (props?: TrackProps, tags?: TrackTags) => void;
68
+ };
69
+ type TrackPageViewProps = {
70
+ props?: TrackProps;
71
+ trackOnPopState?: boolean;
72
+ };
73
+
74
+ declare function AnalyticsProvider({ children, appId, transport, bffEndpoint, directEndpoint, writeKey, environment, autoPageViews, debug, preferSendBeacon, metadata, catalog, catalogEndpoint, strictCatalog, }: AnalyticsProviderProps): react_jsx_runtime.JSX.Element;
75
+
76
+ declare function useTrackPageView(props?: TrackProps, trackOnPopState?: boolean): void;
77
+ declare function TrackPageView({ props, trackOnPopState }: TrackPageViewProps): null;
78
+
79
+ type UseAnalyticsOptions = {
80
+ metadata?: boolean | AnalyticsTrackMetadataConfig;
81
+ };
82
+ declare function useAnalytics(options?: UseAnalyticsOptions): AnalyticsContextValue;
83
+
84
+ export { type AnalyticsEnvironment, AnalyticsProvider, type AnalyticsProviderProps, type AnalyticsTrackClientMetadata, type AnalyticsTrackMetadataConfig, type SendTrack, type TrackCatalogEntry, type TrackEventName, type TrackOptions, TrackPageView, type TrackPageViewProps, type TrackProps, type Transport, useAnalytics, useTrackPageView };
@@ -0,0 +1,84 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { PropsWithChildren } from 'react';
3
+
4
+ type Transport = 'auto' | 'bff' | 'direct';
5
+ type AnalyticsEnvironment = 'local' | 'dev' | 'production';
6
+ type TrackProps = Record<string, unknown>;
7
+ type TrackTags = string[];
8
+ type TrackEventName = 'page_view' | 'click_link' | 'redirect' | 'click_button' | 'scroll' | 'element_view' | (string & {});
9
+ type TrackCatalogEntry = {
10
+ trackName: string;
11
+ validateOn?: 'props' | 'event';
12
+ version?: number;
13
+ description?: string;
14
+ tags?: string[];
15
+ deprecated?: boolean;
16
+ schema?: Record<string, unknown>;
17
+ };
18
+ type AnalyticsTrackMetadataConfig = {
19
+ enabled?: boolean;
20
+ includeIp?: boolean;
21
+ includeUserAgent?: boolean;
22
+ includeDevice?: boolean;
23
+ includeLanguage?: boolean;
24
+ includeScreen?: boolean;
25
+ staticIp?: string;
26
+ resolveIp?: () => Promise<string | undefined>;
27
+ };
28
+ type AnalyticsTrackClientMetadata = {
29
+ ip?: string;
30
+ userAgent?: string;
31
+ isMobile?: boolean;
32
+ os?: 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
33
+ platform?: string;
34
+ language?: string;
35
+ screen?: {
36
+ width: number;
37
+ height: number;
38
+ };
39
+ };
40
+ type TrackOptions = {
41
+ metadata?: boolean | AnalyticsTrackMetadataConfig;
42
+ };
43
+ type AnalyticsProviderProps = PropsWithChildren<{
44
+ appId: string;
45
+ transport?: Transport;
46
+ bffEndpoint?: string;
47
+ directEndpoint?: string;
48
+ writeKey?: string;
49
+ environment?: AnalyticsEnvironment | string;
50
+ autoPageViews?: boolean;
51
+ debug?: boolean;
52
+ preferSendBeacon?: boolean;
53
+ metadata?: boolean | AnalyticsTrackMetadataConfig;
54
+ catalog?: TrackCatalogEntry[];
55
+ catalogEndpoint?: string;
56
+ strictCatalog?: boolean;
57
+ }>;
58
+ type SendTrack = (name: TrackEventName, props?: TrackProps, tags?: TrackTags, options?: TrackOptions) => void;
59
+ type AnalyticsContextValue = {
60
+ track: SendTrack;
61
+ sendTrack: SendTrack;
62
+ trackPageView: (props?: TrackProps, tags?: TrackTags) => void;
63
+ trackClickLink: (props?: TrackProps, tags?: TrackTags) => void;
64
+ trackRedirect: (props?: TrackProps, tags?: TrackTags) => void;
65
+ trackClickButton: (props?: TrackProps, tags?: TrackTags) => void;
66
+ trackScroll: (props?: TrackProps, tags?: TrackTags) => void;
67
+ trackElementView: (props?: TrackProps, tags?: TrackTags) => void;
68
+ };
69
+ type TrackPageViewProps = {
70
+ props?: TrackProps;
71
+ trackOnPopState?: boolean;
72
+ };
73
+
74
+ declare function AnalyticsProvider({ children, appId, transport, bffEndpoint, directEndpoint, writeKey, environment, autoPageViews, debug, preferSendBeacon, metadata, catalog, catalogEndpoint, strictCatalog, }: AnalyticsProviderProps): react_jsx_runtime.JSX.Element;
75
+
76
+ declare function useTrackPageView(props?: TrackProps, trackOnPopState?: boolean): void;
77
+ declare function TrackPageView({ props, trackOnPopState }: TrackPageViewProps): null;
78
+
79
+ type UseAnalyticsOptions = {
80
+ metadata?: boolean | AnalyticsTrackMetadataConfig;
81
+ };
82
+ declare function useAnalytics(options?: UseAnalyticsOptions): AnalyticsContextValue;
83
+
84
+ export { type AnalyticsEnvironment, AnalyticsProvider, type AnalyticsProviderProps, type AnalyticsTrackClientMetadata, type AnalyticsTrackMetadataConfig, type SendTrack, type TrackCatalogEntry, type TrackEventName, type TrackOptions, TrackPageView, type TrackPageViewProps, type TrackProps, type Transport, useAnalytics, useTrackPageView };
package/dist/index.js ADDED
@@ -0,0 +1,360 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.tsx
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ AnalyticsProvider: () => AnalyticsProvider,
24
+ TrackPageView: () => TrackPageView,
25
+ useAnalytics: () => useAnalytics,
26
+ useTrackPageView: () => useTrackPageView
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/core/provider.tsx
31
+ var import_react2 = require("react");
32
+
33
+ // src/core/context.ts
34
+ var import_react = require("react");
35
+ var AnalyticsContext = (0, import_react.createContext)(null);
36
+
37
+ // src/runtime/catalog.ts
38
+ function createCatalogMap(catalog) {
39
+ return new Map(catalog.map((entry) => [entry.trackName, entry]));
40
+ }
41
+ async function fetchCatalog(endpoint) {
42
+ const response = await fetch(endpoint, { method: "GET", cache: "no-store" });
43
+ if (!response.ok) {
44
+ throw new Error(`Failed to fetch track catalog (${response.status})`);
45
+ }
46
+ const payload = await response.json();
47
+ if (!payload.ok || !payload.data?.tracks) {
48
+ throw new Error("Invalid track catalog response");
49
+ }
50
+ return payload.data.tracks;
51
+ }
52
+
53
+ // src/runtime/environment.ts
54
+ function normalizeEnvironment(environment) {
55
+ if (!environment) return "production";
56
+ const env = environment.toLowerCase();
57
+ if (env === "production" || env === "prod") return "production";
58
+ if (env === "development" || env === "dev") return "dev";
59
+ return "local";
60
+ }
61
+
62
+ // src/runtime/metadata.ts
63
+ function detectOs(userAgent) {
64
+ const ua = userAgent.toLowerCase();
65
+ if (/android/.test(ua)) return "android";
66
+ if (/iphone|ipad|ipod/.test(ua)) return "ios";
67
+ if (/mac os x|macintosh/.test(ua)) return "macos";
68
+ if (/windows nt/.test(ua)) return "windows";
69
+ if (/linux/.test(ua)) return "linux";
70
+ return "unknown";
71
+ }
72
+ function normalizeMetadataConfig(metadata) {
73
+ if (!metadata) return null;
74
+ if (metadata === true) {
75
+ return {
76
+ enabled: true,
77
+ includeIp: true,
78
+ includeUserAgent: true,
79
+ includeDevice: true,
80
+ includeLanguage: true,
81
+ includeScreen: true
82
+ };
83
+ }
84
+ return {
85
+ enabled: metadata.enabled ?? true,
86
+ includeIp: metadata.includeIp ?? true,
87
+ includeUserAgent: metadata.includeUserAgent ?? true,
88
+ includeDevice: metadata.includeDevice ?? true,
89
+ includeLanguage: metadata.includeLanguage ?? true,
90
+ includeScreen: metadata.includeScreen ?? true,
91
+ staticIp: metadata.staticIp,
92
+ resolveIp: metadata.resolveIp
93
+ };
94
+ }
95
+ async function collectTrackClientMetadata(metadata) {
96
+ if (typeof window === "undefined" || typeof navigator === "undefined") return void 0;
97
+ const config = normalizeMetadataConfig(metadata);
98
+ if (!config || !config.enabled) return void 0;
99
+ const userAgent = navigator.userAgent;
100
+ const os = detectOs(userAgent);
101
+ const isMobile = /iphone|ipad|ipod|android|mobile/.test(userAgent.toLowerCase());
102
+ let ip;
103
+ if (config.includeIp) {
104
+ if (config.staticIp) {
105
+ ip = config.staticIp;
106
+ } else if (config.resolveIp) {
107
+ ip = await config.resolveIp().catch(() => void 0);
108
+ }
109
+ }
110
+ const metadataPayload = {
111
+ ip,
112
+ userAgent: config.includeUserAgent ? userAgent : void 0,
113
+ isMobile: config.includeDevice ? isMobile : void 0,
114
+ os: config.includeDevice ? os : void 0,
115
+ platform: config.includeDevice ? navigator.platform : void 0,
116
+ language: config.includeLanguage ? navigator.language : void 0,
117
+ screen: config.includeScreen && window.screen ? {
118
+ width: window.screen.width,
119
+ height: window.screen.height
120
+ } : void 0
121
+ };
122
+ const hasValue = Object.values(metadataPayload).some((value) => value !== void 0);
123
+ return hasValue ? metadataPayload : void 0;
124
+ }
125
+
126
+ // src/runtime/session.ts
127
+ function getOrCreateSessionId() {
128
+ const key = "xray_session_id";
129
+ let id = localStorage.getItem(key);
130
+ if (!id) {
131
+ id = crypto.randomUUID();
132
+ localStorage.setItem(key, id);
133
+ }
134
+ return id;
135
+ }
136
+
137
+ // src/runtime/transport.ts
138
+ async function sendBeaconFirst(url, payload, options) {
139
+ const preferBeacon = options?.preferBeacon ?? true;
140
+ if (preferBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
141
+ try {
142
+ const ok = navigator.sendBeacon(url, new Blob([payload], { type: "application/json" }));
143
+ if (ok) return { ok: true, status: 204 };
144
+ } catch {
145
+ }
146
+ }
147
+ try {
148
+ const res = await fetch(url, {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json" },
151
+ body: payload,
152
+ keepalive: true
153
+ });
154
+ return { ok: res.ok, status: res.status };
155
+ } catch {
156
+ return { ok: false, status: 0 };
157
+ }
158
+ }
159
+
160
+ // src/core/provider.tsx
161
+ var import_jsx_runtime = require("react/jsx-runtime");
162
+ function AnalyticsProvider({
163
+ children,
164
+ appId,
165
+ transport = "auto",
166
+ bffEndpoint = "/api/track",
167
+ directEndpoint,
168
+ writeKey,
169
+ environment = "production",
170
+ autoPageViews = true,
171
+ debug = false,
172
+ preferSendBeacon = true,
173
+ metadata,
174
+ catalog,
175
+ catalogEndpoint,
176
+ strictCatalog = false
177
+ }) {
178
+ const sessionIdRef = (0, import_react2.useRef)(null);
179
+ const catalogMapRef = (0, import_react2.useRef)(
180
+ catalog ? createCatalogMap(catalog) : null
181
+ );
182
+ if (typeof window !== "undefined" && !sessionIdRef.current) {
183
+ sessionIdRef.current = getOrCreateSessionId();
184
+ }
185
+ (0, import_react2.useEffect)(() => {
186
+ if (!catalog) return;
187
+ catalogMapRef.current = createCatalogMap(catalog);
188
+ }, [catalog]);
189
+ (0, import_react2.useEffect)(() => {
190
+ if (!catalogEndpoint || typeof window === "undefined") return;
191
+ let cancelled = false;
192
+ fetchCatalog(catalogEndpoint).then((tracks) => {
193
+ if (cancelled) return;
194
+ catalogMapRef.current = createCatalogMap(tracks);
195
+ }).catch((error) => {
196
+ if (!debug) return;
197
+ console.warn(
198
+ "[xray] failed to load track catalog from endpoint",
199
+ error instanceof Error ? error.message : error
200
+ );
201
+ });
202
+ return () => {
203
+ cancelled = true;
204
+ };
205
+ }, [catalogEndpoint, debug]);
206
+ const base = (0, import_react2.useMemo)(
207
+ () => ({
208
+ appId,
209
+ sessionId: sessionIdRef.current ?? "unknown"
210
+ }),
211
+ [appId]
212
+ );
213
+ const sendTrack = (0, import_react2.useCallback)(
214
+ (name, props, tags, options) => {
215
+ if (typeof window === "undefined") return;
216
+ const resolvedEnvironment = normalizeEnvironment(environment);
217
+ const catalogMap = catalogMapRef.current;
218
+ if (catalogMap) {
219
+ const entry = catalogMap.get(name);
220
+ if (!entry) {
221
+ if (debug) {
222
+ console.warn(`[xray] track '${name}' does not exist in the loaded catalog`);
223
+ }
224
+ if (strictCatalog) return;
225
+ } else if (entry.deprecated && debug) {
226
+ console.warn(`[xray] track '${name}' is marked as deprecated in the catalog`);
227
+ }
228
+ }
229
+ (async () => {
230
+ const metadataConfig = (() => {
231
+ if (options?.metadata === void 0) return metadata;
232
+ if (options.metadata === true || options.metadata === false) return options.metadata;
233
+ const baseMetadataConfig = typeof metadata === "object" && metadata ? metadata : {};
234
+ return {
235
+ ...baseMetadataConfig,
236
+ ...options.metadata
237
+ };
238
+ })();
239
+ const clientMeta = await collectTrackClientMetadata(metadataConfig);
240
+ const event = {
241
+ name,
242
+ ts: Date.now(),
243
+ url: window.location.href,
244
+ path: window.location.pathname,
245
+ ref: document.referrer || void 0,
246
+ environment: resolvedEnvironment,
247
+ ...base,
248
+ props: props ?? void 0,
249
+ tags: tags ?? void 0,
250
+ clientMeta: clientMeta ?? void 0,
251
+ writeKey: writeKey ?? void 0
252
+ };
253
+ if (resolvedEnvironment !== "production") {
254
+ console.log("[xray][track]", event);
255
+ return;
256
+ }
257
+ const payload = JSON.stringify(event);
258
+ const sendWithTransport = (url) => preferSendBeacon ? sendBeaconFirst(url, payload) : sendBeaconFirst(url, payload, { preferBeacon: false });
259
+ if (transport === "bff") {
260
+ await sendWithTransport(bffEndpoint);
261
+ return;
262
+ }
263
+ if (transport === "direct") {
264
+ if (!directEndpoint) return;
265
+ await sendWithTransport(directEndpoint);
266
+ return;
267
+ }
268
+ const r1 = await sendWithTransport(bffEndpoint).catch(() => null);
269
+ if (r1?.ok) return;
270
+ if (directEndpoint) {
271
+ await sendWithTransport(directEndpoint).catch(() => null);
272
+ }
273
+ })().catch(() => {
274
+ });
275
+ },
276
+ [
277
+ base,
278
+ transport,
279
+ bffEndpoint,
280
+ directEndpoint,
281
+ writeKey,
282
+ debug,
283
+ environment,
284
+ strictCatalog,
285
+ preferSendBeacon,
286
+ metadata
287
+ ]
288
+ );
289
+ (0, import_react2.useEffect)(() => {
290
+ if (!autoPageViews || typeof window === "undefined") return;
291
+ sendTrack("page_view");
292
+ const notify = () => sendTrack("page_view");
293
+ window.addEventListener("popstate", notify);
294
+ return () => window.removeEventListener("popstate", notify);
295
+ }, [autoPageViews, sendTrack]);
296
+ const value = (0, import_react2.useMemo)(
297
+ () => ({
298
+ track: sendTrack,
299
+ sendTrack,
300
+ trackPageView: (props, tags) => sendTrack("page_view", props, tags),
301
+ trackClickLink: (props, tags) => sendTrack("click_link", props, tags),
302
+ trackRedirect: (props, tags) => sendTrack("redirect", props, tags),
303
+ trackClickButton: (props, tags) => sendTrack("click_button", props, tags),
304
+ trackScroll: (props, tags) => sendTrack("scroll", props, tags),
305
+ trackElementView: (props, tags) => sendTrack("element_view", props, tags)
306
+ }),
307
+ [sendTrack]
308
+ );
309
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AnalyticsContext.Provider, { value, children });
310
+ }
311
+
312
+ // src/hooks/track-page-view.tsx
313
+ var import_react4 = require("react");
314
+
315
+ // src/hooks/use-analytics.ts
316
+ var import_react3 = require("react");
317
+ function useAnalytics(options) {
318
+ const ctx = (0, import_react3.useContext)(AnalyticsContext);
319
+ if (!ctx) throw new Error("useAnalytics must be used within AnalyticsProvider");
320
+ const metadataOption = options?.metadata;
321
+ const wrapped = (0, import_react3.useMemo)(
322
+ () => ({
323
+ ...ctx,
324
+ track: (name, props, tags) => ctx.track(name, props, tags, { metadata: metadataOption }),
325
+ sendTrack: (name, props, tags) => ctx.sendTrack(name, props, tags, { metadata: metadataOption }),
326
+ trackPageView: (props, tags) => ctx.track("page_view", props, tags, { metadata: metadataOption }),
327
+ trackClickLink: (props, tags) => ctx.track("click_link", props, tags, { metadata: metadataOption }),
328
+ trackRedirect: (props, tags) => ctx.track("redirect", props, tags, { metadata: metadataOption }),
329
+ trackClickButton: (props, tags) => ctx.track("click_button", props, tags, { metadata: metadataOption }),
330
+ trackScroll: (props, tags) => ctx.track("scroll", props, tags, { metadata: metadataOption }),
331
+ trackElementView: (props, tags) => ctx.track("element_view", props, tags, { metadata: metadataOption })
332
+ }),
333
+ [ctx, metadataOption]
334
+ );
335
+ return metadataOption ? wrapped : ctx;
336
+ }
337
+
338
+ // src/hooks/track-page-view.tsx
339
+ function useTrackPageView(props, trackOnPopState = true) {
340
+ const { trackPageView } = useAnalytics();
341
+ (0, import_react4.useEffect)(() => {
342
+ if (typeof window === "undefined") return;
343
+ trackPageView(props);
344
+ if (!trackOnPopState) return;
345
+ const notify = () => trackPageView(props);
346
+ window.addEventListener("popstate", notify);
347
+ return () => window.removeEventListener("popstate", notify);
348
+ }, [trackPageView, props, trackOnPopState]);
349
+ }
350
+ function TrackPageView({ props, trackOnPopState = true }) {
351
+ useTrackPageView(props, trackOnPopState);
352
+ return null;
353
+ }
354
+ // Annotate the CommonJS export names for ESM import in node:
355
+ 0 && (module.exports = {
356
+ AnalyticsProvider,
357
+ TrackPageView,
358
+ useAnalytics,
359
+ useTrackPageView
360
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,330 @@
1
+ // src/core/provider.tsx
2
+ import { useCallback, useEffect, useMemo, useRef } from "react";
3
+
4
+ // src/core/context.ts
5
+ import { createContext } from "react";
6
+ var AnalyticsContext = createContext(null);
7
+
8
+ // src/runtime/catalog.ts
9
+ function createCatalogMap(catalog) {
10
+ return new Map(catalog.map((entry) => [entry.trackName, entry]));
11
+ }
12
+ async function fetchCatalog(endpoint) {
13
+ const response = await fetch(endpoint, { method: "GET", cache: "no-store" });
14
+ if (!response.ok) {
15
+ throw new Error(`Failed to fetch track catalog (${response.status})`);
16
+ }
17
+ const payload = await response.json();
18
+ if (!payload.ok || !payload.data?.tracks) {
19
+ throw new Error("Invalid track catalog response");
20
+ }
21
+ return payload.data.tracks;
22
+ }
23
+
24
+ // src/runtime/environment.ts
25
+ function normalizeEnvironment(environment) {
26
+ if (!environment) return "production";
27
+ const env = environment.toLowerCase();
28
+ if (env === "production" || env === "prod") return "production";
29
+ if (env === "development" || env === "dev") return "dev";
30
+ return "local";
31
+ }
32
+
33
+ // src/runtime/metadata.ts
34
+ function detectOs(userAgent) {
35
+ const ua = userAgent.toLowerCase();
36
+ if (/android/.test(ua)) return "android";
37
+ if (/iphone|ipad|ipod/.test(ua)) return "ios";
38
+ if (/mac os x|macintosh/.test(ua)) return "macos";
39
+ if (/windows nt/.test(ua)) return "windows";
40
+ if (/linux/.test(ua)) return "linux";
41
+ return "unknown";
42
+ }
43
+ function normalizeMetadataConfig(metadata) {
44
+ if (!metadata) return null;
45
+ if (metadata === true) {
46
+ return {
47
+ enabled: true,
48
+ includeIp: true,
49
+ includeUserAgent: true,
50
+ includeDevice: true,
51
+ includeLanguage: true,
52
+ includeScreen: true
53
+ };
54
+ }
55
+ return {
56
+ enabled: metadata.enabled ?? true,
57
+ includeIp: metadata.includeIp ?? true,
58
+ includeUserAgent: metadata.includeUserAgent ?? true,
59
+ includeDevice: metadata.includeDevice ?? true,
60
+ includeLanguage: metadata.includeLanguage ?? true,
61
+ includeScreen: metadata.includeScreen ?? true,
62
+ staticIp: metadata.staticIp,
63
+ resolveIp: metadata.resolveIp
64
+ };
65
+ }
66
+ async function collectTrackClientMetadata(metadata) {
67
+ if (typeof window === "undefined" || typeof navigator === "undefined") return void 0;
68
+ const config = normalizeMetadataConfig(metadata);
69
+ if (!config || !config.enabled) return void 0;
70
+ const userAgent = navigator.userAgent;
71
+ const os = detectOs(userAgent);
72
+ const isMobile = /iphone|ipad|ipod|android|mobile/.test(userAgent.toLowerCase());
73
+ let ip;
74
+ if (config.includeIp) {
75
+ if (config.staticIp) {
76
+ ip = config.staticIp;
77
+ } else if (config.resolveIp) {
78
+ ip = await config.resolveIp().catch(() => void 0);
79
+ }
80
+ }
81
+ const metadataPayload = {
82
+ ip,
83
+ userAgent: config.includeUserAgent ? userAgent : void 0,
84
+ isMobile: config.includeDevice ? isMobile : void 0,
85
+ os: config.includeDevice ? os : void 0,
86
+ platform: config.includeDevice ? navigator.platform : void 0,
87
+ language: config.includeLanguage ? navigator.language : void 0,
88
+ screen: config.includeScreen && window.screen ? {
89
+ width: window.screen.width,
90
+ height: window.screen.height
91
+ } : void 0
92
+ };
93
+ const hasValue = Object.values(metadataPayload).some((value) => value !== void 0);
94
+ return hasValue ? metadataPayload : void 0;
95
+ }
96
+
97
+ // src/runtime/session.ts
98
+ function getOrCreateSessionId() {
99
+ const key = "xray_session_id";
100
+ let id = localStorage.getItem(key);
101
+ if (!id) {
102
+ id = crypto.randomUUID();
103
+ localStorage.setItem(key, id);
104
+ }
105
+ return id;
106
+ }
107
+
108
+ // src/runtime/transport.ts
109
+ async function sendBeaconFirst(url, payload, options) {
110
+ const preferBeacon = options?.preferBeacon ?? true;
111
+ if (preferBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
112
+ try {
113
+ const ok = navigator.sendBeacon(url, new Blob([payload], { type: "application/json" }));
114
+ if (ok) return { ok: true, status: 204 };
115
+ } catch {
116
+ }
117
+ }
118
+ try {
119
+ const res = await fetch(url, {
120
+ method: "POST",
121
+ headers: { "content-type": "application/json" },
122
+ body: payload,
123
+ keepalive: true
124
+ });
125
+ return { ok: res.ok, status: res.status };
126
+ } catch {
127
+ return { ok: false, status: 0 };
128
+ }
129
+ }
130
+
131
+ // src/core/provider.tsx
132
+ import { jsx } from "react/jsx-runtime";
133
+ function AnalyticsProvider({
134
+ children,
135
+ appId,
136
+ transport = "auto",
137
+ bffEndpoint = "/api/track",
138
+ directEndpoint,
139
+ writeKey,
140
+ environment = "production",
141
+ autoPageViews = true,
142
+ debug = false,
143
+ preferSendBeacon = true,
144
+ metadata,
145
+ catalog,
146
+ catalogEndpoint,
147
+ strictCatalog = false
148
+ }) {
149
+ const sessionIdRef = useRef(null);
150
+ const catalogMapRef = useRef(
151
+ catalog ? createCatalogMap(catalog) : null
152
+ );
153
+ if (typeof window !== "undefined" && !sessionIdRef.current) {
154
+ sessionIdRef.current = getOrCreateSessionId();
155
+ }
156
+ useEffect(() => {
157
+ if (!catalog) return;
158
+ catalogMapRef.current = createCatalogMap(catalog);
159
+ }, [catalog]);
160
+ useEffect(() => {
161
+ if (!catalogEndpoint || typeof window === "undefined") return;
162
+ let cancelled = false;
163
+ fetchCatalog(catalogEndpoint).then((tracks) => {
164
+ if (cancelled) return;
165
+ catalogMapRef.current = createCatalogMap(tracks);
166
+ }).catch((error) => {
167
+ if (!debug) return;
168
+ console.warn(
169
+ "[xray] failed to load track catalog from endpoint",
170
+ error instanceof Error ? error.message : error
171
+ );
172
+ });
173
+ return () => {
174
+ cancelled = true;
175
+ };
176
+ }, [catalogEndpoint, debug]);
177
+ const base = useMemo(
178
+ () => ({
179
+ appId,
180
+ sessionId: sessionIdRef.current ?? "unknown"
181
+ }),
182
+ [appId]
183
+ );
184
+ const sendTrack = useCallback(
185
+ (name, props, tags, options) => {
186
+ if (typeof window === "undefined") return;
187
+ const resolvedEnvironment = normalizeEnvironment(environment);
188
+ const catalogMap = catalogMapRef.current;
189
+ if (catalogMap) {
190
+ const entry = catalogMap.get(name);
191
+ if (!entry) {
192
+ if (debug) {
193
+ console.warn(`[xray] track '${name}' does not exist in the loaded catalog`);
194
+ }
195
+ if (strictCatalog) return;
196
+ } else if (entry.deprecated && debug) {
197
+ console.warn(`[xray] track '${name}' is marked as deprecated in the catalog`);
198
+ }
199
+ }
200
+ (async () => {
201
+ const metadataConfig = (() => {
202
+ if (options?.metadata === void 0) return metadata;
203
+ if (options.metadata === true || options.metadata === false) return options.metadata;
204
+ const baseMetadataConfig = typeof metadata === "object" && metadata ? metadata : {};
205
+ return {
206
+ ...baseMetadataConfig,
207
+ ...options.metadata
208
+ };
209
+ })();
210
+ const clientMeta = await collectTrackClientMetadata(metadataConfig);
211
+ const event = {
212
+ name,
213
+ ts: Date.now(),
214
+ url: window.location.href,
215
+ path: window.location.pathname,
216
+ ref: document.referrer || void 0,
217
+ environment: resolvedEnvironment,
218
+ ...base,
219
+ props: props ?? void 0,
220
+ tags: tags ?? void 0,
221
+ clientMeta: clientMeta ?? void 0,
222
+ writeKey: writeKey ?? void 0
223
+ };
224
+ if (resolvedEnvironment !== "production") {
225
+ console.log("[xray][track]", event);
226
+ return;
227
+ }
228
+ const payload = JSON.stringify(event);
229
+ const sendWithTransport = (url) => preferSendBeacon ? sendBeaconFirst(url, payload) : sendBeaconFirst(url, payload, { preferBeacon: false });
230
+ if (transport === "bff") {
231
+ await sendWithTransport(bffEndpoint);
232
+ return;
233
+ }
234
+ if (transport === "direct") {
235
+ if (!directEndpoint) return;
236
+ await sendWithTransport(directEndpoint);
237
+ return;
238
+ }
239
+ const r1 = await sendWithTransport(bffEndpoint).catch(() => null);
240
+ if (r1?.ok) return;
241
+ if (directEndpoint) {
242
+ await sendWithTransport(directEndpoint).catch(() => null);
243
+ }
244
+ })().catch(() => {
245
+ });
246
+ },
247
+ [
248
+ base,
249
+ transport,
250
+ bffEndpoint,
251
+ directEndpoint,
252
+ writeKey,
253
+ debug,
254
+ environment,
255
+ strictCatalog,
256
+ preferSendBeacon,
257
+ metadata
258
+ ]
259
+ );
260
+ useEffect(() => {
261
+ if (!autoPageViews || typeof window === "undefined") return;
262
+ sendTrack("page_view");
263
+ const notify = () => sendTrack("page_view");
264
+ window.addEventListener("popstate", notify);
265
+ return () => window.removeEventListener("popstate", notify);
266
+ }, [autoPageViews, sendTrack]);
267
+ const value = useMemo(
268
+ () => ({
269
+ track: sendTrack,
270
+ sendTrack,
271
+ trackPageView: (props, tags) => sendTrack("page_view", props, tags),
272
+ trackClickLink: (props, tags) => sendTrack("click_link", props, tags),
273
+ trackRedirect: (props, tags) => sendTrack("redirect", props, tags),
274
+ trackClickButton: (props, tags) => sendTrack("click_button", props, tags),
275
+ trackScroll: (props, tags) => sendTrack("scroll", props, tags),
276
+ trackElementView: (props, tags) => sendTrack("element_view", props, tags)
277
+ }),
278
+ [sendTrack]
279
+ );
280
+ return /* @__PURE__ */ jsx(AnalyticsContext.Provider, { value, children });
281
+ }
282
+
283
+ // src/hooks/track-page-view.tsx
284
+ import { useEffect as useEffect2 } from "react";
285
+
286
+ // src/hooks/use-analytics.ts
287
+ import { useContext, useMemo as useMemo2 } from "react";
288
+ function useAnalytics(options) {
289
+ const ctx = useContext(AnalyticsContext);
290
+ if (!ctx) throw new Error("useAnalytics must be used within AnalyticsProvider");
291
+ const metadataOption = options?.metadata;
292
+ const wrapped = useMemo2(
293
+ () => ({
294
+ ...ctx,
295
+ track: (name, props, tags) => ctx.track(name, props, tags, { metadata: metadataOption }),
296
+ sendTrack: (name, props, tags) => ctx.sendTrack(name, props, tags, { metadata: metadataOption }),
297
+ trackPageView: (props, tags) => ctx.track("page_view", props, tags, { metadata: metadataOption }),
298
+ trackClickLink: (props, tags) => ctx.track("click_link", props, tags, { metadata: metadataOption }),
299
+ trackRedirect: (props, tags) => ctx.track("redirect", props, tags, { metadata: metadataOption }),
300
+ trackClickButton: (props, tags) => ctx.track("click_button", props, tags, { metadata: metadataOption }),
301
+ trackScroll: (props, tags) => ctx.track("scroll", props, tags, { metadata: metadataOption }),
302
+ trackElementView: (props, tags) => ctx.track("element_view", props, tags, { metadata: metadataOption })
303
+ }),
304
+ [ctx, metadataOption]
305
+ );
306
+ return metadataOption ? wrapped : ctx;
307
+ }
308
+
309
+ // src/hooks/track-page-view.tsx
310
+ function useTrackPageView(props, trackOnPopState = true) {
311
+ const { trackPageView } = useAnalytics();
312
+ useEffect2(() => {
313
+ if (typeof window === "undefined") return;
314
+ trackPageView(props);
315
+ if (!trackOnPopState) return;
316
+ const notify = () => trackPageView(props);
317
+ window.addEventListener("popstate", notify);
318
+ return () => window.removeEventListener("popstate", notify);
319
+ }, [trackPageView, props, trackOnPopState]);
320
+ }
321
+ function TrackPageView({ props, trackOnPopState = true }) {
322
+ useTrackPageView(props, trackOnPopState);
323
+ return null;
324
+ }
325
+ export {
326
+ AnalyticsProvider,
327
+ TrackPageView,
328
+ useAnalytics,
329
+ useTrackPageView
330
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@xray-analytics/analytics-react",
3
+ "version": "0.0.2",
4
+ "private": false,
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "publishConfig": {
9
+ "access": "public",
10
+ "registry": "https://registry.npmjs.org/"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.mjs",
16
+ "require": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "peerDependencies": {
24
+ "react": ">=19 <20"
25
+ },
26
+ "scripts": {
27
+ "build": "tsup src/index.tsx --format cjs,esm --dts",
28
+ "lint": "eslint .",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit",
30
+ "test": "vitest run"
31
+ },
32
+ "typecheck": "tsc -p tsconfig.json --noEmit",
33
+ "devDependencies": {
34
+ "@types/react": "^19.2.14",
35
+ "@types/react-dom": "^19.2.3",
36
+ "jsdom": "^28.0.0",
37
+ "react-dom": "^19.2.4"
38
+ }
39
+ }