onedollarstats 0.0.19 → 0.0.21
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 +39 -0
- package/dist/expo.d.ts +35 -0
- package/dist/expo.js +201 -0
- package/dist/expo.js.map +7 -0
- package/package.json +45 -10
package/README.md
CHANGED
|
@@ -119,6 +119,45 @@ event("Purchase", "/product", { amount: 1, color: "green" });
|
|
|
119
119
|
- `pathOrProps` – Optional, **string** represents the path, **object** represents custom properties.
|
|
120
120
|
- `props` – Optional, properties if the second argument is a path string.
|
|
121
121
|
|
|
122
|
+
## Expo
|
|
123
|
+
|
|
124
|
+
`onedollarstats/expo` is a dedicated entry point for Expo apps using `expo-router`. It auto-collects pageviews on route change and on app foreground, supports dynamic-route templates (`/profile/[id]` instead of `/profile/abc123`), and sends events natively on iOS/Android and via image beacon + `sendBeacon` on web.
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// app/_layout.tsx
|
|
128
|
+
import { Stack } from 'expo-router';
|
|
129
|
+
import { OneDollarStatsProvider } from 'onedollarstats/expo';
|
|
130
|
+
|
|
131
|
+
export default function RootLayout() {
|
|
132
|
+
return (
|
|
133
|
+
<OneDollarStatsProvider config={{ hostname: 'example.com' }}>
|
|
134
|
+
<Stack screenOptions={{ headerShown: false }} />
|
|
135
|
+
</OneDollarStatsProvider>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Fire custom events or manual pageviews from any component with `useAnalytics()`:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
import { useAnalytics } from 'onedollarstats/expo';
|
|
144
|
+
|
|
145
|
+
const { event, view } = useAnalytics();
|
|
146
|
+
|
|
147
|
+
event('signup', { plan: 'pro' }); // event with current route
|
|
148
|
+
event('signup', '/landing'); // event with explicit path
|
|
149
|
+
view({ campaign: 'spring' }); // pageview with just props
|
|
150
|
+
view('/landing', { campaign: 'spring' }); // pageview with explicit path
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Expo-specific config options:**
|
|
154
|
+
|
|
155
|
+
| Option | Type | Default | Description |
|
|
156
|
+
| ----------------------- | ---------- | ------- | -------------------------------------------------------------------------------------------- |
|
|
157
|
+
| `collapseDynamicRoutes` | `boolean` | `true` | Use `useSegments()` to record routes as templates (`/profile/[id]`) instead of concrete paths. Group segments like `(tabs)` are stripped. |
|
|
158
|
+
|
|
159
|
+
All other options (`hostname`, `collectorUrl`, `devmode`, `autocollect`, `excludePages`, `includePages`) behave the same as in the web tracker above.
|
|
160
|
+
|
|
122
161
|
## Autocapture
|
|
123
162
|
|
|
124
163
|
**Page view events**
|
package/dist/expo.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type MutableRefObject, type ReactNode } from 'react';
|
|
2
|
+
export type ExpoAnalyticsConfig = {
|
|
3
|
+
hostname: string;
|
|
4
|
+
collectorUrl?: string;
|
|
5
|
+
excludePages?: string[];
|
|
6
|
+
includePages?: string[];
|
|
7
|
+
autocollect?: boolean;
|
|
8
|
+
devmode?: boolean;
|
|
9
|
+
collapseDynamicRoutes?: boolean;
|
|
10
|
+
};
|
|
11
|
+
type InternalConfig = {
|
|
12
|
+
hostname: string;
|
|
13
|
+
collectorUrl: string;
|
|
14
|
+
autocollect: boolean;
|
|
15
|
+
devmode: boolean;
|
|
16
|
+
collapseDynamicRoutes: boolean;
|
|
17
|
+
excludePages?: string[];
|
|
18
|
+
includePages?: string[];
|
|
19
|
+
};
|
|
20
|
+
type ContextValue = {
|
|
21
|
+
config: InternalConfig;
|
|
22
|
+
lastPathRef: MutableRefObject<string | null>;
|
|
23
|
+
};
|
|
24
|
+
export type OneDollarStatsProviderProps = {
|
|
25
|
+
config: ExpoAnalyticsConfig;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
export declare function OneDollarStatsProvider({ config, children }: OneDollarStatsProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<ContextValue | null>>;
|
|
29
|
+
type Props = Record<string, string>;
|
|
30
|
+
export type AnalyticsAPI = {
|
|
31
|
+
event(eventName: string, pathOrProps?: string | Props, props?: Props): void;
|
|
32
|
+
view(pathOrProps?: string | Props, props?: Props): void;
|
|
33
|
+
};
|
|
34
|
+
export declare function useAnalytics(): AnalyticsAPI;
|
|
35
|
+
export {};
|
package/dist/expo.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef
|
|
9
|
+
} from "react";
|
|
10
|
+
import { AppState, Platform } from "react-native";
|
|
11
|
+
import { usePathname, useSegments } from "expo-router";
|
|
12
|
+
const Context = createContext(null);
|
|
13
|
+
function mergeConfig(config) {
|
|
14
|
+
return {
|
|
15
|
+
hostname: config.hostname,
|
|
16
|
+
collectorUrl: config.collectorUrl ?? "https://collector.onedollarstats.com/events",
|
|
17
|
+
autocollect: config.autocollect ?? true,
|
|
18
|
+
devmode: config.devmode ?? false,
|
|
19
|
+
collapseDynamicRoutes: config.collapseDynamicRoutes ?? true,
|
|
20
|
+
excludePages: config.excludePages,
|
|
21
|
+
includePages: config.includePages
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function isGroupSegment(segment) {
|
|
25
|
+
return /^\(.+\)$/.test(segment);
|
|
26
|
+
}
|
|
27
|
+
function collapsePath(segments) {
|
|
28
|
+
const visible = segments.filter((s) => !isGroupSegment(s));
|
|
29
|
+
if (visible.length === 0) return "/";
|
|
30
|
+
return "/" + visible.join("/");
|
|
31
|
+
}
|
|
32
|
+
function useTrackedPath(config) {
|
|
33
|
+
const pathname = usePathname();
|
|
34
|
+
const segments = useSegments();
|
|
35
|
+
return config.collapseDynamicRoutes ? collapsePath(segments) : pathname;
|
|
36
|
+
}
|
|
37
|
+
function isWebLocalhost() {
|
|
38
|
+
if (Platform.OS !== "web") return false;
|
|
39
|
+
if (typeof window === "undefined" || !window.location) return false;
|
|
40
|
+
const { hostname, protocol } = window.location;
|
|
41
|
+
return /^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(hostname) && (protocol === "http:" || protocol === "https:");
|
|
42
|
+
}
|
|
43
|
+
function useRequiredContext(caller) {
|
|
44
|
+
const ctx = useContext(Context);
|
|
45
|
+
if (!ctx) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[onedollarstats] ${caller} must be used inside <OneDollarStatsProvider>. Wrap your root layout with the provider.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return ctx;
|
|
51
|
+
}
|
|
52
|
+
function OneDollarStatsProvider({ config, children }) {
|
|
53
|
+
const merged = useMemo(
|
|
54
|
+
() => mergeConfig(config),
|
|
55
|
+
[
|
|
56
|
+
config.hostname,
|
|
57
|
+
config.collectorUrl,
|
|
58
|
+
config.autocollect,
|
|
59
|
+
config.devmode,
|
|
60
|
+
config.collapseDynamicRoutes,
|
|
61
|
+
config.excludePages,
|
|
62
|
+
config.includePages
|
|
63
|
+
]
|
|
64
|
+
);
|
|
65
|
+
const lastPathRef = useRef(null);
|
|
66
|
+
const announcedRef = useRef(false);
|
|
67
|
+
const trackedPath = useTrackedPath(merged);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (announcedRef.current) return;
|
|
70
|
+
if (merged.devmode && isWebLocalhost()) {
|
|
71
|
+
console.log(
|
|
72
|
+
`[onedollarstats]
|
|
73
|
+
OneDollarStats connected! Tracking localhost as ${merged.hostname}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
announcedRef.current = true;
|
|
77
|
+
}, [merged.devmode, merged.hostname]);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!merged.autocollect) return;
|
|
80
|
+
if (isExcluded(trackedPath, merged)) return;
|
|
81
|
+
if (lastPathRef.current === trackedPath) return;
|
|
82
|
+
lastPathRef.current = trackedPath;
|
|
83
|
+
send("PageView", trackedPath, merged);
|
|
84
|
+
}, [trackedPath, merged]);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const handler = (state) => {
|
|
87
|
+
if (state !== "active") return;
|
|
88
|
+
if (!merged.autocollect) return;
|
|
89
|
+
const current = lastPathRef.current;
|
|
90
|
+
if (!current || isExcluded(current, merged)) return;
|
|
91
|
+
send("PageView", current, merged);
|
|
92
|
+
};
|
|
93
|
+
const sub = AppState.addEventListener("change", handler);
|
|
94
|
+
return () => sub.remove();
|
|
95
|
+
}, [merged]);
|
|
96
|
+
const value = useMemo(
|
|
97
|
+
() => ({ config: merged, lastPathRef }),
|
|
98
|
+
[merged]
|
|
99
|
+
);
|
|
100
|
+
return createElement(Context.Provider, { value }, children);
|
|
101
|
+
}
|
|
102
|
+
function useAnalytics() {
|
|
103
|
+
const ctx = useRequiredContext("useAnalytics");
|
|
104
|
+
const trackedPath = useTrackedPath(ctx.config);
|
|
105
|
+
const event = useCallback(
|
|
106
|
+
(eventName, pathOrProps, props) => {
|
|
107
|
+
const targetPath = typeof pathOrProps === "string" ? pathOrProps : trackedPath;
|
|
108
|
+
const eventProps = typeof pathOrProps === "object" ? pathOrProps : props;
|
|
109
|
+
send(eventName, targetPath, ctx.config, eventProps);
|
|
110
|
+
},
|
|
111
|
+
[ctx, trackedPath]
|
|
112
|
+
);
|
|
113
|
+
const view = useCallback(
|
|
114
|
+
(pathOrProps, props) => {
|
|
115
|
+
const targetPath = typeof pathOrProps === "string" ? pathOrProps : trackedPath;
|
|
116
|
+
const viewProps = typeof pathOrProps === "object" ? pathOrProps : props;
|
|
117
|
+
send("PageView", targetPath, ctx.config, viewProps);
|
|
118
|
+
},
|
|
119
|
+
[ctx, trackedPath]
|
|
120
|
+
);
|
|
121
|
+
return { event, view };
|
|
122
|
+
}
|
|
123
|
+
function shouldSkipSend(config) {
|
|
124
|
+
if (Platform.OS !== "web") return false;
|
|
125
|
+
if (isWebLocalhost() && !config.devmode) return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
function devLog(label, url, props) {
|
|
129
|
+
let msg = `[onedollarstats]
|
|
130
|
+
Event name: ${label}
|
|
131
|
+
Event collected from: ${url}`;
|
|
132
|
+
if (props && Object.keys(props).length > 0) {
|
|
133
|
+
msg += `
|
|
134
|
+
Props: ${JSON.stringify(props, null, 2)}`;
|
|
135
|
+
}
|
|
136
|
+
console.log(msg);
|
|
137
|
+
}
|
|
138
|
+
const SAFE_GET_THRESHOLD = 1500;
|
|
139
|
+
function send(eventName, path, config, props) {
|
|
140
|
+
if (shouldSkipSend(config)) return;
|
|
141
|
+
const url = `https://${config.hostname}${path}`;
|
|
142
|
+
if (config.devmode) devLog(eventName, url, props);
|
|
143
|
+
const body = JSON.stringify({
|
|
144
|
+
u: url,
|
|
145
|
+
e: [{ t: eventName, ...props && { p: props } }]
|
|
146
|
+
});
|
|
147
|
+
if (Platform.OS === "web") {
|
|
148
|
+
sendWeb(config.collectorUrl, body);
|
|
149
|
+
} else {
|
|
150
|
+
sendNative(config.collectorUrl, body);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function sendNative(collectorUrl, body) {
|
|
154
|
+
fetch(collectorUrl, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body
|
|
158
|
+
}).catch(() => {
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function sendWeb(collectorUrl, body) {
|
|
162
|
+
const bytes = new TextEncoder().encode(body);
|
|
163
|
+
const bin = String.fromCharCode(...bytes);
|
|
164
|
+
const payloadBase64 = btoa(bin);
|
|
165
|
+
if (payloadBase64.length <= SAFE_GET_THRESHOLD) {
|
|
166
|
+
const img = new Image(1, 1);
|
|
167
|
+
img.onerror = () => sendBeaconOrFetch(collectorUrl, body);
|
|
168
|
+
img.src = `${collectorUrl}?data=${payloadBase64}`;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
sendBeaconOrFetch(collectorUrl, body);
|
|
172
|
+
}
|
|
173
|
+
function sendBeaconOrFetch(collectorUrl, body) {
|
|
174
|
+
if (typeof navigator !== "undefined" && navigator.sendBeacon?.(collectorUrl, body)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
fetch(collectorUrl, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body,
|
|
181
|
+
keepalive: true
|
|
182
|
+
}).catch(() => {
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function pathMatches(path, prefix) {
|
|
186
|
+
return path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : prefix + "/");
|
|
187
|
+
}
|
|
188
|
+
function isExcluded(path, config) {
|
|
189
|
+
if (config.includePages?.length) {
|
|
190
|
+
return !config.includePages.some((p) => pathMatches(path, p));
|
|
191
|
+
}
|
|
192
|
+
if (config.excludePages?.length) {
|
|
193
|
+
return config.excludePages.some((p) => pathMatches(path, p));
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
export {
|
|
198
|
+
OneDollarStatsProvider,
|
|
199
|
+
useAnalytics
|
|
200
|
+
};
|
|
201
|
+
//# sourceMappingURL=expo.js.map
|
package/dist/expo.js.map
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/expo.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n createContext,\n createElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n type MutableRefObject,\n type ReactNode\n} from 'react';\nimport { AppState, Platform, type AppStateStatus } from 'react-native';\nimport { usePathname, useSegments } from 'expo-router';\n\nexport type ExpoAnalyticsConfig = {\n hostname: string;\n collectorUrl?: string;\n excludePages?: string[];\n includePages?: string[];\n autocollect?: boolean;\n devmode?: boolean;\n collapseDynamicRoutes?: boolean;\n};\n\ntype InternalConfig = {\n hostname: string;\n collectorUrl: string;\n autocollect: boolean;\n devmode: boolean;\n collapseDynamicRoutes: boolean;\n excludePages?: string[];\n includePages?: string[];\n};\n\n// TODO(page-scope): restore when page-scope detection is designed.\n/*\ntype OverrideSource = 'hook' | 'component';\ntype Override = { realPath: string; customPath: string; source: OverrideSource } | null;\ntype PropsOverride =\n | { realPath: string; props: Record<string, string>; source: OverrideSource }\n | null;\n*/\n\ntype ContextValue = {\n config: InternalConfig;\n lastPathRef: MutableRefObject<string | null>;\n // TODO(page-scope): restore when page-scope detection is designed.\n // overrideRef: MutableRefObject<Override>;\n // propsOverrideRef: MutableRefObject<PropsOverride>;\n};\n\nconst Context = createContext<ContextValue | null>(null);\n\n// TODO(page-scope): restore when page-scope detection is designed.\n/*\nfunction resolvePath(pathname: string, overrideRef: MutableRefObject<Override>): string {\n const o = overrideRef.current;\n return o && o.realPath === pathname ? o.customPath : pathname;\n}\n\nfunction resolveProps(\n pathname: string,\n propsOverrideRef: MutableRefObject<PropsOverride>\n): Record<string, string> | undefined {\n const o = propsOverrideRef.current;\n return o && o.realPath === pathname ? o.props : undefined;\n}\n\nfunction mergeProps(\n screenProps: Record<string, string> | undefined,\n explicitProps: Record<string, string> | undefined\n): Record<string, string> | undefined {\n if (!screenProps && !explicitProps) return undefined;\n if (!screenProps) return explicitProps;\n if (!explicitProps) return screenProps;\n return { ...screenProps, ...explicitProps };\n}\n*/\n\nfunction mergeConfig(config: ExpoAnalyticsConfig): InternalConfig {\n return {\n hostname: config.hostname,\n collectorUrl: config.collectorUrl ?? 'https://collector.onedollarstats.com/events',\n autocollect: config.autocollect ?? true,\n devmode: config.devmode ?? false,\n collapseDynamicRoutes: config.collapseDynamicRoutes ?? true,\n excludePages: config.excludePages,\n includePages: config.includePages\n };\n}\n\nfunction isGroupSegment(segment: string): boolean {\n return /^\\(.+\\)$/.test(segment);\n}\n\nfunction collapsePath(segments: readonly string[]): string {\n const visible = segments.filter(s => !isGroupSegment(s));\n if (visible.length === 0) return '/';\n return '/' + visible.join('/');\n}\n\nfunction useTrackedPath(config: InternalConfig): string {\n const pathname = usePathname();\n const segments = useSegments();\n return config.collapseDynamicRoutes ? collapsePath(segments as readonly string[]) : pathname;\n}\n\nfunction isWebLocalhost(): boolean {\n if (Platform.OS !== 'web') return false;\n if (typeof window === 'undefined' || !window.location) return false;\n const { hostname, protocol } = window.location;\n return (\n /^localhost$|^127(\\.[0-9]+){0,2}\\.[0-9]+$|^\\[::1?\\]$/.test(hostname) &&\n (protocol === 'http:' || protocol === 'https:')\n );\n}\n\nfunction useRequiredContext(caller: string): ContextValue {\n const ctx = useContext(Context);\n if (!ctx) {\n throw new Error(\n `[onedollarstats] ${caller} must be used inside <OneDollarStatsProvider>. ` +\n `Wrap your root layout with the provider.`\n );\n }\n return ctx;\n}\n\nexport type OneDollarStatsProviderProps = {\n config: ExpoAnalyticsConfig;\n children: ReactNode;\n};\n\nexport function OneDollarStatsProvider({ config, children }: OneDollarStatsProviderProps) {\n const merged = useMemo(\n () => mergeConfig(config),\n [\n config.hostname,\n config.collectorUrl,\n config.autocollect,\n config.devmode,\n config.collapseDynamicRoutes,\n config.excludePages,\n config.includePages\n ]\n );\n\n const lastPathRef = useRef<string | null>(null);\n // TODO(page-scope): restore when page-scope detection is designed.\n // const overrideRef = useRef<Override>(null);\n // const propsOverrideRef = useRef<PropsOverride>(null);\n const announcedRef = useRef(false);\n const trackedPath = useTrackedPath(merged);\n\n useEffect(() => {\n if (announcedRef.current) return;\n if (merged.devmode && isWebLocalhost()) {\n console.log(\n `[onedollarstats]\\nOneDollarStats connected! Tracking localhost as ${merged.hostname}`\n );\n }\n announcedRef.current = true;\n }, [merged.devmode, merged.hostname]);\n\n useEffect(() => {\n if (!merged.autocollect) return;\n if (isExcluded(trackedPath, merged)) return;\n if (lastPathRef.current === trackedPath) return;\n lastPathRef.current = trackedPath;\n send('PageView', trackedPath, merged);\n }, [trackedPath, merged]);\n\n useEffect(() => {\n const handler = (state: AppStateStatus) => {\n if (state !== 'active') return;\n if (!merged.autocollect) return;\n const current = lastPathRef.current;\n if (!current || isExcluded(current, merged)) return;\n send('PageView', current, merged);\n };\n const sub = AppState.addEventListener('change', handler);\n return () => sub.remove();\n }, [merged]);\n\n const value = useMemo<ContextValue>(\n () => ({ config: merged, lastPathRef }),\n [merged]\n );\n\n return createElement(Context.Provider, { value }, children);\n}\n\ntype Props = Record<string, string>;\n\nexport type AnalyticsAPI = {\n event(eventName: string, pathOrProps?: string | Props, props?: Props): void;\n view(pathOrProps?: string | Props, props?: Props): void;\n};\n\nexport function useAnalytics(): AnalyticsAPI {\n const ctx = useRequiredContext('useAnalytics');\n const trackedPath = useTrackedPath(ctx.config);\n\n const event = useCallback(\n (eventName: string, pathOrProps?: string | Props, props?: Props) => {\n const targetPath = typeof pathOrProps === 'string' ? pathOrProps : trackedPath;\n const eventProps = typeof pathOrProps === 'object' ? pathOrProps : props;\n send(eventName, targetPath, ctx.config, eventProps);\n },\n [ctx, trackedPath]\n );\n\n const view = useCallback(\n (pathOrProps?: string | Props, props?: Props) => {\n const targetPath = typeof pathOrProps === 'string' ? pathOrProps : trackedPath;\n const viewProps = typeof pathOrProps === 'object' ? pathOrProps : props;\n send('PageView', targetPath, ctx.config, viewProps);\n },\n [ctx, trackedPath]\n );\n\n return { event, view };\n}\n\n// TODO(page-scope): restore when page-scope detection is designed.\n/*\nexport function useAnalyticsPath(customPath: string): void {\n const ctx = useRequiredContext('useAnalyticsPath');\n const pathname = usePathname();\n useEffect(() => {\n const entry: Override = { realPath: pathname, customPath, source: 'hook' };\n ctx.overrideRef.current = entry;\n return () => {\n if (ctx.overrideRef.current === entry) ctx.overrideRef.current = null;\n };\n }, [ctx, pathname, customPath]);\n}\n\nexport type AnalyticsPathProps = { path: string };\n\nexport function AnalyticsPath({ path }: AnalyticsPathProps): null {\n const ctx = useRequiredContext('AnalyticsPath');\n const pathname = usePathname();\n useEffect(() => {\n const existing = ctx.overrideRef.current;\n if (existing && existing.realPath === pathname && existing.source === 'hook') return;\n const entry: Override = { realPath: pathname, customPath: path, source: 'component' };\n ctx.overrideRef.current = entry;\n return () => {\n if (ctx.overrideRef.current === entry) ctx.overrideRef.current = null;\n };\n }, [ctx, pathname, path]);\n return null;\n}\n\nexport function useAnalyticsProps(props: Record<string, string>): void {\n const ctx = useRequiredContext('useAnalyticsProps');\n const pathname = usePathname();\n useEffect(() => {\n const entry: PropsOverride = { realPath: pathname, props, source: 'hook' };\n ctx.propsOverrideRef.current = entry;\n return () => {\n if (ctx.propsOverrideRef.current === entry) ctx.propsOverrideRef.current = null;\n };\n }, [ctx, pathname, props]);\n}\n\nexport type AnalyticsPropsProps = Record<string, string>;\n\nexport function AnalyticsProps(props: AnalyticsPropsProps): null {\n const ctx = useRequiredContext('AnalyticsProps');\n const pathname = usePathname();\n useEffect(() => {\n const existing = ctx.propsOverrideRef.current;\n if (existing && existing.realPath === pathname && existing.source === 'hook') return;\n const entry: PropsOverride = { realPath: pathname, props, source: 'component' };\n ctx.propsOverrideRef.current = entry;\n return () => {\n if (ctx.propsOverrideRef.current === entry) ctx.propsOverrideRef.current = null;\n };\n }, [ctx, pathname, props]);\n return null;\n}\n*/\n\nfunction shouldSkipSend(config: InternalConfig): boolean {\n if (Platform.OS !== 'web') return false;\n if (isWebLocalhost() && !config.devmode) return true;\n return false;\n}\n\nfunction devLog(label: string, url: string, props?: Record<string, string>): void {\n let msg = `[onedollarstats]\\nEvent name: ${label}\\nEvent collected from: ${url}`;\n if (props && Object.keys(props).length > 0) {\n msg += `\\nProps: ${JSON.stringify(props, null, 2)}`;\n }\n console.log(msg);\n}\n\nconst SAFE_GET_THRESHOLD = 1500;\n\nfunction send(\n eventName: string,\n path: string,\n config: InternalConfig,\n props?: Record<string, string>\n): void {\n if (shouldSkipSend(config)) return;\n const url = `https://${config.hostname}${path}`;\n if (config.devmode) devLog(eventName, url, props);\n\n const body = JSON.stringify({\n u: url,\n e: [{ t: eventName, ...(props && { p: props }) }]\n });\n\n if (Platform.OS === 'web') {\n sendWeb(config.collectorUrl, body);\n } else {\n sendNative(config.collectorUrl, body);\n }\n}\n\nfunction sendNative(collectorUrl: string, body: string): void {\n fetch(collectorUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body\n }).catch(() => {});\n}\n\nfunction sendWeb(collectorUrl: string, body: string): void {\n const bytes = new TextEncoder().encode(body);\n const bin = String.fromCharCode(...bytes);\n const payloadBase64 = btoa(bin);\n\n if (payloadBase64.length <= SAFE_GET_THRESHOLD) {\n const img = new Image(1, 1);\n img.onerror = () => sendBeaconOrFetch(collectorUrl, body);\n img.src = `${collectorUrl}?data=${payloadBase64}`;\n return;\n }\n\n sendBeaconOrFetch(collectorUrl, body);\n}\n\nfunction sendBeaconOrFetch(collectorUrl: string, body: string): void {\n if (typeof navigator !== 'undefined' && navigator.sendBeacon?.(collectorUrl, body)) {\n return;\n }\n\n fetch(collectorUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body,\n keepalive: true\n }).catch(() => {});\n}\n\nfunction pathMatches(path: string, prefix: string): boolean {\n return path === prefix || path.startsWith(prefix.endsWith('/') ? prefix : prefix + '/');\n}\n\nfunction isExcluded(path: string, config: InternalConfig): boolean {\n if (config.includePages?.length) {\n return !config.includePages.some(p => pathMatches(path, p));\n }\n if (config.excludePages?.length) {\n return config.excludePages.some(p => pathMatches(path, p));\n }\n return false;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onedollarstats",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"description": "A lightweight, zero-dependency analytics tracker for frontend apps",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -8,14 +8,23 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
|
-
"
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./expo": {
|
|
14
|
+
"types": "./dist/expo.d.ts",
|
|
15
|
+
"react-native": "./dist/expo.js",
|
|
16
|
+
"browser": "./dist/expo.js",
|
|
17
|
+
"default": "./dist/expo.js"
|
|
12
18
|
}
|
|
13
19
|
},
|
|
14
20
|
"files": [
|
|
15
21
|
"dist/index.js",
|
|
16
22
|
"dist/index.js.map",
|
|
17
23
|
"dist/index.d.ts",
|
|
18
|
-
"dist/types.d.ts"
|
|
24
|
+
"dist/types.d.ts",
|
|
25
|
+
"dist/expo.js",
|
|
26
|
+
"dist/expo.js.map",
|
|
27
|
+
"dist/expo.d.ts"
|
|
19
28
|
],
|
|
20
29
|
"keywords": [
|
|
21
30
|
"analytics",
|
|
@@ -29,29 +38,55 @@
|
|
|
29
38
|
],
|
|
30
39
|
"author": "",
|
|
31
40
|
"license": "MIT",
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"expo-router": ">=3.0.0",
|
|
43
|
+
"react": ">=18.0.0",
|
|
44
|
+
"react-native": ">=0.73.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"expo-router": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"react": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"react-native": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
32
57
|
"devDependencies": {
|
|
58
|
+
"@testing-library/react": "^14.0.0",
|
|
33
59
|
"@types/jsdom": "^27.0.0",
|
|
60
|
+
"@types/react": "^18.0.0",
|
|
61
|
+
"@types/react-native": "^0.73.0",
|
|
34
62
|
"esbuild": "^0.25.10",
|
|
63
|
+
"expo-router": "^3.0.0",
|
|
35
64
|
"jsdom": "^26.0.0",
|
|
36
65
|
"puppeteer": "^24.1.1",
|
|
66
|
+
"react": "^18.0.0",
|
|
67
|
+
"react-native": "^0.73.0",
|
|
37
68
|
"tsx": "^4.19.2",
|
|
38
69
|
"typescript": "^5.9.2",
|
|
39
70
|
"vitest": "^4.0.18"
|
|
40
71
|
},
|
|
41
72
|
"scripts": {
|
|
42
|
-
"build": "tsc -p tsconfig.build.json --emitDeclarationOnly && rm -rf dist/utils dist/script.d.ts dist/debug-modal.d.ts dist/globals.d.ts && esbuild src/index.ts --bundle --outfile=dist/index.js --platform=browser --format=esm --target=es2020 --sourcemap --legal-comments=none --define:DEBUG_SCRIPT_URL='\"https://assets.onedollarstats.com/stonks-debug.js\"'",
|
|
73
|
+
"build": "tsc -p tsconfig.build.json --emitDeclarationOnly && rm -rf dist/utils dist/script.d.ts dist/debug-modal.d.ts dist/globals.d.ts && esbuild src/index.ts --bundle --outfile=dist/index.js --platform=browser --format=esm --target=es2020 --sourcemap --legal-comments=none --define:DEBUG_SCRIPT_URL='\"https://assets.onedollarstats.com/stonks-debug.js\"' && esbuild src/expo.ts --bundle=false --outfile=dist/expo.js --platform=neutral --format=esm --target=es2020 --sourcemap --legal-comments=none",
|
|
43
74
|
"bundle": "esbuild src/script.ts --bundle --minify --format=iife --outfile=build/stonks.js --legal-comments=none --define:DEBUG_SCRIPT_URL='\"https://assets.onedollarstats.com/stonks-debug.js\"'",
|
|
44
75
|
"bundle:dev": "esbuild src/script.ts --bundle --minify --format=iife --outfile=build/stonks.js --legal-comments=none --define:DEBUG_SCRIPT_URL='\"https://assets.onedollarstats.com/stonks-debug-dev.js\"'",
|
|
45
76
|
"bundle:test": "esbuild src/script.ts --bundle --format=cjs --outfile=build/stonks.js --legal-comments=none --define:DEBUG_SCRIPT_URL='\"https://assets.onedollarstats.com/stonks-debug.js\"'",
|
|
46
77
|
"bundle:debug": "esbuild src/debug-modal.ts --bundle --minify --format=iife --outfile=build/stonks-debug.js --legal-comments=none",
|
|
47
78
|
"test": "vitest run",
|
|
48
|
-
"test:
|
|
49
|
-
"test:
|
|
50
|
-
"test:
|
|
79
|
+
"test:unit": "vitest run --project 'Web Unit Tests' --project 'Expo Unit Tests'",
|
|
80
|
+
"test:unit:web": "vitest run --project 'Web Unit Tests'",
|
|
81
|
+
"test:unit:web:modal": "vitest run --project 'Web Unit Tests' src/utils/create-modal.test.ts",
|
|
82
|
+
"test:unit:expo": "vitest run --project 'Expo Unit Tests'",
|
|
83
|
+
"test:e2e": "vitest run --project 'Web E2E Tests' --project 'Package E2E Tests' --project 'Expo E2E Tests'",
|
|
84
|
+
"test:e2e:web": "vitest run --project 'Web E2E Tests'",
|
|
51
85
|
"test:e2e:package": "vitest run --project 'Package E2E Tests'",
|
|
52
|
-
"test:e2e": "vitest run --project '
|
|
53
|
-
"test:build
|
|
54
|
-
"test:build
|
|
86
|
+
"test:e2e:expo": "vitest run --project 'Expo E2E Tests'",
|
|
87
|
+
"test:build:e2e:web": "pnpm bundle:test && FORCE_BUILD=1 COPY_TRACKER=1 vitest run --project 'Web E2E Tests'",
|
|
88
|
+
"test:build:e2e:package": "pnpm build && FORCE_BUILD=1 COPY_PACKAGE=1 vitest run --project 'Package E2E Tests'",
|
|
89
|
+
"test:build:e2e:expo": "pnpm build && FORCE_BUILD=1 COPY_PACKAGE=1 vitest run --project 'Expo E2E Tests'",
|
|
55
90
|
"test:all": "pnpm bundle:test && pnpm build && FORCE_BUILD=1 COPY_TRACKER=1 COPY_PACKAGE=1 vitest run"
|
|
56
91
|
}
|
|
57
92
|
}
|