@stillkinetic/web-sdk 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 +53 -0
- package/dist/index.d.mts +76 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +389 -0
- package/dist/index.mjs +361 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @stillkinetic/web-sdk
|
|
2
|
+
|
|
3
|
+
Behavioral tracking + threshold-triggered Stripe billing for web apps and JS desktop apps (Electron, Tauri).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @stillkinetic/web-sdk @stripe/stripe-js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`@stripe/stripe-js` is a peer dependency — install it alongside.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { StillKinetic } from '@stillkinetic/web-sdk';
|
|
17
|
+
|
|
18
|
+
const tp = new TrackPay({
|
|
19
|
+
appId: 'app_123',
|
|
20
|
+
apiKey: 'pk_app_abc', // issued by the platform dashboard
|
|
21
|
+
apiBaseUrl: 'https://api.yourplatform.com',
|
|
22
|
+
stripePublishableKey: 'pk_live_...',
|
|
23
|
+
endUserId: currentUser.id,
|
|
24
|
+
trackedMetrics: ['stay_duration', 'scroll_length'], // pick any subset of the 5
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
tp.start();
|
|
28
|
+
|
|
29
|
+
// ... later, e.g. when a "you're nearing your free usage" banner shows:
|
|
30
|
+
const result = await tp.setupBilling('#card-container', {
|
|
31
|
+
amountCents: 5000, // $50.00
|
|
32
|
+
period: 'monthly',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
console.error(result.error);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// On unmount / route change:
|
|
40
|
+
tp.stop();
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`setupBilling` must be called once per end user before the backend will fire any charge for them — this is the single consent screen in the whole flow. It mounts a Stripe Card Element into the DOM node matching `containerSelector`, so make sure that element exists first, e.g.:
|
|
44
|
+
|
|
45
|
+
```html
|
|
46
|
+
<div id="card-container"></div>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Notes
|
|
50
|
+
|
|
51
|
+
- All five metrics (`press_count`, `scroll_length`, `scroll_speed`, `type_speed`, `stay_duration`) are tracked passively; only the ones listed in `trackedMetrics` are enabled.
|
|
52
|
+
- The backend re-validates `trackedMetrics` against the app's subscription tier and will ignore any metric the tier doesn't allow — the SDK does not enforce this client-side.
|
|
53
|
+
- Charges never fire from this package directly. It only reports metrics and handles the one-time card bind; the threshold engine and all Stripe charge calls live server-side (see the `platform` app).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
type Metric = 'press_count' | 'scroll_length' | 'scroll_speed' | 'type_speed' | 'stay_duration' | 'swipe_count' | 'pinch_zoom_count' | 'long_press_count' | 'form_submit_count' | 'tab_switch_count' | 'search_count' | 'video_play_count' | 'video_watch_duration' | 'file_download_count' | 'share_count' | 'mouse_distance';
|
|
2
|
+
declare const ALL_METRICS: Metric[];
|
|
3
|
+
interface TrackEvent {
|
|
4
|
+
appId: string;
|
|
5
|
+
endUserId: string;
|
|
6
|
+
pageId: string;
|
|
7
|
+
metric: Metric;
|
|
8
|
+
value: number;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
interface StillKineticConfig {
|
|
12
|
+
/** App ID issued by the platform dashboard */
|
|
13
|
+
appId: string;
|
|
14
|
+
/** Public API key issued by the platform dashboard (safe for client use) */
|
|
15
|
+
apiKey: string;
|
|
16
|
+
/** Base URL of the platform API, e.g. https://api.yourplatform.com */
|
|
17
|
+
apiBaseUrl: string;
|
|
18
|
+
/** Stripe publishable key (safe for client use) */
|
|
19
|
+
stripePublishableKey: string;
|
|
20
|
+
/** Stable identifier for the current end user (your own user id, or an anonymous id you generate/store) */
|
|
21
|
+
endUserId: string;
|
|
22
|
+
/** Which metrics this app has opted to track. Must be a subset of what the app's subscription tier allows; the backend re-validates and will silently drop disallowed metrics. */
|
|
23
|
+
trackedMetrics: Metric[];
|
|
24
|
+
/** How often to flush the local event buffer, in ms. Default 5000. */
|
|
25
|
+
batchIntervalMs?: number;
|
|
26
|
+
/** Identifier for the current page/screen. Defaults to location.pathname on web. */
|
|
27
|
+
pageId?: string;
|
|
28
|
+
}
|
|
29
|
+
interface SpendingCapInput {
|
|
30
|
+
amountCents: number;
|
|
31
|
+
period: 'weekly' | 'monthly';
|
|
32
|
+
}
|
|
33
|
+
interface CardBindResult {
|
|
34
|
+
success: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* StillKinetic web SDK.
|
|
40
|
+
*
|
|
41
|
+
* const sk = new StillKinetic({
|
|
42
|
+
* appId: 'app_123',
|
|
43
|
+
* apiKey: 'pk_app_...',
|
|
44
|
+
* apiBaseUrl: 'https://api.yourplatform.com',
|
|
45
|
+
* stripePublishableKey: 'pk_live_...',
|
|
46
|
+
* endUserId: currentUser.id,
|
|
47
|
+
* trackedMetrics: ['stay_duration', 'scroll_length'],
|
|
48
|
+
* });
|
|
49
|
+
* sk.start();
|
|
50
|
+
*
|
|
51
|
+
* // Later, when the host app wants to prompt the end user to bind a card
|
|
52
|
+
* // and set a spending limit (required once before any charge can fire):
|
|
53
|
+
* await sk.setupBilling('#card-container', { amountCents: 5000, period: 'monthly' });
|
|
54
|
+
*/
|
|
55
|
+
declare class StillKinetic {
|
|
56
|
+
private config;
|
|
57
|
+
private sender;
|
|
58
|
+
private detachFns;
|
|
59
|
+
private started;
|
|
60
|
+
constructor(config: StillKineticConfig);
|
|
61
|
+
private getPageId;
|
|
62
|
+
/** Begin tracking the metrics the host app selected in trackedMetrics. */
|
|
63
|
+
start(): void;
|
|
64
|
+
/** Stop all tracking and flush any buffered events. */
|
|
65
|
+
stop(): void;
|
|
66
|
+
/**
|
|
67
|
+
* One-time UI flow: mounts a Stripe card element into the given container,
|
|
68
|
+
* binds the end user's card, and sets their spending cap. Must succeed
|
|
69
|
+
* before the backend will fire any threshold-triggered charge for this
|
|
70
|
+
* end user. Call this once (e.g. on first threshold-nearing warning, or
|
|
71
|
+
* at signup) — never on every action.
|
|
72
|
+
*/
|
|
73
|
+
setupBilling(containerSelector: string, cap: SpendingCapInput): Promise<CardBindResult>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { ALL_METRICS, type CardBindResult, type Metric, type SpendingCapInput, StillKinetic, type StillKineticConfig, type TrackEvent, StillKinetic as default };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
type Metric = 'press_count' | 'scroll_length' | 'scroll_speed' | 'type_speed' | 'stay_duration' | 'swipe_count' | 'pinch_zoom_count' | 'long_press_count' | 'form_submit_count' | 'tab_switch_count' | 'search_count' | 'video_play_count' | 'video_watch_duration' | 'file_download_count' | 'share_count' | 'mouse_distance';
|
|
2
|
+
declare const ALL_METRICS: Metric[];
|
|
3
|
+
interface TrackEvent {
|
|
4
|
+
appId: string;
|
|
5
|
+
endUserId: string;
|
|
6
|
+
pageId: string;
|
|
7
|
+
metric: Metric;
|
|
8
|
+
value: number;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
interface StillKineticConfig {
|
|
12
|
+
/** App ID issued by the platform dashboard */
|
|
13
|
+
appId: string;
|
|
14
|
+
/** Public API key issued by the platform dashboard (safe for client use) */
|
|
15
|
+
apiKey: string;
|
|
16
|
+
/** Base URL of the platform API, e.g. https://api.yourplatform.com */
|
|
17
|
+
apiBaseUrl: string;
|
|
18
|
+
/** Stripe publishable key (safe for client use) */
|
|
19
|
+
stripePublishableKey: string;
|
|
20
|
+
/** Stable identifier for the current end user (your own user id, or an anonymous id you generate/store) */
|
|
21
|
+
endUserId: string;
|
|
22
|
+
/** Which metrics this app has opted to track. Must be a subset of what the app's subscription tier allows; the backend re-validates and will silently drop disallowed metrics. */
|
|
23
|
+
trackedMetrics: Metric[];
|
|
24
|
+
/** How often to flush the local event buffer, in ms. Default 5000. */
|
|
25
|
+
batchIntervalMs?: number;
|
|
26
|
+
/** Identifier for the current page/screen. Defaults to location.pathname on web. */
|
|
27
|
+
pageId?: string;
|
|
28
|
+
}
|
|
29
|
+
interface SpendingCapInput {
|
|
30
|
+
amountCents: number;
|
|
31
|
+
period: 'weekly' | 'monthly';
|
|
32
|
+
}
|
|
33
|
+
interface CardBindResult {
|
|
34
|
+
success: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* StillKinetic web SDK.
|
|
40
|
+
*
|
|
41
|
+
* const sk = new StillKinetic({
|
|
42
|
+
* appId: 'app_123',
|
|
43
|
+
* apiKey: 'pk_app_...',
|
|
44
|
+
* apiBaseUrl: 'https://api.yourplatform.com',
|
|
45
|
+
* stripePublishableKey: 'pk_live_...',
|
|
46
|
+
* endUserId: currentUser.id,
|
|
47
|
+
* trackedMetrics: ['stay_duration', 'scroll_length'],
|
|
48
|
+
* });
|
|
49
|
+
* sk.start();
|
|
50
|
+
*
|
|
51
|
+
* // Later, when the host app wants to prompt the end user to bind a card
|
|
52
|
+
* // and set a spending limit (required once before any charge can fire):
|
|
53
|
+
* await sk.setupBilling('#card-container', { amountCents: 5000, period: 'monthly' });
|
|
54
|
+
*/
|
|
55
|
+
declare class StillKinetic {
|
|
56
|
+
private config;
|
|
57
|
+
private sender;
|
|
58
|
+
private detachFns;
|
|
59
|
+
private started;
|
|
60
|
+
constructor(config: StillKineticConfig);
|
|
61
|
+
private getPageId;
|
|
62
|
+
/** Begin tracking the metrics the host app selected in trackedMetrics. */
|
|
63
|
+
start(): void;
|
|
64
|
+
/** Stop all tracking and flush any buffered events. */
|
|
65
|
+
stop(): void;
|
|
66
|
+
/**
|
|
67
|
+
* One-time UI flow: mounts a Stripe card element into the given container,
|
|
68
|
+
* binds the end user's card, and sets their spending cap. Must succeed
|
|
69
|
+
* before the backend will fire any threshold-triggered charge for this
|
|
70
|
+
* end user. Call this once (e.g. on first threshold-nearing warning, or
|
|
71
|
+
* at signup) — never on every action.
|
|
72
|
+
*/
|
|
73
|
+
setupBilling(containerSelector: string, cap: SpendingCapInput): Promise<CardBindResult>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { ALL_METRICS, type CardBindResult, type Metric, type SpendingCapInput, StillKinetic, type StillKineticConfig, type TrackEvent, StillKinetic as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
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
|
+
ALL_METRICS: () => ALL_METRICS,
|
|
24
|
+
StillKinetic: () => StillKinetic,
|
|
25
|
+
default: () => index_default
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
|
|
29
|
+
// src/transport/batchSender.ts
|
|
30
|
+
var BatchSender = class {
|
|
31
|
+
constructor(config) {
|
|
32
|
+
this.config = config;
|
|
33
|
+
this.buffer = [];
|
|
34
|
+
this.timer = null;
|
|
35
|
+
this.maxBufferSize = 200;
|
|
36
|
+
this.endpoint = `${config.apiBaseUrl.replace(/\/$/, "")}/api/events`;
|
|
37
|
+
}
|
|
38
|
+
start() {
|
|
39
|
+
const intervalMs = this.config.batchIntervalMs ?? 5e3;
|
|
40
|
+
this.timer = setInterval(() => this.flush(), intervalMs);
|
|
41
|
+
if (typeof window !== "undefined") {
|
|
42
|
+
window.addEventListener("beforeunload", () => this.flush(true));
|
|
43
|
+
document.addEventListener("visibilitychange", () => {
|
|
44
|
+
if (document.visibilityState === "hidden") this.flush(true);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
stop() {
|
|
49
|
+
if (this.timer) clearInterval(this.timer);
|
|
50
|
+
this.flush(true);
|
|
51
|
+
}
|
|
52
|
+
enqueue(event) {
|
|
53
|
+
this.buffer.push(event);
|
|
54
|
+
if (this.buffer.length >= this.maxBufferSize) this.flush();
|
|
55
|
+
}
|
|
56
|
+
flush(useBeacon = false) {
|
|
57
|
+
if (this.buffer.length === 0) return;
|
|
58
|
+
const payload = JSON.stringify({
|
|
59
|
+
appId: this.config.appId,
|
|
60
|
+
events: this.buffer
|
|
61
|
+
});
|
|
62
|
+
const events = this.buffer;
|
|
63
|
+
this.buffer = [];
|
|
64
|
+
if (useBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
|
|
65
|
+
const blob = new Blob([payload], { type: "application/json" });
|
|
66
|
+
navigator.sendBeacon(`${this.endpoint}?apiKey=${encodeURIComponent(this.config.apiKey)}`, blob);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.sendWithRetry(payload, events, 3);
|
|
70
|
+
}
|
|
71
|
+
sendWithRetry(payload, events, retriesLeft) {
|
|
72
|
+
fetch(this.endpoint, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"X-Api-Key": this.config.apiKey
|
|
77
|
+
},
|
|
78
|
+
body: payload,
|
|
79
|
+
keepalive: true
|
|
80
|
+
}).catch(() => {
|
|
81
|
+
if (retriesLeft > 0) {
|
|
82
|
+
const delay = Math.pow(2, 3 - retriesLeft) * 1e3;
|
|
83
|
+
setTimeout(() => this.sendWithRetry(payload, events, retriesLeft - 1), delay);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/trackers/pressTracker.ts
|
|
90
|
+
function attachPressTracker(config, sender, getPageId) {
|
|
91
|
+
const handler = () => {
|
|
92
|
+
sender.enqueue({
|
|
93
|
+
appId: config.appId,
|
|
94
|
+
endUserId: config.endUserId,
|
|
95
|
+
pageId: getPageId(),
|
|
96
|
+
metric: "press_count",
|
|
97
|
+
value: 1,
|
|
98
|
+
timestamp: Date.now()
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
document.addEventListener("pointerdown", handler, { passive: true });
|
|
102
|
+
return () => document.removeEventListener("pointerdown", handler);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/trackers/scrollTracker.ts
|
|
106
|
+
function attachScrollTracker(config, sender, getPageId, enabledMetrics) {
|
|
107
|
+
const wantsLength = enabledMetrics.includes("scroll_length");
|
|
108
|
+
const wantsSpeed = enabledMetrics.includes("scroll_speed");
|
|
109
|
+
if (!wantsLength && !wantsSpeed) return () => {
|
|
110
|
+
};
|
|
111
|
+
let lastY = window.scrollY;
|
|
112
|
+
let lastTimestamp = performance.now();
|
|
113
|
+
let throttleHandle = null;
|
|
114
|
+
const sample = () => {
|
|
115
|
+
const currentY = window.scrollY;
|
|
116
|
+
const now = performance.now();
|
|
117
|
+
const deltaY = Math.abs(currentY - lastY);
|
|
118
|
+
const deltaT = Math.max(now - lastTimestamp, 1);
|
|
119
|
+
if (wantsLength && deltaY > 0) {
|
|
120
|
+
sender.enqueue({
|
|
121
|
+
appId: config.appId,
|
|
122
|
+
endUserId: config.endUserId,
|
|
123
|
+
pageId: getPageId(),
|
|
124
|
+
metric: "scroll_length",
|
|
125
|
+
value: deltaY,
|
|
126
|
+
timestamp: Date.now()
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (wantsSpeed && deltaY > 0) {
|
|
130
|
+
const pxPerMs = deltaY / deltaT;
|
|
131
|
+
sender.enqueue({
|
|
132
|
+
appId: config.appId,
|
|
133
|
+
endUserId: config.endUserId,
|
|
134
|
+
pageId: getPageId(),
|
|
135
|
+
metric: "scroll_speed",
|
|
136
|
+
value: pxPerMs,
|
|
137
|
+
timestamp: Date.now()
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
lastY = currentY;
|
|
141
|
+
lastTimestamp = now;
|
|
142
|
+
throttleHandle = null;
|
|
143
|
+
};
|
|
144
|
+
const handler = () => {
|
|
145
|
+
if (throttleHandle !== null) return;
|
|
146
|
+
throttleHandle = window.requestAnimationFrame(sample);
|
|
147
|
+
};
|
|
148
|
+
window.addEventListener("scroll", handler, { passive: true });
|
|
149
|
+
return () => window.removeEventListener("scroll", handler);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/trackers/typeTracker.ts
|
|
153
|
+
function attachTypeTracker(config, sender, getPageId) {
|
|
154
|
+
const timestamps = [];
|
|
155
|
+
const windowMs = 1e4;
|
|
156
|
+
const isCharacterKey = (e) => e.key.length === 1 || e.key === "Backspace" || e.key === "Enter";
|
|
157
|
+
const isTextEntryTarget = (target) => {
|
|
158
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
159
|
+
const tag = target.tagName;
|
|
160
|
+
return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
|
|
161
|
+
};
|
|
162
|
+
const handler = (e) => {
|
|
163
|
+
if (!isTextEntryTarget(e.target) || !isCharacterKey(e)) return;
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
timestamps.push(now);
|
|
166
|
+
while (timestamps.length > 0 && now - timestamps[0] > windowMs) {
|
|
167
|
+
timestamps.shift();
|
|
168
|
+
}
|
|
169
|
+
if (timestamps.length < 2) return;
|
|
170
|
+
const elapsedMinutes = (now - timestamps[0]) / 6e4;
|
|
171
|
+
const charsPerMinute = elapsedMinutes > 0 ? timestamps.length / elapsedMinutes : 0;
|
|
172
|
+
sender.enqueue({
|
|
173
|
+
appId: config.appId,
|
|
174
|
+
endUserId: config.endUserId,
|
|
175
|
+
pageId: getPageId(),
|
|
176
|
+
metric: "type_speed",
|
|
177
|
+
value: Math.round(charsPerMinute),
|
|
178
|
+
timestamp: now
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
document.addEventListener("keydown", handler, { passive: true });
|
|
182
|
+
return () => document.removeEventListener("keydown", handler);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/trackers/stayTracker.ts
|
|
186
|
+
function attachStayTracker(config, sender, getPageId) {
|
|
187
|
+
let activeMsSinceLastReport = 0;
|
|
188
|
+
let segmentStart = document.visibilityState === "visible" ? Date.now() : null;
|
|
189
|
+
let reportHandle = null;
|
|
190
|
+
const closeSegment = () => {
|
|
191
|
+
if (segmentStart !== null) {
|
|
192
|
+
activeMsSinceLastReport += Date.now() - segmentStart;
|
|
193
|
+
segmentStart = null;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const openSegment = () => {
|
|
197
|
+
if (segmentStart === null) segmentStart = Date.now();
|
|
198
|
+
};
|
|
199
|
+
const report = () => {
|
|
200
|
+
closeSegment();
|
|
201
|
+
const elapsed = activeMsSinceLastReport;
|
|
202
|
+
activeMsSinceLastReport = 0;
|
|
203
|
+
if (elapsed > 0) {
|
|
204
|
+
sender.enqueue({
|
|
205
|
+
appId: config.appId,
|
|
206
|
+
endUserId: config.endUserId,
|
|
207
|
+
pageId: getPageId(),
|
|
208
|
+
metric: "stay_duration",
|
|
209
|
+
value: elapsed,
|
|
210
|
+
timestamp: Date.now()
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
openSegment();
|
|
214
|
+
};
|
|
215
|
+
const visibilityHandler = () => {
|
|
216
|
+
if (document.visibilityState === "visible") openSegment();
|
|
217
|
+
else closeSegment();
|
|
218
|
+
};
|
|
219
|
+
document.addEventListener("visibilitychange", visibilityHandler);
|
|
220
|
+
window.addEventListener("blur", closeSegment);
|
|
221
|
+
window.addEventListener("focus", openSegment);
|
|
222
|
+
reportHandle = setInterval(report, 5e3);
|
|
223
|
+
return () => {
|
|
224
|
+
closeSegment();
|
|
225
|
+
const remaining = activeMsSinceLastReport;
|
|
226
|
+
if (remaining > 0) {
|
|
227
|
+
sender.enqueue({
|
|
228
|
+
appId: config.appId,
|
|
229
|
+
endUserId: config.endUserId,
|
|
230
|
+
pageId: getPageId(),
|
|
231
|
+
metric: "stay_duration",
|
|
232
|
+
value: remaining,
|
|
233
|
+
timestamp: Date.now()
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
document.removeEventListener("visibilitychange", visibilityHandler);
|
|
237
|
+
window.removeEventListener("blur", closeSegment);
|
|
238
|
+
window.removeEventListener("focus", openSegment);
|
|
239
|
+
if (reportHandle) clearInterval(reportHandle);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/billing/CardBind.ts
|
|
244
|
+
var import_stripe_js = require("@stripe/stripe-js");
|
|
245
|
+
var CardBind = class {
|
|
246
|
+
constructor(config) {
|
|
247
|
+
this.config = config;
|
|
248
|
+
this.stripe = null;
|
|
249
|
+
this.cardElement = null;
|
|
250
|
+
}
|
|
251
|
+
async mount(containerSelector) {
|
|
252
|
+
this.stripe = await (0, import_stripe_js.loadStripe)(this.config.stripePublishableKey);
|
|
253
|
+
if (!this.stripe) throw new Error("StillKinetic: Stripe.js failed to load.");
|
|
254
|
+
const elements = this.stripe.elements();
|
|
255
|
+
this.cardElement = elements.create("card");
|
|
256
|
+
const container = document.querySelector(containerSelector);
|
|
257
|
+
if (!container) throw new Error(`StillKinetic: container "${containerSelector}" not found.`);
|
|
258
|
+
this.cardElement.mount(container);
|
|
259
|
+
}
|
|
260
|
+
async submit(cap) {
|
|
261
|
+
if (!this.stripe || !this.cardElement) {
|
|
262
|
+
return { success: false, error: "Card element not mounted. Call mount() first." };
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const setupResp = await fetch(
|
|
266
|
+
`${this.config.apiBaseUrl.replace(/\/$/, "")}/api/billing/setup-intent`,
|
|
267
|
+
{
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: { "Content-Type": "application/json", "X-Api-Key": this.config.apiKey },
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
appId: this.config.appId,
|
|
272
|
+
endUserId: this.config.endUserId
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
if (!setupResp.ok) {
|
|
277
|
+
return { success: false, error: `Failed to initialize card setup (${setupResp.status}).` };
|
|
278
|
+
}
|
|
279
|
+
const { clientSecret } = await setupResp.json();
|
|
280
|
+
const confirmResult = await this.stripe.confirmCardSetup(clientSecret, {
|
|
281
|
+
payment_method: { card: this.cardElement }
|
|
282
|
+
});
|
|
283
|
+
if (confirmResult.error) {
|
|
284
|
+
return { success: false, error: confirmResult.error.message };
|
|
285
|
+
}
|
|
286
|
+
const paymentMethodId = confirmResult.setupIntent?.payment_method;
|
|
287
|
+
if (!paymentMethodId || typeof paymentMethodId !== "string") {
|
|
288
|
+
return { success: false, error: "No payment method returned by Stripe." };
|
|
289
|
+
}
|
|
290
|
+
const capResp = await fetch(
|
|
291
|
+
`${this.config.apiBaseUrl.replace(/\/$/, "")}/api/billing/end-user-config`,
|
|
292
|
+
{
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: { "Content-Type": "application/json", "X-Api-Key": this.config.apiKey },
|
|
295
|
+
body: JSON.stringify({
|
|
296
|
+
appId: this.config.appId,
|
|
297
|
+
endUserId: this.config.endUserId,
|
|
298
|
+
paymentMethodId,
|
|
299
|
+
spendingCapCents: cap.amountCents,
|
|
300
|
+
spendingCapPeriod: cap.period
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
if (!capResp.ok) {
|
|
305
|
+
return { success: false, error: `Card saved, but spending cap could not be set (${capResp.status}).` };
|
|
306
|
+
}
|
|
307
|
+
return { success: true };
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return { success: false, error: err instanceof Error ? err.message : "Unknown error during card bind." };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// src/core/types.ts
|
|
315
|
+
var ALL_METRICS = [
|
|
316
|
+
"press_count",
|
|
317
|
+
"scroll_length",
|
|
318
|
+
"scroll_speed",
|
|
319
|
+
"type_speed",
|
|
320
|
+
"stay_duration",
|
|
321
|
+
"swipe_count",
|
|
322
|
+
"pinch_zoom_count",
|
|
323
|
+
"long_press_count",
|
|
324
|
+
"form_submit_count",
|
|
325
|
+
"tab_switch_count",
|
|
326
|
+
"search_count",
|
|
327
|
+
"video_play_count",
|
|
328
|
+
"video_watch_duration",
|
|
329
|
+
"file_download_count",
|
|
330
|
+
"share_count",
|
|
331
|
+
"mouse_distance"
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
// src/index.ts
|
|
335
|
+
var StillKinetic = class {
|
|
336
|
+
constructor(config) {
|
|
337
|
+
this.config = config;
|
|
338
|
+
this.detachFns = [];
|
|
339
|
+
this.started = false;
|
|
340
|
+
this.getPageId = () => this.config.pageId ?? (typeof window !== "undefined" ? window.location.pathname : "unknown");
|
|
341
|
+
this.sender = new BatchSender(config);
|
|
342
|
+
}
|
|
343
|
+
/** Begin tracking the metrics the host app selected in trackedMetrics. */
|
|
344
|
+
start() {
|
|
345
|
+
if (this.started) return;
|
|
346
|
+
this.started = true;
|
|
347
|
+
this.sender.start();
|
|
348
|
+
const metrics = new Set(this.config.trackedMetrics);
|
|
349
|
+
if (metrics.has("press_count")) {
|
|
350
|
+
this.detachFns.push(attachPressTracker(this.config, this.sender, this.getPageId));
|
|
351
|
+
}
|
|
352
|
+
if (metrics.has("scroll_length") || metrics.has("scroll_speed")) {
|
|
353
|
+
this.detachFns.push(
|
|
354
|
+
attachScrollTracker(this.config, this.sender, this.getPageId, this.config.trackedMetrics)
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (metrics.has("type_speed")) {
|
|
358
|
+
this.detachFns.push(attachTypeTracker(this.config, this.sender, this.getPageId));
|
|
359
|
+
}
|
|
360
|
+
if (metrics.has("stay_duration")) {
|
|
361
|
+
this.detachFns.push(attachStayTracker(this.config, this.sender, this.getPageId));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Stop all tracking and flush any buffered events. */
|
|
365
|
+
stop() {
|
|
366
|
+
this.detachFns.forEach((fn) => fn());
|
|
367
|
+
this.detachFns = [];
|
|
368
|
+
this.sender.stop();
|
|
369
|
+
this.started = false;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* One-time UI flow: mounts a Stripe card element into the given container,
|
|
373
|
+
* binds the end user's card, and sets their spending cap. Must succeed
|
|
374
|
+
* before the backend will fire any threshold-triggered charge for this
|
|
375
|
+
* end user. Call this once (e.g. on first threshold-nearing warning, or
|
|
376
|
+
* at signup) — never on every action.
|
|
377
|
+
*/
|
|
378
|
+
async setupBilling(containerSelector, cap) {
|
|
379
|
+
const bind = new CardBind(this.config);
|
|
380
|
+
await bind.mount(containerSelector);
|
|
381
|
+
return bind.submit(cap);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
var index_default = StillKinetic;
|
|
385
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
386
|
+
0 && (module.exports = {
|
|
387
|
+
ALL_METRICS,
|
|
388
|
+
StillKinetic
|
|
389
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// src/transport/batchSender.ts
|
|
2
|
+
var BatchSender = class {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
this.config = config;
|
|
5
|
+
this.buffer = [];
|
|
6
|
+
this.timer = null;
|
|
7
|
+
this.maxBufferSize = 200;
|
|
8
|
+
this.endpoint = `${config.apiBaseUrl.replace(/\/$/, "")}/api/events`;
|
|
9
|
+
}
|
|
10
|
+
start() {
|
|
11
|
+
const intervalMs = this.config.batchIntervalMs ?? 5e3;
|
|
12
|
+
this.timer = setInterval(() => this.flush(), intervalMs);
|
|
13
|
+
if (typeof window !== "undefined") {
|
|
14
|
+
window.addEventListener("beforeunload", () => this.flush(true));
|
|
15
|
+
document.addEventListener("visibilitychange", () => {
|
|
16
|
+
if (document.visibilityState === "hidden") this.flush(true);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
stop() {
|
|
21
|
+
if (this.timer) clearInterval(this.timer);
|
|
22
|
+
this.flush(true);
|
|
23
|
+
}
|
|
24
|
+
enqueue(event) {
|
|
25
|
+
this.buffer.push(event);
|
|
26
|
+
if (this.buffer.length >= this.maxBufferSize) this.flush();
|
|
27
|
+
}
|
|
28
|
+
flush(useBeacon = false) {
|
|
29
|
+
if (this.buffer.length === 0) return;
|
|
30
|
+
const payload = JSON.stringify({
|
|
31
|
+
appId: this.config.appId,
|
|
32
|
+
events: this.buffer
|
|
33
|
+
});
|
|
34
|
+
const events = this.buffer;
|
|
35
|
+
this.buffer = [];
|
|
36
|
+
if (useBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
|
|
37
|
+
const blob = new Blob([payload], { type: "application/json" });
|
|
38
|
+
navigator.sendBeacon(`${this.endpoint}?apiKey=${encodeURIComponent(this.config.apiKey)}`, blob);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.sendWithRetry(payload, events, 3);
|
|
42
|
+
}
|
|
43
|
+
sendWithRetry(payload, events, retriesLeft) {
|
|
44
|
+
fetch(this.endpoint, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
"X-Api-Key": this.config.apiKey
|
|
49
|
+
},
|
|
50
|
+
body: payload,
|
|
51
|
+
keepalive: true
|
|
52
|
+
}).catch(() => {
|
|
53
|
+
if (retriesLeft > 0) {
|
|
54
|
+
const delay = Math.pow(2, 3 - retriesLeft) * 1e3;
|
|
55
|
+
setTimeout(() => this.sendWithRetry(payload, events, retriesLeft - 1), delay);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/trackers/pressTracker.ts
|
|
62
|
+
function attachPressTracker(config, sender, getPageId) {
|
|
63
|
+
const handler = () => {
|
|
64
|
+
sender.enqueue({
|
|
65
|
+
appId: config.appId,
|
|
66
|
+
endUserId: config.endUserId,
|
|
67
|
+
pageId: getPageId(),
|
|
68
|
+
metric: "press_count",
|
|
69
|
+
value: 1,
|
|
70
|
+
timestamp: Date.now()
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
document.addEventListener("pointerdown", handler, { passive: true });
|
|
74
|
+
return () => document.removeEventListener("pointerdown", handler);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/trackers/scrollTracker.ts
|
|
78
|
+
function attachScrollTracker(config, sender, getPageId, enabledMetrics) {
|
|
79
|
+
const wantsLength = enabledMetrics.includes("scroll_length");
|
|
80
|
+
const wantsSpeed = enabledMetrics.includes("scroll_speed");
|
|
81
|
+
if (!wantsLength && !wantsSpeed) return () => {
|
|
82
|
+
};
|
|
83
|
+
let lastY = window.scrollY;
|
|
84
|
+
let lastTimestamp = performance.now();
|
|
85
|
+
let throttleHandle = null;
|
|
86
|
+
const sample = () => {
|
|
87
|
+
const currentY = window.scrollY;
|
|
88
|
+
const now = performance.now();
|
|
89
|
+
const deltaY = Math.abs(currentY - lastY);
|
|
90
|
+
const deltaT = Math.max(now - lastTimestamp, 1);
|
|
91
|
+
if (wantsLength && deltaY > 0) {
|
|
92
|
+
sender.enqueue({
|
|
93
|
+
appId: config.appId,
|
|
94
|
+
endUserId: config.endUserId,
|
|
95
|
+
pageId: getPageId(),
|
|
96
|
+
metric: "scroll_length",
|
|
97
|
+
value: deltaY,
|
|
98
|
+
timestamp: Date.now()
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (wantsSpeed && deltaY > 0) {
|
|
102
|
+
const pxPerMs = deltaY / deltaT;
|
|
103
|
+
sender.enqueue({
|
|
104
|
+
appId: config.appId,
|
|
105
|
+
endUserId: config.endUserId,
|
|
106
|
+
pageId: getPageId(),
|
|
107
|
+
metric: "scroll_speed",
|
|
108
|
+
value: pxPerMs,
|
|
109
|
+
timestamp: Date.now()
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
lastY = currentY;
|
|
113
|
+
lastTimestamp = now;
|
|
114
|
+
throttleHandle = null;
|
|
115
|
+
};
|
|
116
|
+
const handler = () => {
|
|
117
|
+
if (throttleHandle !== null) return;
|
|
118
|
+
throttleHandle = window.requestAnimationFrame(sample);
|
|
119
|
+
};
|
|
120
|
+
window.addEventListener("scroll", handler, { passive: true });
|
|
121
|
+
return () => window.removeEventListener("scroll", handler);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/trackers/typeTracker.ts
|
|
125
|
+
function attachTypeTracker(config, sender, getPageId) {
|
|
126
|
+
const timestamps = [];
|
|
127
|
+
const windowMs = 1e4;
|
|
128
|
+
const isCharacterKey = (e) => e.key.length === 1 || e.key === "Backspace" || e.key === "Enter";
|
|
129
|
+
const isTextEntryTarget = (target) => {
|
|
130
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
131
|
+
const tag = target.tagName;
|
|
132
|
+
return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
|
|
133
|
+
};
|
|
134
|
+
const handler = (e) => {
|
|
135
|
+
if (!isTextEntryTarget(e.target) || !isCharacterKey(e)) return;
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
timestamps.push(now);
|
|
138
|
+
while (timestamps.length > 0 && now - timestamps[0] > windowMs) {
|
|
139
|
+
timestamps.shift();
|
|
140
|
+
}
|
|
141
|
+
if (timestamps.length < 2) return;
|
|
142
|
+
const elapsedMinutes = (now - timestamps[0]) / 6e4;
|
|
143
|
+
const charsPerMinute = elapsedMinutes > 0 ? timestamps.length / elapsedMinutes : 0;
|
|
144
|
+
sender.enqueue({
|
|
145
|
+
appId: config.appId,
|
|
146
|
+
endUserId: config.endUserId,
|
|
147
|
+
pageId: getPageId(),
|
|
148
|
+
metric: "type_speed",
|
|
149
|
+
value: Math.round(charsPerMinute),
|
|
150
|
+
timestamp: now
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
document.addEventListener("keydown", handler, { passive: true });
|
|
154
|
+
return () => document.removeEventListener("keydown", handler);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/trackers/stayTracker.ts
|
|
158
|
+
function attachStayTracker(config, sender, getPageId) {
|
|
159
|
+
let activeMsSinceLastReport = 0;
|
|
160
|
+
let segmentStart = document.visibilityState === "visible" ? Date.now() : null;
|
|
161
|
+
let reportHandle = null;
|
|
162
|
+
const closeSegment = () => {
|
|
163
|
+
if (segmentStart !== null) {
|
|
164
|
+
activeMsSinceLastReport += Date.now() - segmentStart;
|
|
165
|
+
segmentStart = null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const openSegment = () => {
|
|
169
|
+
if (segmentStart === null) segmentStart = Date.now();
|
|
170
|
+
};
|
|
171
|
+
const report = () => {
|
|
172
|
+
closeSegment();
|
|
173
|
+
const elapsed = activeMsSinceLastReport;
|
|
174
|
+
activeMsSinceLastReport = 0;
|
|
175
|
+
if (elapsed > 0) {
|
|
176
|
+
sender.enqueue({
|
|
177
|
+
appId: config.appId,
|
|
178
|
+
endUserId: config.endUserId,
|
|
179
|
+
pageId: getPageId(),
|
|
180
|
+
metric: "stay_duration",
|
|
181
|
+
value: elapsed,
|
|
182
|
+
timestamp: Date.now()
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
openSegment();
|
|
186
|
+
};
|
|
187
|
+
const visibilityHandler = () => {
|
|
188
|
+
if (document.visibilityState === "visible") openSegment();
|
|
189
|
+
else closeSegment();
|
|
190
|
+
};
|
|
191
|
+
document.addEventListener("visibilitychange", visibilityHandler);
|
|
192
|
+
window.addEventListener("blur", closeSegment);
|
|
193
|
+
window.addEventListener("focus", openSegment);
|
|
194
|
+
reportHandle = setInterval(report, 5e3);
|
|
195
|
+
return () => {
|
|
196
|
+
closeSegment();
|
|
197
|
+
const remaining = activeMsSinceLastReport;
|
|
198
|
+
if (remaining > 0) {
|
|
199
|
+
sender.enqueue({
|
|
200
|
+
appId: config.appId,
|
|
201
|
+
endUserId: config.endUserId,
|
|
202
|
+
pageId: getPageId(),
|
|
203
|
+
metric: "stay_duration",
|
|
204
|
+
value: remaining,
|
|
205
|
+
timestamp: Date.now()
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
document.removeEventListener("visibilitychange", visibilityHandler);
|
|
209
|
+
window.removeEventListener("blur", closeSegment);
|
|
210
|
+
window.removeEventListener("focus", openSegment);
|
|
211
|
+
if (reportHandle) clearInterval(reportHandle);
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/billing/CardBind.ts
|
|
216
|
+
import { loadStripe } from "@stripe/stripe-js";
|
|
217
|
+
var CardBind = class {
|
|
218
|
+
constructor(config) {
|
|
219
|
+
this.config = config;
|
|
220
|
+
this.stripe = null;
|
|
221
|
+
this.cardElement = null;
|
|
222
|
+
}
|
|
223
|
+
async mount(containerSelector) {
|
|
224
|
+
this.stripe = await loadStripe(this.config.stripePublishableKey);
|
|
225
|
+
if (!this.stripe) throw new Error("StillKinetic: Stripe.js failed to load.");
|
|
226
|
+
const elements = this.stripe.elements();
|
|
227
|
+
this.cardElement = elements.create("card");
|
|
228
|
+
const container = document.querySelector(containerSelector);
|
|
229
|
+
if (!container) throw new Error(`StillKinetic: container "${containerSelector}" not found.`);
|
|
230
|
+
this.cardElement.mount(container);
|
|
231
|
+
}
|
|
232
|
+
async submit(cap) {
|
|
233
|
+
if (!this.stripe || !this.cardElement) {
|
|
234
|
+
return { success: false, error: "Card element not mounted. Call mount() first." };
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const setupResp = await fetch(
|
|
238
|
+
`${this.config.apiBaseUrl.replace(/\/$/, "")}/api/billing/setup-intent`,
|
|
239
|
+
{
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "Content-Type": "application/json", "X-Api-Key": this.config.apiKey },
|
|
242
|
+
body: JSON.stringify({
|
|
243
|
+
appId: this.config.appId,
|
|
244
|
+
endUserId: this.config.endUserId
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
if (!setupResp.ok) {
|
|
249
|
+
return { success: false, error: `Failed to initialize card setup (${setupResp.status}).` };
|
|
250
|
+
}
|
|
251
|
+
const { clientSecret } = await setupResp.json();
|
|
252
|
+
const confirmResult = await this.stripe.confirmCardSetup(clientSecret, {
|
|
253
|
+
payment_method: { card: this.cardElement }
|
|
254
|
+
});
|
|
255
|
+
if (confirmResult.error) {
|
|
256
|
+
return { success: false, error: confirmResult.error.message };
|
|
257
|
+
}
|
|
258
|
+
const paymentMethodId = confirmResult.setupIntent?.payment_method;
|
|
259
|
+
if (!paymentMethodId || typeof paymentMethodId !== "string") {
|
|
260
|
+
return { success: false, error: "No payment method returned by Stripe." };
|
|
261
|
+
}
|
|
262
|
+
const capResp = await fetch(
|
|
263
|
+
`${this.config.apiBaseUrl.replace(/\/$/, "")}/api/billing/end-user-config`,
|
|
264
|
+
{
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: { "Content-Type": "application/json", "X-Api-Key": this.config.apiKey },
|
|
267
|
+
body: JSON.stringify({
|
|
268
|
+
appId: this.config.appId,
|
|
269
|
+
endUserId: this.config.endUserId,
|
|
270
|
+
paymentMethodId,
|
|
271
|
+
spendingCapCents: cap.amountCents,
|
|
272
|
+
spendingCapPeriod: cap.period
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
if (!capResp.ok) {
|
|
277
|
+
return { success: false, error: `Card saved, but spending cap could not be set (${capResp.status}).` };
|
|
278
|
+
}
|
|
279
|
+
return { success: true };
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return { success: false, error: err instanceof Error ? err.message : "Unknown error during card bind." };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// src/core/types.ts
|
|
287
|
+
var ALL_METRICS = [
|
|
288
|
+
"press_count",
|
|
289
|
+
"scroll_length",
|
|
290
|
+
"scroll_speed",
|
|
291
|
+
"type_speed",
|
|
292
|
+
"stay_duration",
|
|
293
|
+
"swipe_count",
|
|
294
|
+
"pinch_zoom_count",
|
|
295
|
+
"long_press_count",
|
|
296
|
+
"form_submit_count",
|
|
297
|
+
"tab_switch_count",
|
|
298
|
+
"search_count",
|
|
299
|
+
"video_play_count",
|
|
300
|
+
"video_watch_duration",
|
|
301
|
+
"file_download_count",
|
|
302
|
+
"share_count",
|
|
303
|
+
"mouse_distance"
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
// src/index.ts
|
|
307
|
+
var StillKinetic = class {
|
|
308
|
+
constructor(config) {
|
|
309
|
+
this.config = config;
|
|
310
|
+
this.detachFns = [];
|
|
311
|
+
this.started = false;
|
|
312
|
+
this.getPageId = () => this.config.pageId ?? (typeof window !== "undefined" ? window.location.pathname : "unknown");
|
|
313
|
+
this.sender = new BatchSender(config);
|
|
314
|
+
}
|
|
315
|
+
/** Begin tracking the metrics the host app selected in trackedMetrics. */
|
|
316
|
+
start() {
|
|
317
|
+
if (this.started) return;
|
|
318
|
+
this.started = true;
|
|
319
|
+
this.sender.start();
|
|
320
|
+
const metrics = new Set(this.config.trackedMetrics);
|
|
321
|
+
if (metrics.has("press_count")) {
|
|
322
|
+
this.detachFns.push(attachPressTracker(this.config, this.sender, this.getPageId));
|
|
323
|
+
}
|
|
324
|
+
if (metrics.has("scroll_length") || metrics.has("scroll_speed")) {
|
|
325
|
+
this.detachFns.push(
|
|
326
|
+
attachScrollTracker(this.config, this.sender, this.getPageId, this.config.trackedMetrics)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (metrics.has("type_speed")) {
|
|
330
|
+
this.detachFns.push(attachTypeTracker(this.config, this.sender, this.getPageId));
|
|
331
|
+
}
|
|
332
|
+
if (metrics.has("stay_duration")) {
|
|
333
|
+
this.detachFns.push(attachStayTracker(this.config, this.sender, this.getPageId));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Stop all tracking and flush any buffered events. */
|
|
337
|
+
stop() {
|
|
338
|
+
this.detachFns.forEach((fn) => fn());
|
|
339
|
+
this.detachFns = [];
|
|
340
|
+
this.sender.stop();
|
|
341
|
+
this.started = false;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* One-time UI flow: mounts a Stripe card element into the given container,
|
|
345
|
+
* binds the end user's card, and sets their spending cap. Must succeed
|
|
346
|
+
* before the backend will fire any threshold-triggered charge for this
|
|
347
|
+
* end user. Call this once (e.g. on first threshold-nearing warning, or
|
|
348
|
+
* at signup) — never on every action.
|
|
349
|
+
*/
|
|
350
|
+
async setupBilling(containerSelector, cap) {
|
|
351
|
+
const bind = new CardBind(this.config);
|
|
352
|
+
await bind.mount(containerSelector);
|
|
353
|
+
return bind.submit(cap);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
var index_default = StillKinetic;
|
|
357
|
+
export {
|
|
358
|
+
ALL_METRICS,
|
|
359
|
+
StillKinetic,
|
|
360
|
+
index_default as default
|
|
361
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stillkinetic/web-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Selectable-metric behavioral tracking with threshold-triggered Stripe billing, for web apps.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "dist/index.cjs.js",
|
|
7
|
+
"module": "dist/index.esm.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --out-dir dist --clean",
|
|
15
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --out-dir dist --watch",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@stripe/stripe-js": "^3.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@stripe/stripe-js": "^3.5.0",
|
|
23
|
+
"tsup": "^8.0.2",
|
|
24
|
+
"typescript": "^5.9.3"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|