@jmp-technologies/analytics 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ # `@jmp-technologies/analytics`
2
+
3
+ Browser client for **`POST {baseUrl}/api/events`**. Full API, CORS, and examples: **[docs/tracking-integration.md](../../docs/tracking-integration.md)**.
4
+
5
+ **Build:** `npm run build` in this package (or root `prepare` after `npm install`).
6
+
7
+ **Publish to npm (org `jmp-technologies`):** `npm publish` from this directory after `npm run build` (package sets `publishConfig.access` to `public`).
package/dist/index.cjs ADDED
@@ -0,0 +1,191 @@
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.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createJmpAnalytics: () => createJmpAnalytics
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+ var DEFAULT_SOURCE = "direct";
27
+ var SESSION_STORAGE_KEY = "jmp_analytics_sid";
28
+ function normalizeBaseUrl(baseUrl) {
29
+ return baseUrl.replace(/\/$/, "");
30
+ }
31
+ function inferDevice() {
32
+ if (typeof window === "undefined") {
33
+ return "unknown";
34
+ }
35
+ return window.innerWidth < 768 ? "mobile" : "desktop";
36
+ }
37
+ function getOrCreateSessionId() {
38
+ if (typeof window === "undefined" || !globalThis.crypto?.randomUUID) {
39
+ return void 0;
40
+ }
41
+ try {
42
+ let id = sessionStorage.getItem(SESSION_STORAGE_KEY);
43
+ if (!id) {
44
+ id = crypto.randomUUID();
45
+ sessionStorage.setItem(SESSION_STORAGE_KEY, id);
46
+ }
47
+ return id;
48
+ } catch {
49
+ return void 0;
50
+ }
51
+ }
52
+ function createJmpAnalytics(config) {
53
+ const baseUrl = normalizeBaseUrl(config.baseUrl);
54
+ const resolvedFetch = config.fetchImpl ?? (typeof globalThis !== "undefined" && globalThis.fetch ? globalThis.fetch.bind(globalThis) : void 0);
55
+ if (!resolvedFetch) {
56
+ throw new Error(
57
+ "createJmpAnalytics: fetch is not available; pass fetchImpl in config"
58
+ );
59
+ }
60
+ const fetchFn = resolvedFetch;
61
+ let lastDedupePath = "";
62
+ let lastDedupeAt = 0;
63
+ async function send(body) {
64
+ const url = `${baseUrl}/api/events`;
65
+ const headers = {
66
+ "Content-Type": "application/json",
67
+ "X-JMP-Tracking-Key": config.trackingKey
68
+ };
69
+ const sid = getOrCreateSessionId();
70
+ const payload = { ...body, ...sid ? { sessionId: sid } : {} };
71
+ if (config.debug) {
72
+ console.debug("[@jmp-technologies/analytics]", "POST", url, payload);
73
+ }
74
+ const res = await fetchFn(url, {
75
+ method: "POST",
76
+ headers,
77
+ body: JSON.stringify(payload),
78
+ keepalive: true
79
+ });
80
+ if (!res.ok) {
81
+ let detail = "";
82
+ try {
83
+ const err = await res.json();
84
+ detail = err.error ?? await res.text();
85
+ } catch {
86
+ detail = await res.text();
87
+ }
88
+ throw new Error(`JMP Analytics HTTP ${res.status}: ${detail}`);
89
+ }
90
+ }
91
+ function shouldDedupePageView(path) {
92
+ const ms = config.pageViewDedupeMs ?? 0;
93
+ if (ms <= 0 || typeof window === "undefined") {
94
+ return false;
95
+ }
96
+ const now = Date.now();
97
+ if (path === lastDedupePath && now - lastDedupeAt < ms) {
98
+ return true;
99
+ }
100
+ lastDedupePath = path;
101
+ lastDedupeAt = now;
102
+ return false;
103
+ }
104
+ function trackPageView(options) {
105
+ const path = options?.path ?? (typeof window !== "undefined" ? window.location.pathname : "/");
106
+ if (shouldDedupePageView(path)) {
107
+ return Promise.resolve();
108
+ }
109
+ return send({
110
+ type: "page_view",
111
+ path: path || "/",
112
+ source: options?.source ?? DEFAULT_SOURCE,
113
+ device: options?.device ?? inferDevice(),
114
+ country: options?.country,
115
+ metadata: options?.metadata && typeof options.metadata === "object" ? options.metadata : {}
116
+ });
117
+ }
118
+ function trackFormSubmit(path, metadata) {
119
+ return send({
120
+ type: "form_submit",
121
+ path: path || "/",
122
+ metadata: metadata ?? {}
123
+ });
124
+ }
125
+ function trackCallClick(path, metadata) {
126
+ return send({
127
+ type: "call_click",
128
+ path: path || "/",
129
+ metadata: metadata ?? {}
130
+ });
131
+ }
132
+ function track(type, payload) {
133
+ if (type === "page_view") {
134
+ return trackPageView({
135
+ path: payload.path,
136
+ source: payload.source,
137
+ device: payload.device,
138
+ country: payload.country,
139
+ metadata: payload.metadata
140
+ });
141
+ }
142
+ if (type === "form_submit") {
143
+ return trackFormSubmit(payload.path, payload.metadata);
144
+ }
145
+ return trackCallClick(payload.path, payload.metadata);
146
+ }
147
+ function subscribeToSpaNavigation() {
148
+ if (typeof window === "undefined") {
149
+ return () => {
150
+ };
151
+ }
152
+ let lastPath = window.location.pathname;
153
+ const run = () => {
154
+ const path = window.location.pathname;
155
+ if (path !== lastPath) {
156
+ lastPath = path;
157
+ void trackPageView({ path });
158
+ }
159
+ };
160
+ window.addEventListener("popstate", run);
161
+ const push = history.pushState.bind(history);
162
+ const replace = history.replaceState.bind(history);
163
+ history.pushState = (...args) => {
164
+ const r = push(...args);
165
+ queueMicrotask(run);
166
+ return r;
167
+ };
168
+ history.replaceState = (...args) => {
169
+ const r = replace(...args);
170
+ queueMicrotask(run);
171
+ return r;
172
+ };
173
+ return () => {
174
+ window.removeEventListener("popstate", run);
175
+ history.pushState = push;
176
+ history.replaceState = replace;
177
+ };
178
+ }
179
+ return {
180
+ track,
181
+ trackPageView,
182
+ trackFormSubmit,
183
+ trackCallClick,
184
+ subscribeToSpaNavigation
185
+ };
186
+ }
187
+ // Annotate the CommonJS export names for ESM import in node:
188
+ 0 && (module.exports = {
189
+ createJmpAnalytics
190
+ });
191
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type JmpEventType = \"page_view\" | \"form_submit\" | \"call_click\"\n\nexport type JmpAnalyticsConfig = {\n /** Dashboard origin, e.g. https://analytics.example.com (no trailing slash) */\n baseUrl: string\n /** Per-website key from JMP admin */\n trackingKey: string\n /** Log requests to console */\n debug?: boolean\n /** Override fetch (tests, React Native) */\n fetchImpl?: typeof fetch\n /**\n * Suppress duplicate `page_view` for the same path within this window (ms).\n * Helps with React Strict Mode double-mount; typical: 500–1000.\n */\n pageViewDedupeMs?: number\n}\n\nexport type TrackPageViewOptions = {\n path?: string\n source?: string\n device?: string\n country?: string\n metadata?: Record<string, unknown>\n}\n\nconst DEFAULT_SOURCE = \"direct\"\nconst SESSION_STORAGE_KEY = \"jmp_analytics_sid\"\n\nfunction normalizeBaseUrl(baseUrl: string) {\n return baseUrl.replace(/\\/$/, \"\")\n}\n\nfunction inferDevice(): string {\n if (typeof window === \"undefined\") {\n return \"unknown\"\n }\n return window.innerWidth < 768 ? \"mobile\" : \"desktop\"\n}\n\n/** Stable per-tab session id for bounce and unique session metrics (server-stored). */\nfunction getOrCreateSessionId(): string | undefined {\n if (typeof window === \"undefined\" || !globalThis.crypto?.randomUUID) {\n return undefined\n }\n try {\n let id = sessionStorage.getItem(SESSION_STORAGE_KEY)\n if (!id) {\n id = crypto.randomUUID()\n sessionStorage.setItem(SESSION_STORAGE_KEY, id)\n }\n return id\n } catch {\n return undefined\n }\n}\n\n/**\n * Create a tracker instance. Safe to call once per app and reuse.\n */\nexport function createJmpAnalytics(config: JmpAnalyticsConfig) {\n const baseUrl = normalizeBaseUrl(config.baseUrl)\n const resolvedFetch =\n config.fetchImpl ??\n (typeof globalThis !== \"undefined\" && globalThis.fetch\n ? globalThis.fetch.bind(globalThis)\n : undefined)\n\n if (!resolvedFetch) {\n throw new Error(\n \"createJmpAnalytics: fetch is not available; pass fetchImpl in config\"\n )\n }\n\n const fetchFn: typeof fetch = resolvedFetch\n\n let lastDedupePath = \"\"\n let lastDedupeAt = 0\n\n async function send(body: Record<string, unknown>) {\n const url = `${baseUrl}/api/events`\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-JMP-Tracking-Key\": config.trackingKey,\n }\n\n const sid = getOrCreateSessionId()\n const payload = { ...body, ...(sid ? { sessionId: sid } : {}) }\n\n if (config.debug) {\n console.debug(\"[@jmp-technologies/analytics]\", \"POST\", url, payload)\n }\n\n const res = await fetchFn(url, {\n method: \"POST\",\n headers,\n body: JSON.stringify(payload),\n keepalive: true,\n })\n\n if (!res.ok) {\n let detail = \"\"\n try {\n const err = (await res.json()) as { error?: string }\n detail = err.error ?? (await res.text())\n } catch {\n detail = await res.text()\n }\n throw new Error(`JMP Analytics HTTP ${res.status}: ${detail}`)\n }\n }\n\n function shouldDedupePageView(path: string): boolean {\n const ms = config.pageViewDedupeMs ?? 0\n if (ms <= 0 || typeof window === \"undefined\") {\n return false\n }\n const now = Date.now()\n if (path === lastDedupePath && now - lastDedupeAt < ms) {\n return true\n }\n lastDedupePath = path\n lastDedupeAt = now\n return false\n }\n\n function trackPageView(options?: TrackPageViewOptions) {\n const path =\n options?.path ??\n (typeof window !== \"undefined\" ? window.location.pathname : \"/\")\n\n if (shouldDedupePageView(path)) {\n return Promise.resolve()\n }\n\n return send({\n type: \"page_view\",\n path: path || \"/\",\n source: options?.source ?? DEFAULT_SOURCE,\n device: options?.device ?? inferDevice(),\n country: options?.country,\n metadata:\n options?.metadata && typeof options.metadata === \"object\"\n ? options.metadata\n : {},\n })\n }\n\n function trackFormSubmit(\n path: string,\n metadata?: Record<string, unknown>\n ) {\n return send({\n type: \"form_submit\",\n path: path || \"/\",\n metadata: metadata ?? {},\n })\n }\n\n function trackCallClick(\n path: string,\n metadata?: Record<string, unknown>\n ) {\n return send({\n type: \"call_click\",\n path: path || \"/\",\n metadata: metadata ?? {},\n })\n }\n\n function track(\n type: JmpEventType,\n payload: {\n path: string\n metadata?: Record<string, unknown>\n source?: string\n device?: string\n country?: string\n }\n ) {\n if (type === \"page_view\") {\n return trackPageView({\n path: payload.path,\n source: payload.source,\n device: payload.device,\n country: payload.country,\n metadata: payload.metadata,\n })\n }\n if (type === \"form_submit\") {\n return trackFormSubmit(payload.path, payload.metadata)\n }\n return trackCallClick(payload.path, payload.metadata)\n }\n\n /**\n * Fire `page_view` on browser back/forward and when `history.pushState` /\n * `replaceState` are used (many SPAs). Call once after creating the tracker.\n * Returns an unsubscribe function.\n */\n function subscribeToSpaNavigation(): () => void {\n if (typeof window === \"undefined\") {\n return () => {}\n }\n\n let lastPath = window.location.pathname\n\n const run = () => {\n const path = window.location.pathname\n if (path !== lastPath) {\n lastPath = path\n void trackPageView({ path })\n }\n }\n\n window.addEventListener(\"popstate\", run)\n\n const push = history.pushState.bind(history)\n const replace = history.replaceState.bind(history)\n\n history.pushState = (...args: Parameters<History[\"pushState\"]>) => {\n const r = push(...args)\n queueMicrotask(run)\n return r\n }\n history.replaceState = (...args: Parameters<History[\"replaceState\"]>) => {\n const r = replace(...args)\n queueMicrotask(run)\n return r\n }\n\n return () => {\n window.removeEventListener(\"popstate\", run)\n history.pushState = push\n history.replaceState = replace\n }\n }\n\n return {\n track,\n trackPageView,\n trackFormSubmit,\n trackCallClick,\n subscribeToSpaNavigation,\n }\n}\n\nexport type JmpAnalytics = ReturnType<typeof createJmpAnalytics>\n"],"mappings":"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","names":[]}
@@ -0,0 +1,42 @@
1
+ type JmpEventType = "page_view" | "form_submit" | "call_click";
2
+ type JmpAnalyticsConfig = {
3
+ /** Dashboard origin, e.g. https://analytics.example.com (no trailing slash) */
4
+ baseUrl: string;
5
+ /** Per-website key from JMP admin */
6
+ trackingKey: string;
7
+ /** Log requests to console */
8
+ debug?: boolean;
9
+ /** Override fetch (tests, React Native) */
10
+ fetchImpl?: typeof fetch;
11
+ /**
12
+ * Suppress duplicate `page_view` for the same path within this window (ms).
13
+ * Helps with React Strict Mode double-mount; typical: 500–1000.
14
+ */
15
+ pageViewDedupeMs?: number;
16
+ };
17
+ type TrackPageViewOptions = {
18
+ path?: string;
19
+ source?: string;
20
+ device?: string;
21
+ country?: string;
22
+ metadata?: Record<string, unknown>;
23
+ };
24
+ /**
25
+ * Create a tracker instance. Safe to call once per app and reuse.
26
+ */
27
+ declare function createJmpAnalytics(config: JmpAnalyticsConfig): {
28
+ track: (type: JmpEventType, payload: {
29
+ path: string;
30
+ metadata?: Record<string, unknown>;
31
+ source?: string;
32
+ device?: string;
33
+ country?: string;
34
+ }) => Promise<void>;
35
+ trackPageView: (options?: TrackPageViewOptions) => Promise<void>;
36
+ trackFormSubmit: (path: string, metadata?: Record<string, unknown>) => Promise<void>;
37
+ trackCallClick: (path: string, metadata?: Record<string, unknown>) => Promise<void>;
38
+ subscribeToSpaNavigation: () => () => void;
39
+ };
40
+ type JmpAnalytics = ReturnType<typeof createJmpAnalytics>;
41
+
42
+ export { type JmpAnalytics, type JmpAnalyticsConfig, type JmpEventType, type TrackPageViewOptions, createJmpAnalytics };
@@ -0,0 +1,42 @@
1
+ type JmpEventType = "page_view" | "form_submit" | "call_click";
2
+ type JmpAnalyticsConfig = {
3
+ /** Dashboard origin, e.g. https://analytics.example.com (no trailing slash) */
4
+ baseUrl: string;
5
+ /** Per-website key from JMP admin */
6
+ trackingKey: string;
7
+ /** Log requests to console */
8
+ debug?: boolean;
9
+ /** Override fetch (tests, React Native) */
10
+ fetchImpl?: typeof fetch;
11
+ /**
12
+ * Suppress duplicate `page_view` for the same path within this window (ms).
13
+ * Helps with React Strict Mode double-mount; typical: 500–1000.
14
+ */
15
+ pageViewDedupeMs?: number;
16
+ };
17
+ type TrackPageViewOptions = {
18
+ path?: string;
19
+ source?: string;
20
+ device?: string;
21
+ country?: string;
22
+ metadata?: Record<string, unknown>;
23
+ };
24
+ /**
25
+ * Create a tracker instance. Safe to call once per app and reuse.
26
+ */
27
+ declare function createJmpAnalytics(config: JmpAnalyticsConfig): {
28
+ track: (type: JmpEventType, payload: {
29
+ path: string;
30
+ metadata?: Record<string, unknown>;
31
+ source?: string;
32
+ device?: string;
33
+ country?: string;
34
+ }) => Promise<void>;
35
+ trackPageView: (options?: TrackPageViewOptions) => Promise<void>;
36
+ trackFormSubmit: (path: string, metadata?: Record<string, unknown>) => Promise<void>;
37
+ trackCallClick: (path: string, metadata?: Record<string, unknown>) => Promise<void>;
38
+ subscribeToSpaNavigation: () => () => void;
39
+ };
40
+ type JmpAnalytics = ReturnType<typeof createJmpAnalytics>;
41
+
42
+ export { type JmpAnalytics, type JmpAnalyticsConfig, type JmpEventType, type TrackPageViewOptions, createJmpAnalytics };
package/dist/index.js ADDED
@@ -0,0 +1,166 @@
1
+ // src/index.ts
2
+ var DEFAULT_SOURCE = "direct";
3
+ var SESSION_STORAGE_KEY = "jmp_analytics_sid";
4
+ function normalizeBaseUrl(baseUrl) {
5
+ return baseUrl.replace(/\/$/, "");
6
+ }
7
+ function inferDevice() {
8
+ if (typeof window === "undefined") {
9
+ return "unknown";
10
+ }
11
+ return window.innerWidth < 768 ? "mobile" : "desktop";
12
+ }
13
+ function getOrCreateSessionId() {
14
+ if (typeof window === "undefined" || !globalThis.crypto?.randomUUID) {
15
+ return void 0;
16
+ }
17
+ try {
18
+ let id = sessionStorage.getItem(SESSION_STORAGE_KEY);
19
+ if (!id) {
20
+ id = crypto.randomUUID();
21
+ sessionStorage.setItem(SESSION_STORAGE_KEY, id);
22
+ }
23
+ return id;
24
+ } catch {
25
+ return void 0;
26
+ }
27
+ }
28
+ function createJmpAnalytics(config) {
29
+ const baseUrl = normalizeBaseUrl(config.baseUrl);
30
+ const resolvedFetch = config.fetchImpl ?? (typeof globalThis !== "undefined" && globalThis.fetch ? globalThis.fetch.bind(globalThis) : void 0);
31
+ if (!resolvedFetch) {
32
+ throw new Error(
33
+ "createJmpAnalytics: fetch is not available; pass fetchImpl in config"
34
+ );
35
+ }
36
+ const fetchFn = resolvedFetch;
37
+ let lastDedupePath = "";
38
+ let lastDedupeAt = 0;
39
+ async function send(body) {
40
+ const url = `${baseUrl}/api/events`;
41
+ const headers = {
42
+ "Content-Type": "application/json",
43
+ "X-JMP-Tracking-Key": config.trackingKey
44
+ };
45
+ const sid = getOrCreateSessionId();
46
+ const payload = { ...body, ...sid ? { sessionId: sid } : {} };
47
+ if (config.debug) {
48
+ console.debug("[@jmp-technologies/analytics]", "POST", url, payload);
49
+ }
50
+ const res = await fetchFn(url, {
51
+ method: "POST",
52
+ headers,
53
+ body: JSON.stringify(payload),
54
+ keepalive: true
55
+ });
56
+ if (!res.ok) {
57
+ let detail = "";
58
+ try {
59
+ const err = await res.json();
60
+ detail = err.error ?? await res.text();
61
+ } catch {
62
+ detail = await res.text();
63
+ }
64
+ throw new Error(`JMP Analytics HTTP ${res.status}: ${detail}`);
65
+ }
66
+ }
67
+ function shouldDedupePageView(path) {
68
+ const ms = config.pageViewDedupeMs ?? 0;
69
+ if (ms <= 0 || typeof window === "undefined") {
70
+ return false;
71
+ }
72
+ const now = Date.now();
73
+ if (path === lastDedupePath && now - lastDedupeAt < ms) {
74
+ return true;
75
+ }
76
+ lastDedupePath = path;
77
+ lastDedupeAt = now;
78
+ return false;
79
+ }
80
+ function trackPageView(options) {
81
+ const path = options?.path ?? (typeof window !== "undefined" ? window.location.pathname : "/");
82
+ if (shouldDedupePageView(path)) {
83
+ return Promise.resolve();
84
+ }
85
+ return send({
86
+ type: "page_view",
87
+ path: path || "/",
88
+ source: options?.source ?? DEFAULT_SOURCE,
89
+ device: options?.device ?? inferDevice(),
90
+ country: options?.country,
91
+ metadata: options?.metadata && typeof options.metadata === "object" ? options.metadata : {}
92
+ });
93
+ }
94
+ function trackFormSubmit(path, metadata) {
95
+ return send({
96
+ type: "form_submit",
97
+ path: path || "/",
98
+ metadata: metadata ?? {}
99
+ });
100
+ }
101
+ function trackCallClick(path, metadata) {
102
+ return send({
103
+ type: "call_click",
104
+ path: path || "/",
105
+ metadata: metadata ?? {}
106
+ });
107
+ }
108
+ function track(type, payload) {
109
+ if (type === "page_view") {
110
+ return trackPageView({
111
+ path: payload.path,
112
+ source: payload.source,
113
+ device: payload.device,
114
+ country: payload.country,
115
+ metadata: payload.metadata
116
+ });
117
+ }
118
+ if (type === "form_submit") {
119
+ return trackFormSubmit(payload.path, payload.metadata);
120
+ }
121
+ return trackCallClick(payload.path, payload.metadata);
122
+ }
123
+ function subscribeToSpaNavigation() {
124
+ if (typeof window === "undefined") {
125
+ return () => {
126
+ };
127
+ }
128
+ let lastPath = window.location.pathname;
129
+ const run = () => {
130
+ const path = window.location.pathname;
131
+ if (path !== lastPath) {
132
+ lastPath = path;
133
+ void trackPageView({ path });
134
+ }
135
+ };
136
+ window.addEventListener("popstate", run);
137
+ const push = history.pushState.bind(history);
138
+ const replace = history.replaceState.bind(history);
139
+ history.pushState = (...args) => {
140
+ const r = push(...args);
141
+ queueMicrotask(run);
142
+ return r;
143
+ };
144
+ history.replaceState = (...args) => {
145
+ const r = replace(...args);
146
+ queueMicrotask(run);
147
+ return r;
148
+ };
149
+ return () => {
150
+ window.removeEventListener("popstate", run);
151
+ history.pushState = push;
152
+ history.replaceState = replace;
153
+ };
154
+ }
155
+ return {
156
+ track,
157
+ trackPageView,
158
+ trackFormSubmit,
159
+ trackCallClick,
160
+ subscribeToSpaNavigation
161
+ };
162
+ }
163
+ export {
164
+ createJmpAnalytics
165
+ };
166
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type JmpEventType = \"page_view\" | \"form_submit\" | \"call_click\"\n\nexport type JmpAnalyticsConfig = {\n /** Dashboard origin, e.g. https://analytics.example.com (no trailing slash) */\n baseUrl: string\n /** Per-website key from JMP admin */\n trackingKey: string\n /** Log requests to console */\n debug?: boolean\n /** Override fetch (tests, React Native) */\n fetchImpl?: typeof fetch\n /**\n * Suppress duplicate `page_view` for the same path within this window (ms).\n * Helps with React Strict Mode double-mount; typical: 500–1000.\n */\n pageViewDedupeMs?: number\n}\n\nexport type TrackPageViewOptions = {\n path?: string\n source?: string\n device?: string\n country?: string\n metadata?: Record<string, unknown>\n}\n\nconst DEFAULT_SOURCE = \"direct\"\nconst SESSION_STORAGE_KEY = \"jmp_analytics_sid\"\n\nfunction normalizeBaseUrl(baseUrl: string) {\n return baseUrl.replace(/\\/$/, \"\")\n}\n\nfunction inferDevice(): string {\n if (typeof window === \"undefined\") {\n return \"unknown\"\n }\n return window.innerWidth < 768 ? \"mobile\" : \"desktop\"\n}\n\n/** Stable per-tab session id for bounce and unique session metrics (server-stored). */\nfunction getOrCreateSessionId(): string | undefined {\n if (typeof window === \"undefined\" || !globalThis.crypto?.randomUUID) {\n return undefined\n }\n try {\n let id = sessionStorage.getItem(SESSION_STORAGE_KEY)\n if (!id) {\n id = crypto.randomUUID()\n sessionStorage.setItem(SESSION_STORAGE_KEY, id)\n }\n return id\n } catch {\n return undefined\n }\n}\n\n/**\n * Create a tracker instance. Safe to call once per app and reuse.\n */\nexport function createJmpAnalytics(config: JmpAnalyticsConfig) {\n const baseUrl = normalizeBaseUrl(config.baseUrl)\n const resolvedFetch =\n config.fetchImpl ??\n (typeof globalThis !== \"undefined\" && globalThis.fetch\n ? globalThis.fetch.bind(globalThis)\n : undefined)\n\n if (!resolvedFetch) {\n throw new Error(\n \"createJmpAnalytics: fetch is not available; pass fetchImpl in config\"\n )\n }\n\n const fetchFn: typeof fetch = resolvedFetch\n\n let lastDedupePath = \"\"\n let lastDedupeAt = 0\n\n async function send(body: Record<string, unknown>) {\n const url = `${baseUrl}/api/events`\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-JMP-Tracking-Key\": config.trackingKey,\n }\n\n const sid = getOrCreateSessionId()\n const payload = { ...body, ...(sid ? { sessionId: sid } : {}) }\n\n if (config.debug) {\n console.debug(\"[@jmp-technologies/analytics]\", \"POST\", url, payload)\n }\n\n const res = await fetchFn(url, {\n method: \"POST\",\n headers,\n body: JSON.stringify(payload),\n keepalive: true,\n })\n\n if (!res.ok) {\n let detail = \"\"\n try {\n const err = (await res.json()) as { error?: string }\n detail = err.error ?? (await res.text())\n } catch {\n detail = await res.text()\n }\n throw new Error(`JMP Analytics HTTP ${res.status}: ${detail}`)\n }\n }\n\n function shouldDedupePageView(path: string): boolean {\n const ms = config.pageViewDedupeMs ?? 0\n if (ms <= 0 || typeof window === \"undefined\") {\n return false\n }\n const now = Date.now()\n if (path === lastDedupePath && now - lastDedupeAt < ms) {\n return true\n }\n lastDedupePath = path\n lastDedupeAt = now\n return false\n }\n\n function trackPageView(options?: TrackPageViewOptions) {\n const path =\n options?.path ??\n (typeof window !== \"undefined\" ? window.location.pathname : \"/\")\n\n if (shouldDedupePageView(path)) {\n return Promise.resolve()\n }\n\n return send({\n type: \"page_view\",\n path: path || \"/\",\n source: options?.source ?? DEFAULT_SOURCE,\n device: options?.device ?? inferDevice(),\n country: options?.country,\n metadata:\n options?.metadata && typeof options.metadata === \"object\"\n ? options.metadata\n : {},\n })\n }\n\n function trackFormSubmit(\n path: string,\n metadata?: Record<string, unknown>\n ) {\n return send({\n type: \"form_submit\",\n path: path || \"/\",\n metadata: metadata ?? {},\n })\n }\n\n function trackCallClick(\n path: string,\n metadata?: Record<string, unknown>\n ) {\n return send({\n type: \"call_click\",\n path: path || \"/\",\n metadata: metadata ?? {},\n })\n }\n\n function track(\n type: JmpEventType,\n payload: {\n path: string\n metadata?: Record<string, unknown>\n source?: string\n device?: string\n country?: string\n }\n ) {\n if (type === \"page_view\") {\n return trackPageView({\n path: payload.path,\n source: payload.source,\n device: payload.device,\n country: payload.country,\n metadata: payload.metadata,\n })\n }\n if (type === \"form_submit\") {\n return trackFormSubmit(payload.path, payload.metadata)\n }\n return trackCallClick(payload.path, payload.metadata)\n }\n\n /**\n * Fire `page_view` on browser back/forward and when `history.pushState` /\n * `replaceState` are used (many SPAs). Call once after creating the tracker.\n * Returns an unsubscribe function.\n */\n function subscribeToSpaNavigation(): () => void {\n if (typeof window === \"undefined\") {\n return () => {}\n }\n\n let lastPath = window.location.pathname\n\n const run = () => {\n const path = window.location.pathname\n if (path !== lastPath) {\n lastPath = path\n void trackPageView({ path })\n }\n }\n\n window.addEventListener(\"popstate\", run)\n\n const push = history.pushState.bind(history)\n const replace = history.replaceState.bind(history)\n\n history.pushState = (...args: Parameters<History[\"pushState\"]>) => {\n const r = push(...args)\n queueMicrotask(run)\n return r\n }\n history.replaceState = (...args: Parameters<History[\"replaceState\"]>) => {\n const r = replace(...args)\n queueMicrotask(run)\n return r\n }\n\n return () => {\n window.removeEventListener(\"popstate\", run)\n history.pushState = push\n history.replaceState = replace\n }\n }\n\n return {\n track,\n trackPageView,\n trackFormSubmit,\n trackCallClick,\n subscribeToSpaNavigation,\n }\n}\n\nexport type JmpAnalytics = ReturnType<typeof createJmpAnalytics>\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@jmp-technologies/analytics",
3
+ "version": "0.1.0",
4
+ "description": "Browser client for JMP Analytics event ingestion",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": ["dist"],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap"
23
+ },
24
+ "devDependencies": {
25
+ "tsup": "^8.5.1",
26
+ "typescript": "^5.9.3"
27
+ }
28
+ }