sellfolk 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 +195 -0
- package/dist/index.cjs +169 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +93 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +138 -0
- package/dist/index.js.map +1 -0
- package/dist/sellfolk.min.js +1 -0
- package/dist/server.cjs +73 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +52 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +47 -0
- package/dist/server.js.map +1 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# sellfolk
|
|
2
|
+
|
|
3
|
+
Two-line tracking SDK for [Sellfolk](https://sellfolk.com). Attribute signups, trials and paid conversions back to the seller who sent the visitor.
|
|
4
|
+
|
|
5
|
+
- ✅ **2 KB minified** browser bundle (sub-1 KB gzipped)
|
|
6
|
+
- ✅ **Zero dependencies** on the server SDK (Node 20+ native fetch)
|
|
7
|
+
- ✅ **`keepalive: true`** — fire-and-forget event delivery, survives page unload
|
|
8
|
+
- ✅ **`localStorage`** with 60-day ref expiry
|
|
9
|
+
- ✅ **Never throws** — silent failure path. Your SaaS keeps running.
|
|
10
|
+
- ESM + CJS + minified CDN bundle
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install sellfolk
|
|
18
|
+
pnpm add sellfolk
|
|
19
|
+
yarn add sellfolk
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or drop the CDN bundle into your HTML (see [Plain HTML](#plain-html) below).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Quickstart — browser
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import sellfolk from 'sellfolk'
|
|
30
|
+
|
|
31
|
+
// One-line init. Do this at the root of your app.
|
|
32
|
+
sellfolk.init('sk_live_acme_a8e92f...')
|
|
33
|
+
|
|
34
|
+
// Track any event. sellfolk auto-attaches the ref_id captured from ?ref= on init.
|
|
35
|
+
sellfolk.track('signup', { email: 'jane@stripe.com' })
|
|
36
|
+
|
|
37
|
+
// Fire when real money moves.
|
|
38
|
+
sellfolk.conversion({ amount: 199.00, email: 'jane@stripe.com' })
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The first time a visitor lands on your site via `https://yoursite.com?ref=jane123-acme`, sellfolk captures `jane123-acme` and stores it in `localStorage` for 60 days. Every subsequent `track()` or `conversion()` call automatically attaches that ref id — even on different pages, days later, after sign-in.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Framework recipes
|
|
46
|
+
|
|
47
|
+
### Vue 3 / Nuxt
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// plugins/sellfolk.client.ts
|
|
51
|
+
import sellfolk from 'sellfolk'
|
|
52
|
+
|
|
53
|
+
export default defineNuxtPlugin(() => {
|
|
54
|
+
sellfolk.init('sk_live_acme_a8e92f...')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Then anywhere in your components:
|
|
58
|
+
import sellfolk from 'sellfolk'
|
|
59
|
+
sellfolk.track('signup', { email: user.value.email })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### React / Next.js
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
// app/providers.tsx (Next 13+) or a top-level layout
|
|
66
|
+
'use client'
|
|
67
|
+
import { useEffect } from 'react'
|
|
68
|
+
import sellfolk from 'sellfolk'
|
|
69
|
+
|
|
70
|
+
export function SaaSBoostProvider({ children }: { children: React.ReactNode }) {
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
sellfolk.init('sk_live_acme_a8e92f...')
|
|
73
|
+
}, [])
|
|
74
|
+
return children
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// In a component:
|
|
78
|
+
import sellfolk from 'sellfolk'
|
|
79
|
+
|
|
80
|
+
function SignupForm() {
|
|
81
|
+
async function onSubmit(email: string) {
|
|
82
|
+
await fetch('/api/signup', { method: 'POST', body: JSON.stringify({ email }) })
|
|
83
|
+
sellfolk.track('signup', { email })
|
|
84
|
+
}
|
|
85
|
+
...
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Plain HTML
|
|
90
|
+
|
|
91
|
+
```html
|
|
92
|
+
<script src="https://cdn.jsdelivr.net/npm/sellfolk/dist/sellfolk.min.js"></script>
|
|
93
|
+
<script>
|
|
94
|
+
sellfolk.init('sk_live_acme_a8e92f...')
|
|
95
|
+
sellfolk.track('signup', { email: 'jane@stripe.com' })
|
|
96
|
+
</script>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The IIFE bundle exposes a global `sellfolk`.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Server SDK
|
|
104
|
+
|
|
105
|
+
Use the server SDK from inside your billing webhook so conversions are reported the moment money moves — they don't depend on a browser session.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { SellfolkServer } from 'sellfolk/server'
|
|
109
|
+
|
|
110
|
+
const sb = new SellfolkServer(process.env.SELLFOLK_KEY!)
|
|
111
|
+
|
|
112
|
+
// Inside your Stripe / Polar / Lemonsqueezy webhook handler:
|
|
113
|
+
export async function handleOrderPaid(order) {
|
|
114
|
+
await sb.conversion({
|
|
115
|
+
email: order.customer_email,
|
|
116
|
+
amount: order.amount_total / 100,
|
|
117
|
+
currency: order.currency,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The server SDK uses Node 20+'s native fetch — **zero runtime deps**.
|
|
123
|
+
|
|
124
|
+
By default it never throws. If you want errors to propagate (e.g. so your queue retries):
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const sb = new SellfolkServer(key, { throwOnError: true })
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## API reference
|
|
133
|
+
|
|
134
|
+
### Browser
|
|
135
|
+
|
|
136
|
+
#### `sellfolk.init(apiKey, options?)`
|
|
137
|
+
|
|
138
|
+
Initialize the SDK. Captures `?ref=` from the URL if present (otherwise reads the stored ref) and prepares the network client.
|
|
139
|
+
|
|
140
|
+
| Option | Type | Default | Description |
|
|
141
|
+
| --------------------- | -------- | -------------------------------- | ------------------------------------------------- |
|
|
142
|
+
| `apiBase` | string | `https://api.sellfolk.com` | Override (testing / self-hosted backends). |
|
|
143
|
+
| `forceClearOnEmptyUrl`| boolean | `false` | When true and URL has no `?ref=`, wipe stored ref.|
|
|
144
|
+
| `now` | function | `Date.now` | Deterministic clock (mostly for tests). |
|
|
145
|
+
|
|
146
|
+
#### `sellfolk.track(event, payload?)`
|
|
147
|
+
|
|
148
|
+
Send a non-conversion event. Common values: `'signup'`, `'trial'`, `'demo'`, `'newsletter'`. Custom names are allowed and recorded as `type='custom'` with `customName=<event>`.
|
|
149
|
+
|
|
150
|
+
#### `sellfolk.conversion(payload)`
|
|
151
|
+
|
|
152
|
+
Fire a conversion. `amount` is required. The backend attributes this to the stored ref id.
|
|
153
|
+
|
|
154
|
+
| Field | Type | Required | Notes |
|
|
155
|
+
| ---------- | -------- | -------- | ---------------------------------------------- |
|
|
156
|
+
| `amount` | number | ✅ | In your store's currency. |
|
|
157
|
+
| `email` | string | optional | Privacy-truncated server-side before display. |
|
|
158
|
+
| `currency` | string | optional | 3-letter ISO code. Defaults to USD server-side.|
|
|
159
|
+
|
|
160
|
+
#### `sellfolk.getRef()` / `sellfolk.clearRef()`
|
|
161
|
+
|
|
162
|
+
Read or clear the currently-stored ref id. Useful for "Was this user referred?" UI or to drop attribution on logout.
|
|
163
|
+
|
|
164
|
+
### Server
|
|
165
|
+
|
|
166
|
+
#### `new SellfolkServer(apiKey, options?)`
|
|
167
|
+
|
|
168
|
+
| Option | Type | Default |
|
|
169
|
+
| -------------- | --------- | ----------------------------- |
|
|
170
|
+
| `apiBase` | string | `https://api.sellfolk.com` |
|
|
171
|
+
| `throwOnError` | boolean | `false` |
|
|
172
|
+
| `fetch` | function | `globalThis.fetch` |
|
|
173
|
+
|
|
174
|
+
#### `sb.track(event, payload?)` / `sb.conversion(payload)`
|
|
175
|
+
|
|
176
|
+
Same shape as the browser SDK. Both are async.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## How attribution works
|
|
181
|
+
|
|
182
|
+
1. A seller shares `https://sellfolk.com/r/jane123-acme`
|
|
183
|
+
2. The visitor clicks. Sellfolk logs the click and 302-redirects to `https://yoursite.com?ref=jane123-acme`
|
|
184
|
+
3. Your site loads. `sellfolk.init()` captures `jane123-acme` and stores it in `localStorage` for 60 days.
|
|
185
|
+
4. The visitor browses, signs up, starts a trial, eventually pays.
|
|
186
|
+
5. Each event (`signup`, `trial`, `conversion`) is reported with the stored ref id.
|
|
187
|
+
6. Sellfolk attributes the conversion to `jane` and pays her per your listing's terms.
|
|
188
|
+
|
|
189
|
+
If the visitor returns from a different referral link later, the most recent click wins. If they wipe their browser storage, the chain is broken — that's by design (and matches every other attribution tool).
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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 src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
DEFAULT_API_BASE: () => DEFAULT_API_BASE,
|
|
24
|
+
REF_STORAGE_KEY: () => REF_STORAGE_KEY,
|
|
25
|
+
REF_TTL_MS: () => REF_TTL_MS,
|
|
26
|
+
clearStoredRef: () => clearStoredRef,
|
|
27
|
+
default: () => src_default,
|
|
28
|
+
extractRefFromUrl: () => extractRefFromUrl,
|
|
29
|
+
readStoredRef: () => readStoredRef,
|
|
30
|
+
writeStoredRef: () => writeStoredRef
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(src_exports);
|
|
33
|
+
var DEFAULT_API_BASE = "https://api.sellfolk.com";
|
|
34
|
+
var REF_STORAGE_KEY = "sellfolk_ref";
|
|
35
|
+
var REF_TTL_MS = 60 * 24 * 60 * 60 * 1e3;
|
|
36
|
+
var state = {
|
|
37
|
+
apiKey: null,
|
|
38
|
+
apiBase: DEFAULT_API_BASE,
|
|
39
|
+
refId: null,
|
|
40
|
+
now: () => Date.now()
|
|
41
|
+
};
|
|
42
|
+
function readStoredRef(now = Date.now) {
|
|
43
|
+
try {
|
|
44
|
+
if (typeof localStorage === "undefined") return null;
|
|
45
|
+
const raw = localStorage.getItem(REF_STORAGE_KEY);
|
|
46
|
+
if (!raw) return null;
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (!parsed || typeof parsed.refId !== "string" || typeof parsed.expiresAt !== "number") return null;
|
|
49
|
+
if (now() > parsed.expiresAt) {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.removeItem(REF_STORAGE_KEY);
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return parsed.refId;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function writeStoredRef(refId, now = Date.now) {
|
|
62
|
+
try {
|
|
63
|
+
if (typeof localStorage === "undefined") return;
|
|
64
|
+
const stored = { refId, expiresAt: now() + REF_TTL_MS };
|
|
65
|
+
localStorage.setItem(REF_STORAGE_KEY, JSON.stringify(stored));
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function clearStoredRef() {
|
|
70
|
+
try {
|
|
71
|
+
if (typeof localStorage === "undefined") return;
|
|
72
|
+
localStorage.removeItem(REF_STORAGE_KEY);
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function extractRefFromUrl(href) {
|
|
77
|
+
try {
|
|
78
|
+
const url = href ?? (typeof window !== "undefined" ? window.location.href : "");
|
|
79
|
+
if (!url) return null;
|
|
80
|
+
const u = new URL(url);
|
|
81
|
+
const ref = u.searchParams.get("ref");
|
|
82
|
+
return ref && ref.length > 0 && ref.length <= 80 ? ref : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function dispatch(path, body) {
|
|
88
|
+
if (!state.apiKey) return;
|
|
89
|
+
try {
|
|
90
|
+
fetch(`${state.apiBase}${path}`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
"X-API-Key": state.apiKey
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({ ...body, refId: state.refId }),
|
|
97
|
+
keepalive: true
|
|
98
|
+
}).catch(() => {
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
var sellfolk = {
|
|
104
|
+
/**
|
|
105
|
+
* Initialize the SDK with your public API key.
|
|
106
|
+
*
|
|
107
|
+
* sellfolk.init('sk_live_acme_a8e92f...')
|
|
108
|
+
*
|
|
109
|
+
* On first call, captures `?ref=` from the URL (if present) and stores it
|
|
110
|
+
* with a 60-day expiry. Subsequent track() / conversion() calls automatically
|
|
111
|
+
* include the stored ref_id.
|
|
112
|
+
*/
|
|
113
|
+
init(apiKey, options = {}) {
|
|
114
|
+
state.apiKey = apiKey;
|
|
115
|
+
state.apiBase = options.apiBase ?? DEFAULT_API_BASE;
|
|
116
|
+
if (options.now) state.now = options.now;
|
|
117
|
+
const urlRef = extractRefFromUrl();
|
|
118
|
+
if (urlRef) {
|
|
119
|
+
writeStoredRef(urlRef, state.now);
|
|
120
|
+
state.refId = urlRef;
|
|
121
|
+
} else if (options.forceClearOnEmptyUrl) {
|
|
122
|
+
clearStoredRef();
|
|
123
|
+
state.refId = null;
|
|
124
|
+
} else {
|
|
125
|
+
state.refId = readStoredRef(state.now);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
/**
|
|
129
|
+
* Track an event. Common values: 'signup', 'trial', 'demo', 'newsletter'.
|
|
130
|
+
* Custom event names are allowed (they go in as type='custom' with
|
|
131
|
+
* customName=<event>).
|
|
132
|
+
*/
|
|
133
|
+
track(event, payload = {}) {
|
|
134
|
+
dispatch("/api/v1/track", { event, ...payload });
|
|
135
|
+
},
|
|
136
|
+
/**
|
|
137
|
+
* Fire a conversion event — call this when real money moves.
|
|
138
|
+
* Example: inside your Stripe / Polar webhook handler.
|
|
139
|
+
*/
|
|
140
|
+
conversion(payload) {
|
|
141
|
+
dispatch("/api/v1/conversion", payload);
|
|
142
|
+
},
|
|
143
|
+
/**
|
|
144
|
+
* Read the current stored ref_id (e.g. for logging or attribution debugging).
|
|
145
|
+
* Returns null if no ref has been captured yet.
|
|
146
|
+
*/
|
|
147
|
+
getRef() {
|
|
148
|
+
return state.refId;
|
|
149
|
+
},
|
|
150
|
+
/** Programmatically clear the stored ref (e.g. on logout). */
|
|
151
|
+
clearRef() {
|
|
152
|
+
clearStoredRef();
|
|
153
|
+
state.refId = null;
|
|
154
|
+
},
|
|
155
|
+
// ── Testing handles (not part of public API) ──────────────────────────────
|
|
156
|
+
_state: state
|
|
157
|
+
};
|
|
158
|
+
var src_default = sellfolk;
|
|
159
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
160
|
+
0 && (module.exports = {
|
|
161
|
+
DEFAULT_API_BASE,
|
|
162
|
+
REF_STORAGE_KEY,
|
|
163
|
+
REF_TTL_MS,
|
|
164
|
+
clearStoredRef,
|
|
165
|
+
extractRefFromUrl,
|
|
166
|
+
readStoredRef,
|
|
167
|
+
writeStoredRef
|
|
168
|
+
});
|
|
169
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * sellfolk — Browser SDK\n *\n * Two lines to install:\n *\n * import sellfolk from 'sellfolk'\n * sellfolk.init('sk_live_...')\n *\n * Then call:\n * sellfolk.track('signup', { email })\n * sellfolk.conversion({ amount: 49.99, email })\n *\n * Design rules:\n * • Never throws. If anything fails, we swallow it. Your SaaS keeps running.\n * • `keepalive: true` on every fetch so requests survive page unload.\n * • Captures `?ref=` on init() and stores it in localStorage with 60-day expiry.\n * • All subsequent event calls auto-attach the stored ref_id.\n */\n\nexport const DEFAULT_API_BASE = 'https://api.sellfolk.com'\n\nexport const REF_STORAGE_KEY = 'sellfolk_ref'\n\nexport const REF_TTL_MS = 60 * 24 * 60 * 60 * 1000 // 60 days\n\nexport interface InitOptions {\n /** Override the API base URL (used for testing or self-hosted backends). */\n apiBase?: string\n /** Provide a deterministic clock — primarily for tests. */\n now?: () => number\n /**\n * If true, *replace* the stored ref with the one in the URL even when the\n * URL has none. Useful in tests; defaults to false (URL ref always wins,\n * but missing URL ref leaves the stored value untouched).\n */\n forceClearOnEmptyUrl?: boolean\n}\n\nexport interface StoredRef {\n refId: string\n expiresAt: number\n}\n\nexport interface TrackPayload {\n /** Optional email — privacy-truncated server-side before display. */\n email?: string\n /** Any extra metadata for the event. */\n [key: string]: unknown\n}\n\nexport interface ConversionPayload {\n amount: number\n email?: string\n currency?: string\n /** Anything else you want to attach (order_id, plan, etc.). */\n [key: string]: unknown\n}\n\n// ── Internal state ──────────────────────────────────────────────────────────\ninterface State {\n apiKey: string | null\n apiBase: string\n refId: string | null\n now: () => number\n}\n\nconst state: State = {\n apiKey: null,\n apiBase: DEFAULT_API_BASE,\n refId: null,\n now: () => Date.now(),\n}\n\n// ── Storage helpers (defensive — localStorage may be missing/disabled) ──────\nexport function readStoredRef(now: () => number = Date.now): string | null {\n try {\n if (typeof localStorage === 'undefined') return null\n const raw = localStorage.getItem(REF_STORAGE_KEY)\n if (!raw) return null\n const parsed = JSON.parse(raw) as StoredRef\n if (!parsed || typeof parsed.refId !== 'string' || typeof parsed.expiresAt !== 'number') return null\n if (now() > parsed.expiresAt) {\n try { localStorage.removeItem(REF_STORAGE_KEY) } catch { /* ignored */ }\n return null\n }\n return parsed.refId\n } catch {\n return null\n }\n}\n\nexport function writeStoredRef(refId: string, now: () => number = Date.now): void {\n try {\n if (typeof localStorage === 'undefined') return\n const stored: StoredRef = { refId, expiresAt: now() + REF_TTL_MS }\n localStorage.setItem(REF_STORAGE_KEY, JSON.stringify(stored))\n } catch {\n // ignored\n }\n}\n\nexport function clearStoredRef(): void {\n try {\n if (typeof localStorage === 'undefined') return\n localStorage.removeItem(REF_STORAGE_KEY)\n } catch {\n // ignored\n }\n}\n\n// ── URL parsing ─────────────────────────────────────────────────────────────\nexport function extractRefFromUrl(href?: string): string | null {\n try {\n const url = href ?? (typeof window !== 'undefined' ? window.location.href : '')\n if (!url) return null\n const u = new URL(url)\n const ref = u.searchParams.get('ref')\n return ref && ref.length > 0 && ref.length <= 80 ? ref : null\n } catch {\n return null\n }\n}\n\n// ── Network ─────────────────────────────────────────────────────────────────\nfunction dispatch(path: string, body: Record<string, unknown>): void {\n if (!state.apiKey) return\n try {\n // We intentionally do not await — keepalive ensures the request finishes\n // even if the page is unloading.\n fetch(`${state.apiBase}${path}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-API-Key': state.apiKey,\n },\n body: JSON.stringify({ ...body, refId: state.refId }),\n keepalive: true,\n }).catch(() => { /* never throw */ })\n } catch {\n // Browsers without fetch (or with extreme restrictions) — silent.\n }\n}\n\n// ── Public API ──────────────────────────────────────────────────────────────\nconst sellfolk = {\n /**\n * Initialize the SDK with your public API key.\n *\n * sellfolk.init('sk_live_acme_a8e92f...')\n *\n * On first call, captures `?ref=` from the URL (if present) and stores it\n * with a 60-day expiry. Subsequent track() / conversion() calls automatically\n * include the stored ref_id.\n */\n init(apiKey: string, options: InitOptions = {}): void {\n state.apiKey = apiKey\n state.apiBase = options.apiBase ?? DEFAULT_API_BASE\n if (options.now) state.now = options.now\n\n const urlRef = extractRefFromUrl()\n if (urlRef) {\n writeStoredRef(urlRef, state.now)\n state.refId = urlRef\n } else if (options.forceClearOnEmptyUrl) {\n clearStoredRef()\n state.refId = null\n } else {\n state.refId = readStoredRef(state.now)\n }\n },\n\n /**\n * Track an event. Common values: 'signup', 'trial', 'demo', 'newsletter'.\n * Custom event names are allowed (they go in as type='custom' with\n * customName=<event>).\n */\n track(event: string, payload: TrackPayload = {}): void {\n dispatch('/api/v1/track', { event, ...payload })\n },\n\n /**\n * Fire a conversion event — call this when real money moves.\n * Example: inside your Stripe / Polar webhook handler.\n */\n conversion(payload: ConversionPayload): void {\n dispatch('/api/v1/conversion', payload)\n },\n\n /**\n * Read the current stored ref_id (e.g. for logging or attribution debugging).\n * Returns null if no ref has been captured yet.\n */\n getRef(): string | null {\n return state.refId\n },\n\n /** Programmatically clear the stored ref (e.g. on logout). */\n clearRef(): void {\n clearStoredRef()\n state.refId = null\n },\n\n // ── Testing handles (not part of public API) ──────────────────────────────\n _state: state,\n}\n\nexport default sellfolk\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sellfolk — Browser SDK
|
|
3
|
+
*
|
|
4
|
+
* Two lines to install:
|
|
5
|
+
*
|
|
6
|
+
* import sellfolk from 'sellfolk'
|
|
7
|
+
* sellfolk.init('sk_live_...')
|
|
8
|
+
*
|
|
9
|
+
* Then call:
|
|
10
|
+
* sellfolk.track('signup', { email })
|
|
11
|
+
* sellfolk.conversion({ amount: 49.99, email })
|
|
12
|
+
*
|
|
13
|
+
* Design rules:
|
|
14
|
+
* • Never throws. If anything fails, we swallow it. Your SaaS keeps running.
|
|
15
|
+
* • `keepalive: true` on every fetch so requests survive page unload.
|
|
16
|
+
* • Captures `?ref=` on init() and stores it in localStorage with 60-day expiry.
|
|
17
|
+
* • All subsequent event calls auto-attach the stored ref_id.
|
|
18
|
+
*/
|
|
19
|
+
declare const DEFAULT_API_BASE = "https://api.sellfolk.com";
|
|
20
|
+
declare const REF_STORAGE_KEY = "sellfolk_ref";
|
|
21
|
+
declare const REF_TTL_MS: number;
|
|
22
|
+
interface InitOptions {
|
|
23
|
+
/** Override the API base URL (used for testing or self-hosted backends). */
|
|
24
|
+
apiBase?: string;
|
|
25
|
+
/** Provide a deterministic clock — primarily for tests. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
/**
|
|
28
|
+
* If true, *replace* the stored ref with the one in the URL even when the
|
|
29
|
+
* URL has none. Useful in tests; defaults to false (URL ref always wins,
|
|
30
|
+
* but missing URL ref leaves the stored value untouched).
|
|
31
|
+
*/
|
|
32
|
+
forceClearOnEmptyUrl?: boolean;
|
|
33
|
+
}
|
|
34
|
+
interface StoredRef {
|
|
35
|
+
refId: string;
|
|
36
|
+
expiresAt: number;
|
|
37
|
+
}
|
|
38
|
+
interface TrackPayload {
|
|
39
|
+
/** Optional email — privacy-truncated server-side before display. */
|
|
40
|
+
email?: string;
|
|
41
|
+
/** Any extra metadata for the event. */
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
interface ConversionPayload {
|
|
45
|
+
amount: number;
|
|
46
|
+
email?: string;
|
|
47
|
+
currency?: string;
|
|
48
|
+
/** Anything else you want to attach (order_id, plan, etc.). */
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
interface State {
|
|
52
|
+
apiKey: string | null;
|
|
53
|
+
apiBase: string;
|
|
54
|
+
refId: string | null;
|
|
55
|
+
now: () => number;
|
|
56
|
+
}
|
|
57
|
+
declare function readStoredRef(now?: () => number): string | null;
|
|
58
|
+
declare function writeStoredRef(refId: string, now?: () => number): void;
|
|
59
|
+
declare function clearStoredRef(): void;
|
|
60
|
+
declare function extractRefFromUrl(href?: string): string | null;
|
|
61
|
+
declare const sellfolk: {
|
|
62
|
+
/**
|
|
63
|
+
* Initialize the SDK with your public API key.
|
|
64
|
+
*
|
|
65
|
+
* sellfolk.init('sk_live_acme_a8e92f...')
|
|
66
|
+
*
|
|
67
|
+
* On first call, captures `?ref=` from the URL (if present) and stores it
|
|
68
|
+
* with a 60-day expiry. Subsequent track() / conversion() calls automatically
|
|
69
|
+
* include the stored ref_id.
|
|
70
|
+
*/
|
|
71
|
+
init(apiKey: string, options?: InitOptions): void;
|
|
72
|
+
/**
|
|
73
|
+
* Track an event. Common values: 'signup', 'trial', 'demo', 'newsletter'.
|
|
74
|
+
* Custom event names are allowed (they go in as type='custom' with
|
|
75
|
+
* customName=<event>).
|
|
76
|
+
*/
|
|
77
|
+
track(event: string, payload?: TrackPayload): void;
|
|
78
|
+
/**
|
|
79
|
+
* Fire a conversion event — call this when real money moves.
|
|
80
|
+
* Example: inside your Stripe / Polar webhook handler.
|
|
81
|
+
*/
|
|
82
|
+
conversion(payload: ConversionPayload): void;
|
|
83
|
+
/**
|
|
84
|
+
* Read the current stored ref_id (e.g. for logging or attribution debugging).
|
|
85
|
+
* Returns null if no ref has been captured yet.
|
|
86
|
+
*/
|
|
87
|
+
getRef(): string | null;
|
|
88
|
+
/** Programmatically clear the stored ref (e.g. on logout). */
|
|
89
|
+
clearRef(): void;
|
|
90
|
+
_state: State;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export { type ConversionPayload, DEFAULT_API_BASE, type InitOptions, REF_STORAGE_KEY, REF_TTL_MS, type StoredRef, type TrackPayload, clearStoredRef, sellfolk as default, extractRefFromUrl, readStoredRef, writeStoredRef };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sellfolk — Browser SDK
|
|
3
|
+
*
|
|
4
|
+
* Two lines to install:
|
|
5
|
+
*
|
|
6
|
+
* import sellfolk from 'sellfolk'
|
|
7
|
+
* sellfolk.init('sk_live_...')
|
|
8
|
+
*
|
|
9
|
+
* Then call:
|
|
10
|
+
* sellfolk.track('signup', { email })
|
|
11
|
+
* sellfolk.conversion({ amount: 49.99, email })
|
|
12
|
+
*
|
|
13
|
+
* Design rules:
|
|
14
|
+
* • Never throws. If anything fails, we swallow it. Your SaaS keeps running.
|
|
15
|
+
* • `keepalive: true` on every fetch so requests survive page unload.
|
|
16
|
+
* • Captures `?ref=` on init() and stores it in localStorage with 60-day expiry.
|
|
17
|
+
* • All subsequent event calls auto-attach the stored ref_id.
|
|
18
|
+
*/
|
|
19
|
+
declare const DEFAULT_API_BASE = "https://api.sellfolk.com";
|
|
20
|
+
declare const REF_STORAGE_KEY = "sellfolk_ref";
|
|
21
|
+
declare const REF_TTL_MS: number;
|
|
22
|
+
interface InitOptions {
|
|
23
|
+
/** Override the API base URL (used for testing or self-hosted backends). */
|
|
24
|
+
apiBase?: string;
|
|
25
|
+
/** Provide a deterministic clock — primarily for tests. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
/**
|
|
28
|
+
* If true, *replace* the stored ref with the one in the URL even when the
|
|
29
|
+
* URL has none. Useful in tests; defaults to false (URL ref always wins,
|
|
30
|
+
* but missing URL ref leaves the stored value untouched).
|
|
31
|
+
*/
|
|
32
|
+
forceClearOnEmptyUrl?: boolean;
|
|
33
|
+
}
|
|
34
|
+
interface StoredRef {
|
|
35
|
+
refId: string;
|
|
36
|
+
expiresAt: number;
|
|
37
|
+
}
|
|
38
|
+
interface TrackPayload {
|
|
39
|
+
/** Optional email — privacy-truncated server-side before display. */
|
|
40
|
+
email?: string;
|
|
41
|
+
/** Any extra metadata for the event. */
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
interface ConversionPayload {
|
|
45
|
+
amount: number;
|
|
46
|
+
email?: string;
|
|
47
|
+
currency?: string;
|
|
48
|
+
/** Anything else you want to attach (order_id, plan, etc.). */
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
interface State {
|
|
52
|
+
apiKey: string | null;
|
|
53
|
+
apiBase: string;
|
|
54
|
+
refId: string | null;
|
|
55
|
+
now: () => number;
|
|
56
|
+
}
|
|
57
|
+
declare function readStoredRef(now?: () => number): string | null;
|
|
58
|
+
declare function writeStoredRef(refId: string, now?: () => number): void;
|
|
59
|
+
declare function clearStoredRef(): void;
|
|
60
|
+
declare function extractRefFromUrl(href?: string): string | null;
|
|
61
|
+
declare const sellfolk: {
|
|
62
|
+
/**
|
|
63
|
+
* Initialize the SDK with your public API key.
|
|
64
|
+
*
|
|
65
|
+
* sellfolk.init('sk_live_acme_a8e92f...')
|
|
66
|
+
*
|
|
67
|
+
* On first call, captures `?ref=` from the URL (if present) and stores it
|
|
68
|
+
* with a 60-day expiry. Subsequent track() / conversion() calls automatically
|
|
69
|
+
* include the stored ref_id.
|
|
70
|
+
*/
|
|
71
|
+
init(apiKey: string, options?: InitOptions): void;
|
|
72
|
+
/**
|
|
73
|
+
* Track an event. Common values: 'signup', 'trial', 'demo', 'newsletter'.
|
|
74
|
+
* Custom event names are allowed (they go in as type='custom' with
|
|
75
|
+
* customName=<event>).
|
|
76
|
+
*/
|
|
77
|
+
track(event: string, payload?: TrackPayload): void;
|
|
78
|
+
/**
|
|
79
|
+
* Fire a conversion event — call this when real money moves.
|
|
80
|
+
* Example: inside your Stripe / Polar webhook handler.
|
|
81
|
+
*/
|
|
82
|
+
conversion(payload: ConversionPayload): void;
|
|
83
|
+
/**
|
|
84
|
+
* Read the current stored ref_id (e.g. for logging or attribution debugging).
|
|
85
|
+
* Returns null if no ref has been captured yet.
|
|
86
|
+
*/
|
|
87
|
+
getRef(): string | null;
|
|
88
|
+
/** Programmatically clear the stored ref (e.g. on logout). */
|
|
89
|
+
clearRef(): void;
|
|
90
|
+
_state: State;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export { type ConversionPayload, DEFAULT_API_BASE, type InitOptions, REF_STORAGE_KEY, REF_TTL_MS, type StoredRef, type TrackPayload, clearStoredRef, sellfolk as default, extractRefFromUrl, readStoredRef, writeStoredRef };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var DEFAULT_API_BASE = "https://api.sellfolk.com";
|
|
3
|
+
var REF_STORAGE_KEY = "sellfolk_ref";
|
|
4
|
+
var REF_TTL_MS = 60 * 24 * 60 * 60 * 1e3;
|
|
5
|
+
var state = {
|
|
6
|
+
apiKey: null,
|
|
7
|
+
apiBase: DEFAULT_API_BASE,
|
|
8
|
+
refId: null,
|
|
9
|
+
now: () => Date.now()
|
|
10
|
+
};
|
|
11
|
+
function readStoredRef(now = Date.now) {
|
|
12
|
+
try {
|
|
13
|
+
if (typeof localStorage === "undefined") return null;
|
|
14
|
+
const raw = localStorage.getItem(REF_STORAGE_KEY);
|
|
15
|
+
if (!raw) return null;
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (!parsed || typeof parsed.refId !== "string" || typeof parsed.expiresAt !== "number") return null;
|
|
18
|
+
if (now() > parsed.expiresAt) {
|
|
19
|
+
try {
|
|
20
|
+
localStorage.removeItem(REF_STORAGE_KEY);
|
|
21
|
+
} catch {
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return parsed.refId;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeStoredRef(refId, now = Date.now) {
|
|
31
|
+
try {
|
|
32
|
+
if (typeof localStorage === "undefined") return;
|
|
33
|
+
const stored = { refId, expiresAt: now() + REF_TTL_MS };
|
|
34
|
+
localStorage.setItem(REF_STORAGE_KEY, JSON.stringify(stored));
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function clearStoredRef() {
|
|
39
|
+
try {
|
|
40
|
+
if (typeof localStorage === "undefined") return;
|
|
41
|
+
localStorage.removeItem(REF_STORAGE_KEY);
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function extractRefFromUrl(href) {
|
|
46
|
+
try {
|
|
47
|
+
const url = href ?? (typeof window !== "undefined" ? window.location.href : "");
|
|
48
|
+
if (!url) return null;
|
|
49
|
+
const u = new URL(url);
|
|
50
|
+
const ref = u.searchParams.get("ref");
|
|
51
|
+
return ref && ref.length > 0 && ref.length <= 80 ? ref : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function dispatch(path, body) {
|
|
57
|
+
if (!state.apiKey) return;
|
|
58
|
+
try {
|
|
59
|
+
fetch(`${state.apiBase}${path}`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"X-API-Key": state.apiKey
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify({ ...body, refId: state.refId }),
|
|
66
|
+
keepalive: true
|
|
67
|
+
}).catch(() => {
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
var sellfolk = {
|
|
73
|
+
/**
|
|
74
|
+
* Initialize the SDK with your public API key.
|
|
75
|
+
*
|
|
76
|
+
* sellfolk.init('sk_live_acme_a8e92f...')
|
|
77
|
+
*
|
|
78
|
+
* On first call, captures `?ref=` from the URL (if present) and stores it
|
|
79
|
+
* with a 60-day expiry. Subsequent track() / conversion() calls automatically
|
|
80
|
+
* include the stored ref_id.
|
|
81
|
+
*/
|
|
82
|
+
init(apiKey, options = {}) {
|
|
83
|
+
state.apiKey = apiKey;
|
|
84
|
+
state.apiBase = options.apiBase ?? DEFAULT_API_BASE;
|
|
85
|
+
if (options.now) state.now = options.now;
|
|
86
|
+
const urlRef = extractRefFromUrl();
|
|
87
|
+
if (urlRef) {
|
|
88
|
+
writeStoredRef(urlRef, state.now);
|
|
89
|
+
state.refId = urlRef;
|
|
90
|
+
} else if (options.forceClearOnEmptyUrl) {
|
|
91
|
+
clearStoredRef();
|
|
92
|
+
state.refId = null;
|
|
93
|
+
} else {
|
|
94
|
+
state.refId = readStoredRef(state.now);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
/**
|
|
98
|
+
* Track an event. Common values: 'signup', 'trial', 'demo', 'newsletter'.
|
|
99
|
+
* Custom event names are allowed (they go in as type='custom' with
|
|
100
|
+
* customName=<event>).
|
|
101
|
+
*/
|
|
102
|
+
track(event, payload = {}) {
|
|
103
|
+
dispatch("/api/v1/track", { event, ...payload });
|
|
104
|
+
},
|
|
105
|
+
/**
|
|
106
|
+
* Fire a conversion event — call this when real money moves.
|
|
107
|
+
* Example: inside your Stripe / Polar webhook handler.
|
|
108
|
+
*/
|
|
109
|
+
conversion(payload) {
|
|
110
|
+
dispatch("/api/v1/conversion", payload);
|
|
111
|
+
},
|
|
112
|
+
/**
|
|
113
|
+
* Read the current stored ref_id (e.g. for logging or attribution debugging).
|
|
114
|
+
* Returns null if no ref has been captured yet.
|
|
115
|
+
*/
|
|
116
|
+
getRef() {
|
|
117
|
+
return state.refId;
|
|
118
|
+
},
|
|
119
|
+
/** Programmatically clear the stored ref (e.g. on logout). */
|
|
120
|
+
clearRef() {
|
|
121
|
+
clearStoredRef();
|
|
122
|
+
state.refId = null;
|
|
123
|
+
},
|
|
124
|
+
// ── Testing handles (not part of public API) ──────────────────────────────
|
|
125
|
+
_state: state
|
|
126
|
+
};
|
|
127
|
+
var src_default = sellfolk;
|
|
128
|
+
export {
|
|
129
|
+
DEFAULT_API_BASE,
|
|
130
|
+
REF_STORAGE_KEY,
|
|
131
|
+
REF_TTL_MS,
|
|
132
|
+
clearStoredRef,
|
|
133
|
+
src_default as default,
|
|
134
|
+
extractRefFromUrl,
|
|
135
|
+
readStoredRef,
|
|
136
|
+
writeStoredRef
|
|
137
|
+
};
|
|
138
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * sellfolk — Browser SDK\n *\n * Two lines to install:\n *\n * import sellfolk from 'sellfolk'\n * sellfolk.init('sk_live_...')\n *\n * Then call:\n * sellfolk.track('signup', { email })\n * sellfolk.conversion({ amount: 49.99, email })\n *\n * Design rules:\n * • Never throws. If anything fails, we swallow it. Your SaaS keeps running.\n * • `keepalive: true` on every fetch so requests survive page unload.\n * • Captures `?ref=` on init() and stores it in localStorage with 60-day expiry.\n * • All subsequent event calls auto-attach the stored ref_id.\n */\n\nexport const DEFAULT_API_BASE = 'https://api.sellfolk.com'\n\nexport const REF_STORAGE_KEY = 'sellfolk_ref'\n\nexport const REF_TTL_MS = 60 * 24 * 60 * 60 * 1000 // 60 days\n\nexport interface InitOptions {\n /** Override the API base URL (used for testing or self-hosted backends). */\n apiBase?: string\n /** Provide a deterministic clock — primarily for tests. */\n now?: () => number\n /**\n * If true, *replace* the stored ref with the one in the URL even when the\n * URL has none. Useful in tests; defaults to false (URL ref always wins,\n * but missing URL ref leaves the stored value untouched).\n */\n forceClearOnEmptyUrl?: boolean\n}\n\nexport interface StoredRef {\n refId: string\n expiresAt: number\n}\n\nexport interface TrackPayload {\n /** Optional email — privacy-truncated server-side before display. */\n email?: string\n /** Any extra metadata for the event. */\n [key: string]: unknown\n}\n\nexport interface ConversionPayload {\n amount: number\n email?: string\n currency?: string\n /** Anything else you want to attach (order_id, plan, etc.). */\n [key: string]: unknown\n}\n\n// ── Internal state ──────────────────────────────────────────────────────────\ninterface State {\n apiKey: string | null\n apiBase: string\n refId: string | null\n now: () => number\n}\n\nconst state: State = {\n apiKey: null,\n apiBase: DEFAULT_API_BASE,\n refId: null,\n now: () => Date.now(),\n}\n\n// ── Storage helpers (defensive — localStorage may be missing/disabled) ──────\nexport function readStoredRef(now: () => number = Date.now): string | null {\n try {\n if (typeof localStorage === 'undefined') return null\n const raw = localStorage.getItem(REF_STORAGE_KEY)\n if (!raw) return null\n const parsed = JSON.parse(raw) as StoredRef\n if (!parsed || typeof parsed.refId !== 'string' || typeof parsed.expiresAt !== 'number') return null\n if (now() > parsed.expiresAt) {\n try { localStorage.removeItem(REF_STORAGE_KEY) } catch { /* ignored */ }\n return null\n }\n return parsed.refId\n } catch {\n return null\n }\n}\n\nexport function writeStoredRef(refId: string, now: () => number = Date.now): void {\n try {\n if (typeof localStorage === 'undefined') return\n const stored: StoredRef = { refId, expiresAt: now() + REF_TTL_MS }\n localStorage.setItem(REF_STORAGE_KEY, JSON.stringify(stored))\n } catch {\n // ignored\n }\n}\n\nexport function clearStoredRef(): void {\n try {\n if (typeof localStorage === 'undefined') return\n localStorage.removeItem(REF_STORAGE_KEY)\n } catch {\n // ignored\n }\n}\n\n// ── URL parsing ─────────────────────────────────────────────────────────────\nexport function extractRefFromUrl(href?: string): string | null {\n try {\n const url = href ?? (typeof window !== 'undefined' ? window.location.href : '')\n if (!url) return null\n const u = new URL(url)\n const ref = u.searchParams.get('ref')\n return ref && ref.length > 0 && ref.length <= 80 ? ref : null\n } catch {\n return null\n }\n}\n\n// ── Network ─────────────────────────────────────────────────────────────────\nfunction dispatch(path: string, body: Record<string, unknown>): void {\n if (!state.apiKey) return\n try {\n // We intentionally do not await — keepalive ensures the request finishes\n // even if the page is unloading.\n fetch(`${state.apiBase}${path}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-API-Key': state.apiKey,\n },\n body: JSON.stringify({ ...body, refId: state.refId }),\n keepalive: true,\n }).catch(() => { /* never throw */ })\n } catch {\n // Browsers without fetch (or with extreme restrictions) — silent.\n }\n}\n\n// ── Public API ──────────────────────────────────────────────────────────────\nconst sellfolk = {\n /**\n * Initialize the SDK with your public API key.\n *\n * sellfolk.init('sk_live_acme_a8e92f...')\n *\n * On first call, captures `?ref=` from the URL (if present) and stores it\n * with a 60-day expiry. Subsequent track() / conversion() calls automatically\n * include the stored ref_id.\n */\n init(apiKey: string, options: InitOptions = {}): void {\n state.apiKey = apiKey\n state.apiBase = options.apiBase ?? DEFAULT_API_BASE\n if (options.now) state.now = options.now\n\n const urlRef = extractRefFromUrl()\n if (urlRef) {\n writeStoredRef(urlRef, state.now)\n state.refId = urlRef\n } else if (options.forceClearOnEmptyUrl) {\n clearStoredRef()\n state.refId = null\n } else {\n state.refId = readStoredRef(state.now)\n }\n },\n\n /**\n * Track an event. Common values: 'signup', 'trial', 'demo', 'newsletter'.\n * Custom event names are allowed (they go in as type='custom' with\n * customName=<event>).\n */\n track(event: string, payload: TrackPayload = {}): void {\n dispatch('/api/v1/track', { event, ...payload })\n },\n\n /**\n * Fire a conversion event — call this when real money moves.\n * Example: inside your Stripe / Polar webhook handler.\n */\n conversion(payload: ConversionPayload): void {\n dispatch('/api/v1/conversion', payload)\n },\n\n /**\n * Read the current stored ref_id (e.g. for logging or attribution debugging).\n * Returns null if no ref has been captured yet.\n */\n getRef(): string | null {\n return state.refId\n },\n\n /** Programmatically clear the stored ref (e.g. on logout). */\n clearRef(): void {\n clearStoredRef()\n state.refId = null\n },\n\n // ── Testing handles (not part of public API) ──────────────────────────────\n _state: state,\n}\n\nexport default sellfolk\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var sellfolk=(()=>{var l=Object.defineProperty;var g=Object.getOwnPropertyDescriptor;var y=Object.getOwnPropertyNames;var S=Object.prototype.hasOwnProperty;var m=(t,e)=>{for(var r in e)l(t,r,{get:e[r],enumerable:!0})},w=(t,e,r,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of y(e))!S.call(t,i)&&i!==r&&l(t,i,{get:()=>e[i],enumerable:!(o=g(e,i))||o.enumerable});return t};var I=t=>w(l({},"__esModule",{value:!0}),t);var R={};m(R,{DEFAULT_API_BASE:()=>s,REF_STORAGE_KEY:()=>a,REF_TTL_MS:()=>x,clearStoredRef:()=>f,default:()=>v,extractRefFromUrl:()=>p,readStoredRef:()=>u,writeStoredRef:()=>d});var s="https://api.sellfolk.com",a="sellfolk_ref",x=5184e6,n={apiKey:null,apiBase:s,refId:null,now:()=>Date.now()};function u(t=Date.now){try{if(typeof localStorage>"u")return null;let e=localStorage.getItem(a);if(!e)return null;let r=JSON.parse(e);if(!r||typeof r.refId!="string"||typeof r.expiresAt!="number")return null;if(t()>r.expiresAt){try{localStorage.removeItem(a)}catch{}return null}return r.refId}catch{return null}}function d(t,e=Date.now){try{if(typeof localStorage>"u")return;let r={refId:t,expiresAt:e()+5184e6};localStorage.setItem(a,JSON.stringify(r))}catch{}}function f(){try{if(typeof localStorage>"u")return;localStorage.removeItem(a)}catch{}}function p(t){try{let e=t??(typeof window<"u"?window.location.href:"");if(!e)return null;let o=new URL(e).searchParams.get("ref");return o&&o.length>0&&o.length<=80?o:null}catch{return null}}function c(t,e){if(n.apiKey)try{fetch(`${n.apiBase}${t}`,{method:"POST",headers:{"Content-Type":"application/json","X-API-Key":n.apiKey},body:JSON.stringify({...e,refId:n.refId}),keepalive:!0}).catch(()=>{})}catch{}}var h={init(t,e={}){n.apiKey=t,n.apiBase=e.apiBase??s,e.now&&(n.now=e.now);let r=p();r?(d(r,n.now),n.refId=r):e.forceClearOnEmptyUrl?(f(),n.refId=null):n.refId=u(n.now)},track(t,e={}){c("/api/v1/track",{event:t,...e})},conversion(t){c("/api/v1/conversion",t)},getRef(){return n.refId},clearRef(){f(),n.refId=null},_state:n},v=h;return I(R);})();
|
package/dist/server.cjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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/server.ts
|
|
21
|
+
var server_exports = {};
|
|
22
|
+
__export(server_exports, {
|
|
23
|
+
SellfolkError: () => SellfolkError,
|
|
24
|
+
SellfolkServer: () => SellfolkServer
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(server_exports);
|
|
27
|
+
var DEFAULT_API_BASE = "https://api.sellfolk.com";
|
|
28
|
+
var SellfolkServer = class {
|
|
29
|
+
constructor(apiKey, options = {}) {
|
|
30
|
+
this.apiKey = apiKey;
|
|
31
|
+
this.apiBase = options.apiBase ?? DEFAULT_API_BASE;
|
|
32
|
+
this.throwOnError = options.throwOnError ?? false;
|
|
33
|
+
this._fetch = options.fetch ?? globalThis.fetch;
|
|
34
|
+
}
|
|
35
|
+
/** Track a non-conversion event (signup, trial, demo, newsletter, custom). */
|
|
36
|
+
async track(event, payload = {}) {
|
|
37
|
+
await this._post("/api/v1/track", { event, ...payload });
|
|
38
|
+
}
|
|
39
|
+
/** Track a paid conversion. Call from your billing webhook. */
|
|
40
|
+
async conversion(payload) {
|
|
41
|
+
await this._post("/api/v1/conversion", payload);
|
|
42
|
+
}
|
|
43
|
+
async _post(path, body) {
|
|
44
|
+
try {
|
|
45
|
+
const res = await this._fetch(`${this.apiBase}${path}`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"X-API-Key": this.apiKey
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(body)
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok && this.throwOnError) {
|
|
54
|
+
throw new SellfolkError(`Sellfolk ${path} returned ${res.status}`, res.status);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (this.throwOnError) throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var SellfolkError = class extends Error {
|
|
62
|
+
constructor(message, status) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "SellfolkError";
|
|
65
|
+
this.status = status;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
69
|
+
0 && (module.exports = {
|
|
70
|
+
SellfolkError,
|
|
71
|
+
SellfolkServer
|
|
72
|
+
});
|
|
73
|
+
//# sourceMappingURL=server.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["/**\n * sellfolk/server — Server SDK\n *\n * Node 20+ (uses native fetch). Zero runtime dependencies.\n *\n * import { SellfolkServer } from 'sellfolk/server'\n * const sb = new SellfolkServer(process.env.SELLFOLK_KEY!)\n * await sb.conversion({ email, amount, currency })\n *\n * Design rules:\n * • Never throws unless you explicitly opt-in with `throwOnError: true`.\n * • Methods are async — await them inside webhook handlers so retries work.\n */\n\nconst DEFAULT_API_BASE = 'https://api.sellfolk.com'\n\nexport interface ServerOptions {\n apiBase?: string\n /** When true, propagate fetch errors. Default: false (swallowed). */\n throwOnError?: boolean\n /** Custom fetch implementation (mostly for tests). */\n fetch?: typeof fetch\n}\n\nexport interface ConversionPayload {\n email: string\n amount: number\n currency?: string\n /** If omitted, the backend can still attribute via signed cookies or other channels. */\n refId?: string\n /** Arbitrary metadata (order_id, plan, etc.). */\n [key: string]: unknown\n}\n\nexport interface TrackPayload {\n refId?: string\n email?: string\n [key: string]: unknown\n}\n\nexport class SellfolkServer {\n private apiKey: string\n private apiBase: string\n private throwOnError: boolean\n private _fetch: typeof fetch\n\n constructor(apiKey: string, options: ServerOptions = {}) {\n this.apiKey = apiKey\n this.apiBase = options.apiBase ?? DEFAULT_API_BASE\n this.throwOnError = options.throwOnError ?? false\n this._fetch = options.fetch ?? globalThis.fetch\n }\n\n /** Track a non-conversion event (signup, trial, demo, newsletter, custom). */\n async track(event: string, payload: TrackPayload = {}): Promise<void> {\n await this._post('/api/v1/track', { event, ...payload })\n }\n\n /** Track a paid conversion. Call from your billing webhook. */\n async conversion(payload: ConversionPayload): Promise<void> {\n await this._post('/api/v1/conversion', payload)\n }\n\n private async _post(path: string, body: Record<string, unknown>): Promise<void> {\n try {\n const res = await this._fetch(`${this.apiBase}${path}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-API-Key': this.apiKey,\n },\n body: JSON.stringify(body),\n })\n if (!res.ok && this.throwOnError) {\n throw new SellfolkError(`Sellfolk ${path} returned ${res.status}`, res.status)\n }\n } catch (err) {\n if (this.throwOnError) throw err\n // Silent failure — webhook handlers should never crash because our API\n // hiccuped. The conversion will be reconciled later if needed.\n }\n }\n}\n\nexport class SellfolkError extends Error {\n status?: number\n constructor(message: string, status?: number) {\n super(message)\n this.name = 'SellfolkError'\n this.status = status\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAcA,IAAM,mBAAmB;AA0BlB,IAAM,iBAAN,MAAqB;AAAA,EAM1B,YAAY,QAAgB,UAAyB,CAAC,GAAG;AACvD,SAAK,SAAS;AACd,SAAK,UAAU,QAAQ,WAAW;AAClC,SAAK,eAAe,QAAQ,gBAAgB;AAC5C,SAAK,SAAS,QAAQ,SAAS,WAAW;AAAA,EAC5C;AAAA;AAAA,EAGA,MAAM,MAAM,OAAe,UAAwB,CAAC,GAAkB;AACpE,UAAM,KAAK,MAAM,iBAAiB,EAAE,OAAO,GAAG,QAAQ,CAAC;AAAA,EACzD;AAAA;AAAA,EAGA,MAAM,WAAW,SAA2C;AAC1D,UAAM,KAAK,MAAM,sBAAsB,OAAO;AAAA,EAChD;AAAA,EAEA,MAAc,MAAM,MAAc,MAA8C;AAC9E,QAAI;AACF,YAAM,MAAM,MAAM,KAAK,OAAO,GAAG,KAAK,OAAO,GAAG,IAAI,IAAI;AAAA,QACtD,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,gBAAgB;AAAA,UAChB,aAAa,KAAK;AAAA,QACpB;AAAA,QACA,MAAM,KAAK,UAAU,IAAI;AAAA,MAC3B,CAAC;AACD,UAAI,CAAC,IAAI,MAAM,KAAK,cAAc;AAChC,cAAM,IAAI,cAAc,YAAY,IAAI,aAAa,IAAI,MAAM,IAAI,IAAI,MAAM;AAAA,MAC/E;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,KAAK,aAAc,OAAM;AAAA,IAG/B;AAAA,EACF;AACF;AAEO,IAAM,gBAAN,cAA4B,MAAM;AAAA,EAEvC,YAAY,SAAiB,QAAiB;AAC5C,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;","names":[]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sellfolk/server — Server SDK
|
|
3
|
+
*
|
|
4
|
+
* Node 20+ (uses native fetch). Zero runtime dependencies.
|
|
5
|
+
*
|
|
6
|
+
* import { SellfolkServer } from 'sellfolk/server'
|
|
7
|
+
* const sb = new SellfolkServer(process.env.SELLFOLK_KEY!)
|
|
8
|
+
* await sb.conversion({ email, amount, currency })
|
|
9
|
+
*
|
|
10
|
+
* Design rules:
|
|
11
|
+
* • Never throws unless you explicitly opt-in with `throwOnError: true`.
|
|
12
|
+
* • Methods are async — await them inside webhook handlers so retries work.
|
|
13
|
+
*/
|
|
14
|
+
interface ServerOptions {
|
|
15
|
+
apiBase?: string;
|
|
16
|
+
/** When true, propagate fetch errors. Default: false (swallowed). */
|
|
17
|
+
throwOnError?: boolean;
|
|
18
|
+
/** Custom fetch implementation (mostly for tests). */
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
}
|
|
21
|
+
interface ConversionPayload {
|
|
22
|
+
email: string;
|
|
23
|
+
amount: number;
|
|
24
|
+
currency?: string;
|
|
25
|
+
/** If omitted, the backend can still attribute via signed cookies or other channels. */
|
|
26
|
+
refId?: string;
|
|
27
|
+
/** Arbitrary metadata (order_id, plan, etc.). */
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
interface TrackPayload {
|
|
31
|
+
refId?: string;
|
|
32
|
+
email?: string;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
declare class SellfolkServer {
|
|
36
|
+
private apiKey;
|
|
37
|
+
private apiBase;
|
|
38
|
+
private throwOnError;
|
|
39
|
+
private _fetch;
|
|
40
|
+
constructor(apiKey: string, options?: ServerOptions);
|
|
41
|
+
/** Track a non-conversion event (signup, trial, demo, newsletter, custom). */
|
|
42
|
+
track(event: string, payload?: TrackPayload): Promise<void>;
|
|
43
|
+
/** Track a paid conversion. Call from your billing webhook. */
|
|
44
|
+
conversion(payload: ConversionPayload): Promise<void>;
|
|
45
|
+
private _post;
|
|
46
|
+
}
|
|
47
|
+
declare class SellfolkError extends Error {
|
|
48
|
+
status?: number;
|
|
49
|
+
constructor(message: string, status?: number);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { type ConversionPayload, SellfolkError, SellfolkServer, type ServerOptions, type TrackPayload };
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sellfolk/server — Server SDK
|
|
3
|
+
*
|
|
4
|
+
* Node 20+ (uses native fetch). Zero runtime dependencies.
|
|
5
|
+
*
|
|
6
|
+
* import { SellfolkServer } from 'sellfolk/server'
|
|
7
|
+
* const sb = new SellfolkServer(process.env.SELLFOLK_KEY!)
|
|
8
|
+
* await sb.conversion({ email, amount, currency })
|
|
9
|
+
*
|
|
10
|
+
* Design rules:
|
|
11
|
+
* • Never throws unless you explicitly opt-in with `throwOnError: true`.
|
|
12
|
+
* • Methods are async — await them inside webhook handlers so retries work.
|
|
13
|
+
*/
|
|
14
|
+
interface ServerOptions {
|
|
15
|
+
apiBase?: string;
|
|
16
|
+
/** When true, propagate fetch errors. Default: false (swallowed). */
|
|
17
|
+
throwOnError?: boolean;
|
|
18
|
+
/** Custom fetch implementation (mostly for tests). */
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
}
|
|
21
|
+
interface ConversionPayload {
|
|
22
|
+
email: string;
|
|
23
|
+
amount: number;
|
|
24
|
+
currency?: string;
|
|
25
|
+
/** If omitted, the backend can still attribute via signed cookies or other channels. */
|
|
26
|
+
refId?: string;
|
|
27
|
+
/** Arbitrary metadata (order_id, plan, etc.). */
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
interface TrackPayload {
|
|
31
|
+
refId?: string;
|
|
32
|
+
email?: string;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
declare class SellfolkServer {
|
|
36
|
+
private apiKey;
|
|
37
|
+
private apiBase;
|
|
38
|
+
private throwOnError;
|
|
39
|
+
private _fetch;
|
|
40
|
+
constructor(apiKey: string, options?: ServerOptions);
|
|
41
|
+
/** Track a non-conversion event (signup, trial, demo, newsletter, custom). */
|
|
42
|
+
track(event: string, payload?: TrackPayload): Promise<void>;
|
|
43
|
+
/** Track a paid conversion. Call from your billing webhook. */
|
|
44
|
+
conversion(payload: ConversionPayload): Promise<void>;
|
|
45
|
+
private _post;
|
|
46
|
+
}
|
|
47
|
+
declare class SellfolkError extends Error {
|
|
48
|
+
status?: number;
|
|
49
|
+
constructor(message: string, status?: number);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { type ConversionPayload, SellfolkError, SellfolkServer, type ServerOptions, type TrackPayload };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
var DEFAULT_API_BASE = "https://api.sellfolk.com";
|
|
3
|
+
var SellfolkServer = class {
|
|
4
|
+
constructor(apiKey, options = {}) {
|
|
5
|
+
this.apiKey = apiKey;
|
|
6
|
+
this.apiBase = options.apiBase ?? DEFAULT_API_BASE;
|
|
7
|
+
this.throwOnError = options.throwOnError ?? false;
|
|
8
|
+
this._fetch = options.fetch ?? globalThis.fetch;
|
|
9
|
+
}
|
|
10
|
+
/** Track a non-conversion event (signup, trial, demo, newsletter, custom). */
|
|
11
|
+
async track(event, payload = {}) {
|
|
12
|
+
await this._post("/api/v1/track", { event, ...payload });
|
|
13
|
+
}
|
|
14
|
+
/** Track a paid conversion. Call from your billing webhook. */
|
|
15
|
+
async conversion(payload) {
|
|
16
|
+
await this._post("/api/v1/conversion", payload);
|
|
17
|
+
}
|
|
18
|
+
async _post(path, body) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await this._fetch(`${this.apiBase}${path}`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
"X-API-Key": this.apiKey
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify(body)
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok && this.throwOnError) {
|
|
29
|
+
throw new SellfolkError(`Sellfolk ${path} returned ${res.status}`, res.status);
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (this.throwOnError) throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var SellfolkError = class extends Error {
|
|
37
|
+
constructor(message, status) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "SellfolkError";
|
|
40
|
+
this.status = status;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
export {
|
|
44
|
+
SellfolkError,
|
|
45
|
+
SellfolkServer
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["/**\n * sellfolk/server — Server SDK\n *\n * Node 20+ (uses native fetch). Zero runtime dependencies.\n *\n * import { SellfolkServer } from 'sellfolk/server'\n * const sb = new SellfolkServer(process.env.SELLFOLK_KEY!)\n * await sb.conversion({ email, amount, currency })\n *\n * Design rules:\n * • Never throws unless you explicitly opt-in with `throwOnError: true`.\n * • Methods are async — await them inside webhook handlers so retries work.\n */\n\nconst DEFAULT_API_BASE = 'https://api.sellfolk.com'\n\nexport interface ServerOptions {\n apiBase?: string\n /** When true, propagate fetch errors. Default: false (swallowed). */\n throwOnError?: boolean\n /** Custom fetch implementation (mostly for tests). */\n fetch?: typeof fetch\n}\n\nexport interface ConversionPayload {\n email: string\n amount: number\n currency?: string\n /** If omitted, the backend can still attribute via signed cookies or other channels. */\n refId?: string\n /** Arbitrary metadata (order_id, plan, etc.). */\n [key: string]: unknown\n}\n\nexport interface TrackPayload {\n refId?: string\n email?: string\n [key: string]: unknown\n}\n\nexport class SellfolkServer {\n private apiKey: string\n private apiBase: string\n private throwOnError: boolean\n private _fetch: typeof fetch\n\n constructor(apiKey: string, options: ServerOptions = {}) {\n this.apiKey = apiKey\n this.apiBase = options.apiBase ?? DEFAULT_API_BASE\n this.throwOnError = options.throwOnError ?? false\n this._fetch = options.fetch ?? globalThis.fetch\n }\n\n /** Track a non-conversion event (signup, trial, demo, newsletter, custom). */\n async track(event: string, payload: TrackPayload = {}): Promise<void> {\n await this._post('/api/v1/track', { event, ...payload })\n }\n\n /** Track a paid conversion. Call from your billing webhook. */\n async conversion(payload: ConversionPayload): Promise<void> {\n await this._post('/api/v1/conversion', payload)\n }\n\n private async _post(path: string, body: Record<string, unknown>): Promise<void> {\n try {\n const res = await this._fetch(`${this.apiBase}${path}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-API-Key': this.apiKey,\n },\n body: JSON.stringify(body),\n })\n if (!res.ok && this.throwOnError) {\n throw new SellfolkError(`Sellfolk ${path} returned ${res.status}`, res.status)\n }\n } catch (err) {\n if (this.throwOnError) throw err\n // Silent failure — webhook handlers should never crash because our API\n // hiccuped. The conversion will be reconciled later if needed.\n }\n }\n}\n\nexport class SellfolkError extends Error {\n status?: number\n constructor(message: string, status?: number) {\n super(message)\n this.name = 'SellfolkError'\n this.status = status\n }\n}\n"],"mappings":";AAcA,IAAM,mBAAmB;AA0BlB,IAAM,iBAAN,MAAqB;AAAA,EAM1B,YAAY,QAAgB,UAAyB,CAAC,GAAG;AACvD,SAAK,SAAS;AACd,SAAK,UAAU,QAAQ,WAAW;AAClC,SAAK,eAAe,QAAQ,gBAAgB;AAC5C,SAAK,SAAS,QAAQ,SAAS,WAAW;AAAA,EAC5C;AAAA;AAAA,EAGA,MAAM,MAAM,OAAe,UAAwB,CAAC,GAAkB;AACpE,UAAM,KAAK,MAAM,iBAAiB,EAAE,OAAO,GAAG,QAAQ,CAAC;AAAA,EACzD;AAAA;AAAA,EAGA,MAAM,WAAW,SAA2C;AAC1D,UAAM,KAAK,MAAM,sBAAsB,OAAO;AAAA,EAChD;AAAA,EAEA,MAAc,MAAM,MAAc,MAA8C;AAC9E,QAAI;AACF,YAAM,MAAM,MAAM,KAAK,OAAO,GAAG,KAAK,OAAO,GAAG,IAAI,IAAI;AAAA,QACtD,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,gBAAgB;AAAA,UAChB,aAAa,KAAK;AAAA,QACpB;AAAA,QACA,MAAM,KAAK,UAAU,IAAI;AAAA,MAC3B,CAAC;AACD,UAAI,CAAC,IAAI,MAAM,KAAK,cAAc;AAChC,cAAM,IAAI,cAAc,YAAY,IAAI,aAAa,IAAI,MAAM,IAAI,IAAI,MAAM;AAAA,MAC/E;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,KAAK,aAAc,OAAM;AAAA,IAG/B;AAAA,EACF;AACF;AAEO,IAAM,gBAAN,cAA4B,MAAM;AAAA,EAEvC,YAAY,SAAiB,QAAiB;AAC5C,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sellfolk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sellfolk SDK — track signups, trials, and conversions from your referral sellers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.cjs",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"import": "./dist/server.js",
|
|
16
|
+
"require": "./dist/server.cjs",
|
|
17
|
+
"types": "./dist/server.d.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup",
|
|
25
|
+
"dev": "tsup --watch",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"tsup": "^8.5.0",
|
|
31
|
+
"typescript": "^5.8.3",
|
|
32
|
+
"vitest": "^3.2.4"
|
|
33
|
+
}
|
|
34
|
+
}
|