@openpartner/sdk 0.2.0 → 0.3.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/CHANGELOG.md +79 -0
- package/README.md +52 -5
- package/dist/browser.cjs +169 -33
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +104 -18
- package/dist/browser.d.ts +104 -18
- package/dist/browser.js +169 -33
- package/dist/browser.js.map +1 -1
- package/dist/server.cjs +50 -2
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +57 -2
- package/dist/server.d.ts +57 -2
- package/dist/server.js +46 -1
- package/dist/server.js.map +1 -1
- package/dist-global/openpartner.js +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
Closes the places where a partner click could go missing in the browser, and
|
|
6
|
+
reports clicks that arrive so Admin → Tracking health can show the ones that
|
|
7
|
+
don't. No breaking API changes; the one new default behaviour is the landing
|
|
8
|
+
report (see Compatibility).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Consent mode.** `consent: 'granted' | 'pending' | 'denied'` in
|
|
13
|
+
`OpenPartner.init()` (script tag: `data-consent="pending"`) and
|
|
14
|
+
`setConsent('granted' | 'denied')`.
|
|
15
|
+
- `'pending'` holds the click from the URL in memory only. Nothing is read
|
|
16
|
+
from or written to storage or cookies, no Stripe link is tagged and no
|
|
17
|
+
request is sent until `setConsent('granted')`, which then works even
|
|
18
|
+
after a single-page app has moved off the landing URL.
|
|
19
|
+
- `'denied'`, at load or later, deletes what an earlier visit stored and
|
|
20
|
+
takes the `client_reference_id` the SDK added back off Stripe Payment
|
|
21
|
+
Links and embeds. A reference you set yourself stays. Safe to call more
|
|
22
|
+
than once.
|
|
23
|
+
- The default is `'granted'`: integrations that don't set it behave as
|
|
24
|
+
before.
|
|
25
|
+
- **Landing report.** When a click first arrives from the URL, the SDK posts
|
|
26
|
+
its id once to `POST /attribution/landing` (the page's hostname comes from
|
|
27
|
+
the browser's `Origin` header; nothing else about the visitor is sent).
|
|
28
|
+
Admin → Tracking health compares landings with clicks to show the clicks
|
|
29
|
+
lost between the partner link and your page. Turn it off with
|
|
30
|
+
`reportLanding: false` (script tag: `data-landing="off"`).
|
|
31
|
+
- **Server-side referral cookie** in `@openpartner/sdk/server`, for Safari,
|
|
32
|
+
which deletes storage a page's scripts wrote after 7 days without a visit.
|
|
33
|
+
A cookie your own server sets on the landing response isn't capped that way,
|
|
34
|
+
and the browser SDK reads it back.
|
|
35
|
+
- `referralCookieHeader(url, options?)`: the `Set-Cookie` value for a
|
|
36
|
+
request whose URL carries `?cref=`, or `null`.
|
|
37
|
+
- `referralCookieMiddleware(options?)`: Express / Connect middleware that
|
|
38
|
+
sets it, adding to any `Set-Cookie` already queued.
|
|
39
|
+
- `readReferralCookie(cookieHeader, name?)`: the referral off a `Cookie`
|
|
40
|
+
header, for `identify()` from your backend or `client_reference_id` on a
|
|
41
|
+
Checkout Session.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **The referral is read back from the `op_ref` cookie** (or
|
|
46
|
+
`referralCookie.name`) when localStorage is empty: on a second subdomain
|
|
47
|
+
(land on `www.`, sign up on `app.` with `referralCookie.domain` /
|
|
48
|
+
`data-cookie-domain` set), after Safari clears localStorage, or where
|
|
49
|
+
localStorage is blocked. Previously only localStorage and the router's
|
|
50
|
+
`_cref` cookie were read.
|
|
51
|
+
- **An `op_ref` cookie that already holds the referral isn't rewritten** from
|
|
52
|
+
script, so one your server set keeps its lifetime in Safari. A new click
|
|
53
|
+
still replaces it.
|
|
54
|
+
- **A click from the URL is kept in memory as well,** so the referral cookie,
|
|
55
|
+
Stripe tagging and `identify()` still get it where localStorage throws.
|
|
56
|
+
- **`clear()` also expires the referral cookie** and removes the Stripe
|
|
57
|
+
references the SDK added.
|
|
58
|
+
- **`onError`'s default log line** is now `[openpartner] request failed` (was
|
|
59
|
+
`identify failed`): it also covers the landing report.
|
|
60
|
+
|
|
61
|
+
### Compatibility
|
|
62
|
+
|
|
63
|
+
- The landing report is on by default and needs an OpenPartner API that has
|
|
64
|
+
`POST /attribution/landing` (OpenPartner Cloud, or a self-hosted instance
|
|
65
|
+
from October 24 2026 on). An older API answers 404, and the SDK ignores it:
|
|
66
|
+
only a `click_not_found` from a current API reaches `onError`, because that
|
|
67
|
+
usually means `apiUrl` names another program.
|
|
68
|
+
- The script tag an OpenPartner portal serves at `/op.js` is built from this
|
|
69
|
+
source and already behaves this way.
|
|
70
|
+
|
|
71
|
+
## 0.2.0
|
|
72
|
+
|
|
73
|
+
Published to npm on October 20 2026: Stripe Payment Link and Pricing Table /
|
|
74
|
+
Buy Button linking, the script-tag build, the `op_ref` referral cookie, and the
|
|
75
|
+
server SDK (`trackEvent`, `trackConversion`, `identify`).
|
|
76
|
+
|
|
77
|
+
## 0.1.0
|
|
78
|
+
|
|
79
|
+
The first published version.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Client + server SDK for [OpenPartner](https://github.com/getcoherence/openpartner).
|
|
4
4
|
|
|
5
|
-
- **Browser** — captures the `cref` attribution token from landing URLs and
|
|
5
|
+
- **Browser** — captures the `cref` attribution token from landing URLs and first-party cookies, keeps it across pages and subdomains, waits for consent when you ask it to, and stitches to a logged-in user on auth.
|
|
6
6
|
- **Server** — posts conversion events, stitches server-side where needed, with typed errors.
|
|
7
7
|
|
|
8
8
|
Zero runtime dependencies. ~4 KB gzipped browser bundle.
|
|
@@ -13,7 +13,7 @@ npm install @openpartner/sdk
|
|
|
13
13
|
|
|
14
14
|
## Browser
|
|
15
15
|
|
|
16
|
-
Embed once on every page of your marketing site and product. The SDK runs on init, pulling the cref out of `?cref=…` (first visit from a partner link) or
|
|
16
|
+
Embed once on every page of your marketing site and product. The SDK runs on init, pulling the cref out of `?cref=…` (first visit from a partner link), or on later pages out of localStorage or a first-party cookie (`_cref` from the click router, `op_ref` from a sibling subdomain or your server), and stashing it in localStorage.
|
|
17
17
|
|
|
18
18
|
```ts
|
|
19
19
|
import { OpenPartner } from '@openpartner/sdk';
|
|
@@ -40,9 +40,55 @@ OpenPartner.init({
|
|
|
40
40
|
onError: (err) => Sentry.captureException(err),
|
|
41
41
|
stripe: { linkHosts: ['pay.yourbrand.com'] }, // or false — see below
|
|
42
42
|
referralCookie: { name: 'op_ref', domain: '.yourbrand.com' }, // off by default
|
|
43
|
+
consent: 'pending', // default 'granted' — see Consent below
|
|
44
|
+
reportLanding: true, // default on — see Landing report below
|
|
43
45
|
});
|
|
44
46
|
```
|
|
45
47
|
|
|
48
|
+
### Subdomains
|
|
49
|
+
|
|
50
|
+
A visitor who lands on `www.yourbrand.com` and signs up on `app.yourbrand.com` has two localStorages, one per subdomain. Share the referral through a cookie on the parent domain: `referralCookie: { domain: '.yourbrand.com' }` (script tag: `data-cookie-domain=".yourbrand.com"`) on every subdomain. The SDK on `app.` reads `op_ref` back when its own storage is empty, so `identify()` and Stripe links work there too. A journey across two different domains (`yourbrand.com` → `yourbrand-shop.com`) has no shared cookie: carry `?cref=` on the link between them.
|
|
51
|
+
|
|
52
|
+
### Safari and long gaps
|
|
53
|
+
|
|
54
|
+
Safari deletes storage a page's scripts wrote (localStorage, and cookies set through `document.cookie`) after 7 days without a visit. A visitor who clicks, comes back after a fortnight and buys without signing up first would arrive with nothing. Two things close that gap:
|
|
55
|
+
|
|
56
|
+
- **Call `identify()` as early as you can.** Once a click is stitched to a user, the link lives on OpenPartner's side and no browser storage matters. An email capture or account creation before checkout is enough.
|
|
57
|
+
- **Set the referral cookie from your own server**, on the landing page's response. A cookie your server sets keeps its full lifetime in Safari, and the browser SDK reads it back:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { referralCookieHeader, referralCookieMiddleware } from '@openpartner/sdk/server';
|
|
61
|
+
|
|
62
|
+
// Express / Connect:
|
|
63
|
+
app.use(referralCookieMiddleware({ domain: '.yourbrand.com' }));
|
|
64
|
+
|
|
65
|
+
// Anything else (Next.js middleware, a Worker): set the header yourself.
|
|
66
|
+
const header = referralCookieHeader(request.url, { domain: '.yourbrand.com' });
|
|
67
|
+
if (header) response.headers.append('Set-Cookie', header);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set it from the server that serves the page. Safari caps a cookie at 7 days when it comes from a server on a different IP address than the site, such as an API hosted on another provider. The SDK never rewrites an `op_ref` cookie that already holds the referral, because rewriting it from script would bring the 7-day cap back.
|
|
71
|
+
|
|
72
|
+
### Consent
|
|
73
|
+
|
|
74
|
+
Behind a consent banner, start the SDK with `consent: 'pending'` and tell it the answer:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const op = OpenPartner.init({ apiUrl, consent: 'pending' });
|
|
78
|
+
|
|
79
|
+
yourConsentBanner.onChange((choices) => {
|
|
80
|
+
op.setConsent(choices.marketing ? 'granted' : 'denied');
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
While consent is pending, the SDK keeps the click from the URL in memory and does nothing else: it doesn't read or write storage or cookies, tag Stripe links, or call OpenPartner. `setConsent('granted')` then works as if the page had just loaded, even after the visitor has navigated within a single-page app. `setConsent('denied')` stops everything, takes the references the SDK added back off Stripe links and embeds (one you set yourself stays), and deletes what was stored. Starting with `consent: 'denied'` deletes what an earlier visit stored. `getReferral()` and `identify()` return `null` until consent is granted. Script tag: `data-consent="pending"`, then `openpartner.setConsent('granted')`.
|
|
85
|
+
|
|
86
|
+
Clicks from visitors who never consent can't be credited, and Admin → Tracking health counts them among the clicks that never landed.
|
|
87
|
+
|
|
88
|
+
### Landing report
|
|
89
|
+
|
|
90
|
+
When a partner click first arrives from the URL, the SDK tells OpenPartner (`POST /attribution/landing`, the click id and nothing else; the page's hostname comes from the browser's `Origin` header). **Admin → Tracking health** compares landings with clicks to show the ones lost between the partner link and your page: a redirect that dropped `?cref=`, a page without the script, a consent banner. A `click_not_found` from it reaches `onError` and usually means `apiUrl` names another program; any other failure is dropped, because the report is a diagnostic and never part of attribution. Turn it off with `reportLanding: false` (script tag: `data-landing="off"`).
|
|
91
|
+
|
|
46
92
|
### Stripe links and embeds
|
|
47
93
|
|
|
48
94
|
When the visitor came from a partner link, the SDK hands that click to Stripe for you:
|
|
@@ -62,10 +108,11 @@ An OpenPartner portal serves a self-starting build of this SDK at `/op.js`. Past
|
|
|
62
108
|
<script src="https://app.openpartner.dev/op.js" data-api-url="https://app.openpartner.dev/api/t/your-slug"></script>
|
|
63
109
|
```
|
|
64
110
|
|
|
65
|
-
**Admin → Integrations** shows the exact tag for your program. It does everything above, keeps the referral in an `op_ref` cookie on your domain, and exposes `window.openpartner` (`openpartner.identify(userId)`, `openpartner.getReferral()`). Optional attributes:
|
|
111
|
+
**Admin → Integrations** shows the exact tag for your program. It does everything above, keeps the referral in an `op_ref` cookie on your domain, and exposes `window.openpartner` (`openpartner.identify(userId)`, `openpartner.getReferral()`, `openpartner.setConsent('granted')`). Optional attributes:
|
|
66
112
|
- `data-stripe-hosts="pay.yourbrand.com"`: a custom Payment Link domain.
|
|
67
113
|
- `data-cookie-domain=".yourbrand.com"`: share `op_ref` across subdomains.
|
|
68
|
-
- `data-
|
|
114
|
+
- `data-consent="pending"`: wait for `openpartner.setConsent()` (see [Consent](#consent)).
|
|
115
|
+
- `data-stripe="off"`, `data-cookie="off"`, `data-landing="off"`: turn any of them off.
|
|
69
116
|
|
|
70
117
|
The npm package carries the same file at `dist-global/openpartner.js`.
|
|
71
118
|
|
|
@@ -166,7 +213,7 @@ OpenPartner has to know which Stripe customer is which referred user. Link them
|
|
|
166
213
|
});
|
|
167
214
|
```
|
|
168
215
|
|
|
169
|
-
With the [script tag](#script-tag-no-bundler) (or `referralCookie` set), your backend can read the referral straight off the request instead: `client_reference_id: req.cookies.op_ref`.
|
|
216
|
+
With the [script tag](#script-tag-no-bundler) (or `referralCookie` set), your backend can read the referral straight off the request instead: `client_reference_id: req.cookies.op_ref`, or `readReferralCookie(req.headers.cookie)` from `@openpartner/sdk/server` without a cookie parser. The same call lets your backend stitch at signup, which works even where browser scripts are blocked: `await op.identify({ cref, userId: user.id })`.
|
|
170
217
|
|
|
171
218
|
A Stripe Payment Link can't run your code, so let the partner link fill it in: set the program's destination URL to the Payment Link with `?client_reference_id={click_id}`, and the click router puts the click id there.
|
|
172
219
|
|
package/dist/browser.cjs
CHANGED
|
@@ -75,28 +75,42 @@ var DEFAULTS = {
|
|
|
75
75
|
storageKey: "openpartner:cref",
|
|
76
76
|
stripe: true,
|
|
77
77
|
referralCookie: false,
|
|
78
|
+
consent: "granted",
|
|
79
|
+
reportLanding: true,
|
|
78
80
|
onError: (err) => {
|
|
79
|
-
console.warn("[openpartner]
|
|
81
|
+
console.warn("[openpartner] request failed", err);
|
|
80
82
|
}
|
|
81
83
|
};
|
|
82
84
|
var STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
83
85
|
var STRIPE_EMBED_SELECTOR = "stripe-pricing-table, stripe-buy-button";
|
|
86
|
+
var REFERRAL_COOKIE_DEFAULT = "op_ref";
|
|
84
87
|
var OpenPartner = class _OpenPartner {
|
|
85
88
|
constructor(config) {
|
|
86
89
|
__publicField(this, "config");
|
|
87
90
|
__publicField(this, "observer", null);
|
|
91
|
+
__publicField(this, "consent");
|
|
92
|
+
/** The URL's referral while consent is pending, held in memory only. */
|
|
93
|
+
__publicField(this, "held", null);
|
|
94
|
+
/** This page's referral, kept here too: where localStorage throws it is
|
|
95
|
+
* the only copy until the cookie is written. */
|
|
96
|
+
__publicField(this, "current", null);
|
|
97
|
+
/** The Stripe links and embeds this SDK tagged, and with what, so a
|
|
98
|
+
* withdrawn consent can take the tags back off. */
|
|
99
|
+
__publicField(this, "tagged", []);
|
|
88
100
|
this.config = { ...DEFAULTS, ...config };
|
|
89
|
-
this.
|
|
90
|
-
this.
|
|
91
|
-
if (this.
|
|
101
|
+
this.consent = this.config.consent;
|
|
102
|
+
if (this.consent === "granted") this.start();
|
|
103
|
+
else if (this.consent === "pending") this.held = this.queryCref();
|
|
104
|
+
else this.clear();
|
|
92
105
|
}
|
|
93
106
|
static init(config) {
|
|
94
107
|
return new _OpenPartner(config);
|
|
95
108
|
}
|
|
96
|
-
/** The cref we're holding onto, if any. Useful for debugging.
|
|
109
|
+
/** The cref we're holding onto, if any. Useful for debugging. Null until
|
|
110
|
+
* consent is granted. */
|
|
97
111
|
getCref() {
|
|
98
|
-
if (
|
|
99
|
-
return
|
|
112
|
+
if (this.consent !== "granted") return null;
|
|
113
|
+
return this.storageGet() ?? this.current ?? this.readCookie(this.referralCookieName());
|
|
100
114
|
}
|
|
101
115
|
/**
|
|
102
116
|
* Captured referral token for this visitor. Pass to Stripe Checkout
|
|
@@ -108,15 +122,44 @@ var OpenPartner = class _OpenPartner {
|
|
|
108
122
|
* // ...
|
|
109
123
|
* });
|
|
110
124
|
*
|
|
111
|
-
* Returns null if the visitor didn't arrive via a partner link
|
|
125
|
+
* Returns null if the visitor didn't arrive via a partner link, or
|
|
126
|
+
* hasn't agreed to tracking yet.
|
|
112
127
|
*/
|
|
113
128
|
getReferral() {
|
|
114
129
|
return this.getCref();
|
|
115
130
|
}
|
|
116
|
-
/** Explicitly discard the captured cref
|
|
131
|
+
/** Explicitly discard the captured cref, the referral cookie, and the
|
|
132
|
+
* references this SDK added to Stripe links and embeds. */
|
|
117
133
|
clear() {
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
this.held = null;
|
|
135
|
+
this.current = null;
|
|
136
|
+
this.untagStripe();
|
|
137
|
+
this.storageRemove();
|
|
138
|
+
if (typeof document === "undefined") return;
|
|
139
|
+
const name = this.referralCookieName();
|
|
140
|
+
if (!this.readCookie(name)) return;
|
|
141
|
+
const cfg = this.config.referralCookie;
|
|
142
|
+
document.cookie = `${name}=; Max-Age=0; Path=/`;
|
|
143
|
+
if (cfg && cfg.domain) document.cookie = `${name}=; Max-Age=0; Path=/; Domain=${cfg.domain}`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Record the visitor's answer to your consent banner. `'granted'` stores
|
|
147
|
+
* the referral (the one held since the page loaded, or one already
|
|
148
|
+
* stored), starts tagging Stripe links and sends the landing report.
|
|
149
|
+
* `'denied'` stops all of it, takes the references it added off Stripe
|
|
150
|
+
* links and embeds, and deletes what was stored. Calling it again is
|
|
151
|
+
* safe.
|
|
152
|
+
*/
|
|
153
|
+
setConsent(state) {
|
|
154
|
+
if (state === "granted") {
|
|
155
|
+
if (this.consent === "granted") return;
|
|
156
|
+
this.consent = state;
|
|
157
|
+
this.start();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.consent = state;
|
|
161
|
+
this.stopLinkingStripe();
|
|
162
|
+
this.clear();
|
|
120
163
|
}
|
|
121
164
|
/**
|
|
122
165
|
* Add the referral to every Stripe Payment Link and Pricing Table / Buy
|
|
@@ -143,6 +186,7 @@ var OpenPartner = class _OpenPartner {
|
|
|
143
186
|
if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has("client_reference_id")) continue;
|
|
144
187
|
url.searchParams.set("client_reference_id", referral);
|
|
145
188
|
a.href = url.toString();
|
|
189
|
+
this.remember(a, referral);
|
|
146
190
|
changed += 1;
|
|
147
191
|
}
|
|
148
192
|
const embeds = [];
|
|
@@ -151,10 +195,35 @@ var OpenPartner = class _OpenPartner {
|
|
|
151
195
|
for (const el of embeds) {
|
|
152
196
|
if (el.hasAttribute("client-reference-id")) continue;
|
|
153
197
|
el.setAttribute("client-reference-id", referral);
|
|
198
|
+
this.remember(el, referral);
|
|
154
199
|
changed += 1;
|
|
155
200
|
}
|
|
156
201
|
return changed;
|
|
157
202
|
}
|
|
203
|
+
remember(el, value) {
|
|
204
|
+
this.tagged = this.tagged.filter((t) => t.el.isConnected);
|
|
205
|
+
this.tagged.push({ el, value });
|
|
206
|
+
}
|
|
207
|
+
/** Take back the references this SDK added. One the brand set, or one
|
|
208
|
+
* changed since, stays. */
|
|
209
|
+
untagStripe() {
|
|
210
|
+
for (const { el, value } of this.tagged) {
|
|
211
|
+
if (el instanceof HTMLAnchorElement) {
|
|
212
|
+
let url;
|
|
213
|
+
try {
|
|
214
|
+
url = new URL(el.href);
|
|
215
|
+
} catch {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (url.searchParams.get("client_reference_id") !== value) continue;
|
|
219
|
+
url.searchParams.delete("client_reference_id");
|
|
220
|
+
el.href = url.toString();
|
|
221
|
+
} else if (el.getAttribute("client-reference-id") === value) {
|
|
222
|
+
el.removeAttribute("client-reference-id");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.tagged = [];
|
|
226
|
+
}
|
|
158
227
|
/** Stop watching the page for new Stripe links and embeds. */
|
|
159
228
|
stopLinkingStripe() {
|
|
160
229
|
this.observer?.disconnect();
|
|
@@ -164,6 +233,14 @@ var OpenPartner = class _OpenPartner {
|
|
|
164
233
|
const cfg = this.config.stripe;
|
|
165
234
|
return typeof cfg === "object" && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];
|
|
166
235
|
}
|
|
236
|
+
/** Everything the SDK does once it may: store, mirror, link, report. */
|
|
237
|
+
start() {
|
|
238
|
+
const landed = this.captureCref(this.held);
|
|
239
|
+
this.held = null;
|
|
240
|
+
this.writeReferralCookie();
|
|
241
|
+
if (this.config.stripe !== false) this.watchStripe();
|
|
242
|
+
if (landed && this.config.reportLanding !== false) void this.reportLanding(landed);
|
|
243
|
+
}
|
|
167
244
|
/**
|
|
168
245
|
* Link what's on the page now, then anything added or re-pointed later
|
|
169
246
|
* (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor
|
|
@@ -175,7 +252,7 @@ var OpenPartner = class _OpenPartner {
|
|
|
175
252
|
* element is parsed, before Stripe upgrades it.
|
|
176
253
|
*/
|
|
177
254
|
watchStripe() {
|
|
178
|
-
if (typeof document === "undefined" || !this.getReferral()) return;
|
|
255
|
+
if (typeof document === "undefined" || this.observer || !this.getReferral()) return;
|
|
179
256
|
this.linkStripe(document);
|
|
180
257
|
if (typeof MutationObserver === "undefined") return;
|
|
181
258
|
this.observer = new MutationObserver((records) => {
|
|
@@ -196,32 +273,52 @@ var OpenPartner = class _OpenPartner {
|
|
|
196
273
|
attributeFilter: ["href"]
|
|
197
274
|
});
|
|
198
275
|
}
|
|
276
|
+
/** The cref on the landing URL, if any. */
|
|
277
|
+
queryCref() {
|
|
278
|
+
if (typeof window === "undefined") return null;
|
|
279
|
+
return new URL(window.location.href).searchParams.get(this.config.queryParam);
|
|
280
|
+
}
|
|
199
281
|
/**
|
|
200
|
-
* Capture the cref from
|
|
201
|
-
*
|
|
202
|
-
*
|
|
282
|
+
* Capture the cref from the URL (or the one held while consent was
|
|
283
|
+
* pending), else the router's cookie, else the referral cookie, and
|
|
284
|
+
* stash it in localStorage so it survives SPA navigations and later
|
|
285
|
+
* page loads. Returns the cref when it's a click arriving now, one this
|
|
286
|
+
* browser didn't have yet, for the landing report.
|
|
203
287
|
*/
|
|
204
|
-
captureCref() {
|
|
205
|
-
if (typeof window === "undefined"
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
288
|
+
captureCref(held) {
|
|
289
|
+
if (typeof window === "undefined") return null;
|
|
290
|
+
const incoming = held ?? this.queryCref();
|
|
291
|
+
if (incoming) {
|
|
292
|
+
const isNew = this.storageGet() !== incoming;
|
|
293
|
+
this.current = incoming;
|
|
294
|
+
this.storageSet(incoming);
|
|
295
|
+
return isNew ? incoming : null;
|
|
211
296
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
297
|
+
if (this.storageGet()) return null;
|
|
298
|
+
const restored = this.readCookie(this.config.cookieName) ?? this.readCookie(this.referralCookieName());
|
|
299
|
+
if (restored) {
|
|
300
|
+
this.current = restored;
|
|
301
|
+
this.storageSet(restored);
|
|
215
302
|
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
referralCookieName() {
|
|
306
|
+
const cfg = this.config.referralCookie;
|
|
307
|
+
return cfg && cfg.name || REFERRAL_COOKIE_DEFAULT;
|
|
216
308
|
}
|
|
217
|
-
/**
|
|
218
|
-
*
|
|
309
|
+
/**
|
|
310
|
+
* Mirror the referral into the brand's own first-party cookie (when
|
|
311
|
+
* configured). Left alone when it already holds this referral: in Safari
|
|
312
|
+
* rewriting it from script would trade a server-set cookie's full
|
|
313
|
+
* lifetime for the 7-day cap. A new click overwrites it.
|
|
314
|
+
*/
|
|
219
315
|
writeReferralCookie() {
|
|
220
316
|
const cfg = this.config.referralCookie;
|
|
221
317
|
if (!cfg || typeof document === "undefined") return;
|
|
222
318
|
const referral = this.getReferral();
|
|
223
319
|
if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;
|
|
224
|
-
const name =
|
|
320
|
+
const name = this.referralCookieName();
|
|
321
|
+
if (this.readCookie(name) === referral) return;
|
|
225
322
|
const maxAge = Math.round((cfg.days ?? 90) * 86400);
|
|
226
323
|
const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
|
|
227
324
|
const domain = cfg.domain ? `; Domain=${cfg.domain}` : "";
|
|
@@ -230,16 +327,55 @@ var OpenPartner = class _OpenPartner {
|
|
|
230
327
|
readCookie(name) {
|
|
231
328
|
if (typeof document === "undefined") return null;
|
|
232
329
|
const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));
|
|
233
|
-
|
|
330
|
+
if (!match) return null;
|
|
331
|
+
try {
|
|
332
|
+
return decodeURIComponent(match[2]);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// localStorage throws in some sandboxed iframes and with site data
|
|
338
|
+
// blocked; the referral then lives in the cookie alone.
|
|
339
|
+
storageGet() {
|
|
340
|
+
try {
|
|
341
|
+
return typeof localStorage === "undefined" ? null : localStorage.getItem(this.config.storageKey);
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
storageSet(value) {
|
|
347
|
+
try {
|
|
348
|
+
if (typeof localStorage !== "undefined") localStorage.setItem(this.config.storageKey, value);
|
|
349
|
+
} catch {
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
storageRemove() {
|
|
353
|
+
try {
|
|
354
|
+
if (typeof localStorage !== "undefined") localStorage.removeItem(this.config.storageKey);
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Tell OpenPartner the click arrived. Best-effort, once per click. */
|
|
359
|
+
async reportLanding(cref) {
|
|
360
|
+
if (!STRIPE_REFERENCE_RE.test(cref) || typeof fetch === "undefined") return;
|
|
361
|
+
try {
|
|
362
|
+
await requestJson(`${this.config.apiUrl}/attribution/landing`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
body: { cref },
|
|
365
|
+
keepalive: true
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err instanceof OpenPartnerError && err.code === "click_not_found") this.config.onError(err);
|
|
369
|
+
}
|
|
234
370
|
}
|
|
235
371
|
/**
|
|
236
372
|
* Stitch the current captured click to an authenticated user. Call
|
|
237
373
|
* this on login, signup, or anywhere the user becomes known.
|
|
238
374
|
*
|
|
239
|
-
* No-op if no cref was captured. Errors are
|
|
240
|
-
* `config.onError` by default — attribution is
|
|
241
|
-
* browser — but you can await this and try/catch
|
|
242
|
-
* surface failures explicitly.
|
|
375
|
+
* No-op if no cref was captured, or consent isn't granted. Errors are
|
|
376
|
+
* reported through `config.onError` by default — attribution is
|
|
377
|
+
* best-effort from the browser — but you can await this and try/catch
|
|
378
|
+
* if you want to surface failures explicitly.
|
|
243
379
|
*/
|
|
244
380
|
async identify(userId) {
|
|
245
381
|
const cref = this.getCref();
|
package/dist/browser.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/browser.ts","../src/shared.ts"],"sourcesContent":["/**\n * Browser SDK — runs inside a merchant's web app. Captures the `cref`\n * attribution token from landing-page URLs and the first-party cookie\n * the click router sets, stashes it in localStorage so it survives\n * Safari / ITP cookie expiry and SPA navigations, and stitches to the\n * authenticated user on signup or login.\n *\n * Typical integration:\n *\n * import { OpenPartner } from '@openpartner/sdk';\n *\n * const op = OpenPartner.init({ apiUrl: 'https://openpartner.example.com' });\n *\n * // After login/signup, once you know who the user is:\n * await op.identify(user.id);\n *\n * With a captured referral it also hands the click to Stripe on its own:\n * Payment Links (buy.stripe.com) get `client_reference_id`, and Stripe\n * Pricing Table / Buy Button embeds get `client-reference-id`, including\n * ones added to the page later. A brand whose Stripe account is connected\n * to OpenPartner then needs no code for those checkouts. `stripe: false`\n * turns it off.\n *\n * No bundler? The same SDK ships as a script tag that starts itself\n * (auto.ts), served at /op.js by an OpenPartner portal.\n */\n\nimport { OpenPartnerError, requestJson, type IdentifyResponse } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { IdentifyResponse };\n\nexport interface StripeLinkingConfig {\n /** Extra Payment Link hosts — a custom domain set up for Payment Links\n * in Stripe (e.g. \"pay.yourbrand.com\"). buy.stripe.com is always on. */\n linkHosts?: string[];\n}\n\nexport interface OpenPartnerConfig {\n /** Base URL of your OpenPartner API, e.g. \"https://openpartner.example.com\". */\n apiUrl: string;\n /** First-party cookie name set by the click router. Default `_cref`. */\n cookieName?: string;\n /** Query-string parameter that carries the cref on the landing URL. Default `cref`. */\n queryParam?: string;\n /** localStorage key the captured cref is stashed under. Default `openpartner:cref`. */\n storageKey?: string;\n /**\n * Hand the captured referral to Stripe Payment Links and Pricing Table /\n * Buy Button embeds on the page. Default on; `false` turns it off.\n */\n stripe?: boolean | StripeLinkingConfig;\n /**\n * Also keep the referral in a first-party cookie, so your server can read\n * it straight off the request when it creates a Stripe Checkout Session\n * (`client_reference_id: req.cookies.op_ref`). Off by default; the\n * script-tag build turns it on. `domain` shares it across subdomains\n * (\".yourbrand.com\") when visitors land on one and check out on another.\n */\n referralCookie?: false | { name?: string; domain?: string; days?: number };\n /**\n * Called when identify() fails. Default: log to console. Pass a noop\n * to suppress entirely or hook into your observability stack.\n */\n onError?: (err: unknown) => void;\n}\n\nconst DEFAULTS = {\n cookieName: '_cref',\n queryParam: 'cref',\n storageKey: 'openpartner:cref',\n stripe: true,\n referralCookie: false,\n onError: (err: unknown) => {\n console.warn('[openpartner] identify failed', err);\n },\n};\n\n/** Stripe accepts a client_reference_id of letters, digits, \"-\" and \"_\",\n * up to 200, and silently drops anything else. A click id always fits; a\n * value that doesn't came from somewhere else and isn't sent. */\nconst STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;\nconst STRIPE_EMBED_SELECTOR = 'stripe-pricing-table, stripe-buy-button';\n\nexport class OpenPartner {\n private config: Required<OpenPartnerConfig>;\n private observer: MutationObserver | null = null;\n\n private constructor(config: OpenPartnerConfig) {\n this.config = { ...DEFAULTS, ...config } as Required<OpenPartnerConfig>;\n this.captureCref();\n this.writeReferralCookie();\n if (this.config.stripe !== false) this.watchStripe();\n }\n\n static init(config: OpenPartnerConfig): OpenPartner {\n return new OpenPartner(config);\n }\n\n /** The cref we're holding onto, if any. Useful for debugging. */\n getCref(): string | null {\n if (typeof localStorage === 'undefined') return null;\n return localStorage.getItem(this.config.storageKey);\n }\n\n /**\n * Captured referral token for this visitor. Pass to Stripe Checkout\n * as `client_reference_id` to enable webhook-based attribution\n * without an explicit identify() call:\n *\n * stripe.checkout.sessions.create({\n * client_reference_id: op.getReferral() ?? undefined,\n * // ...\n * });\n *\n * Returns null if the visitor didn't arrive via a partner link.\n */\n getReferral(): string | null {\n return this.getCref();\n }\n\n /** Explicitly discard the captured cref. */\n clear(): void {\n if (typeof localStorage === 'undefined') return;\n localStorage.removeItem(this.config.storageKey);\n }\n\n /**\n * Add the referral to every Stripe Payment Link and Pricing Table / Buy\n * Button embed under `root` that doesn't carry a reference already.\n * Runs on its own at init and for anything added later; call it yourself\n * only if you build Stripe elements inside a shadow root. Returns how\n * many it changed.\n */\n linkStripe(root: ParentNode = document): number {\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === 'undefined') return 0;\n const hosts = new Set(['buy.stripe.com', ...this.stripeLinkHosts()]);\n let changed = 0;\n\n const anchors: HTMLAnchorElement[] = [];\n if (root instanceof HTMLAnchorElement) anchors.push(root);\n anchors.push(...Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]')));\n for (const a of anchors) {\n let url: URL;\n try {\n url = new URL(a.href);\n } catch {\n continue;\n }\n // One the brand set itself (or an earlier pass) stays as it is.\n if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has('client_reference_id')) continue;\n url.searchParams.set('client_reference_id', referral);\n a.href = url.toString();\n changed += 1;\n }\n\n const embeds: Element[] = [];\n if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);\n embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));\n for (const el of embeds) {\n if (el.hasAttribute('client-reference-id')) continue;\n el.setAttribute('client-reference-id', referral);\n changed += 1;\n }\n return changed;\n }\n\n /** Stop watching the page for new Stripe links and embeds. */\n stopLinkingStripe(): void {\n this.observer?.disconnect();\n this.observer = null;\n }\n\n private stripeLinkHosts(): string[] {\n const cfg = this.config.stripe;\n return typeof cfg === 'object' && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];\n }\n\n /**\n * Link what's on the page now, then anything added or re-pointed later\n * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor\n * who didn't come from a partner link costs nothing.\n *\n * Pricing Table and Buy Button embeds read client-reference-id when\n * Stripe's script renders them, so the script tag belongs in <head>,\n * ahead of the embed: the observer then sets the attribute as the\n * element is parsed, before Stripe upgrades it.\n */\n private watchStripe(): void {\n if (typeof document === 'undefined' || !this.getReferral()) return;\n this.linkStripe(document);\n if (typeof MutationObserver === 'undefined') return;\n this.observer = new MutationObserver((records) => {\n for (const record of records) {\n if (record.type === 'attributes') {\n // An href re-pointed at a Payment Link.\n if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);\n continue;\n }\n for (const node of Array.from(record.addedNodes)) {\n if (node instanceof Element) this.linkStripe(node);\n }\n }\n });\n // Our own edits only ever add a missing reference, so the second pass\n // they trigger finds nothing to change.\n this.observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: ['href'],\n });\n }\n\n /**\n * Capture the cref from query string or cookie and stash in\n * localStorage. Runs on init so the value survives SPA navigations,\n * ITP cookie expiry, and subsequent page loads.\n */\n private captureCref(): void {\n if (typeof window === 'undefined' || typeof localStorage === 'undefined') return;\n\n const url = new URL(window.location.href);\n const queryCref = url.searchParams.get(this.config.queryParam);\n\n if (queryCref) {\n localStorage.setItem(this.config.storageKey, queryCref);\n return;\n }\n\n const cookieCref = this.readCookie(this.config.cookieName);\n if (cookieCref && !localStorage.getItem(this.config.storageKey)) {\n localStorage.setItem(this.config.storageKey, cookieCref);\n }\n }\n\n /** Mirror the referral into the brand's own first-party cookie (when\n * configured), refreshed on every page load that has one. */\n private writeReferralCookie(): void {\n const cfg = this.config.referralCookie;\n if (!cfg || typeof document === 'undefined') return;\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;\n const name = cfg.name ?? 'op_ref';\n const maxAge = Math.round((cfg.days ?? 90) * 86400);\n const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';\n const domain = cfg.domain ? `; Domain=${cfg.domain}` : '';\n document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;\n }\n\n private readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null;\n const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));\n return match ? decodeURIComponent(match[2]!) : null;\n }\n\n /**\n * Stitch the current captured click to an authenticated user. Call\n * this on login, signup, or anywhere the user becomes known.\n *\n * No-op if no cref was captured. Errors are reported through\n * `config.onError` by default — attribution is best-effort from the\n * browser — but you can await this and try/catch if you want to\n * surface failures explicitly.\n */\n async identify(userId: string): Promise<IdentifyResponse | null> {\n const cref = this.getCref();\n if (!cref) return null;\n\n try {\n return await requestJson<IdentifyResponse>(`${this.config.apiUrl}/attribution/identify`, {\n method: 'POST',\n body: { cref, userId, ts: Date.now() },\n keepalive: true,\n });\n } catch (err) {\n this.config.onError(err);\n return null;\n }\n }\n}\n","/**\n * Shared types + error class for both the browser and server entry\n * points.\n *\n * Keeping these in their own module means browser imports won't drag in\n * `fetch` polyfills or server-only code, and server imports won't touch\n * anything that references `window` or `document`.\n */\n\nexport class OpenPartnerError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n public readonly detail?: unknown,\n ) {\n super(message);\n this.name = 'OpenPartnerError';\n }\n}\n\nexport interface IdentifyResponse {\n ok: boolean;\n identityId: string | null;\n firstStitch: boolean;\n backfilledAttributions?: number;\n}\n\nexport interface EventAttribution {\n /**\n * 'not_attributed' only appears on a replay (`replayed: true`) of an event\n * that didn't attribute the first time; which miss it was isn't stored.\n * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —\n * recorded, never attributed.\n */\n status:\n | 'attributed'\n | 'no_identity'\n | 'no_click'\n | 'outside_window'\n | 'already_attributed'\n | 'not_attributed'\n | 'corrective';\n model?: 'last_click' | 'first_click' | 'linear' | 'position';\n touches?: Array<{\n clickId: string;\n partnerId: string;\n weight: number;\n attributionId: string;\n commissionId?: string;\n }>;\n}\n\nexport interface EventResponse {\n ok: boolean;\n eventId: string;\n attribution: EventAttribution;\n /** True when an earlier call with the same externalEventId already\n * recorded this event; eventId and attribution are that call's. */\n replayed?: boolean;\n}\n\nexport interface ConversionResponse extends EventResponse {\n /** The user the conversion was recorded under: the userId you passed, or\n * the click's own `click:<clickId>` user. */\n userId: string;\n}\n\n/**\n * Small fetch helper used by both entries. Normalizes non-2xx responses\n * into OpenPartnerError so callers can try/catch with a stable shape\n * instead of poking at Response objects themselves.\n */\nexport type JsonRequestInit = Omit<RequestInit, 'body'> & { body?: unknown };\n\nexport async function requestJson<T>(\n input: string,\n init: JsonRequestInit = {},\n fetchImpl: typeof fetch = globalThis.fetch,\n): Promise<T> {\n const headers = new Headers(init.headers);\n if (init.body !== undefined && !headers.has('content-type')) {\n headers.set('content-type', 'application/json');\n }\n const res = await fetchImpl(input, {\n ...init,\n headers,\n body:\n init.body === undefined\n ? undefined\n : typeof init.body === 'string'\n ? init.body\n : JSON.stringify(init.body),\n });\n\n const text = await res.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (!res.ok) {\n const errBody = (body ?? {}) as { error?: string; detail?: unknown };\n throw new OpenPartnerError(\n errBody.error ?? `${res.status} ${res.statusText}`,\n res.status,\n errBody.error,\n errBody.detail ?? body,\n );\n }\n\n return body as T;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/browser.ts","../src/shared.ts"],"sourcesContent":["/**\n * Browser SDK — runs inside a merchant's web app. Captures the `cref`\n * attribution token from landing-page URLs and the first-party cookie\n * the click router sets, stashes it in localStorage so it survives SPA\n * navigations and later visits, and stitches to the authenticated user on\n * signup or login.\n *\n * Typical integration:\n *\n * import { OpenPartner } from '@openpartner/sdk';\n *\n * const op = OpenPartner.init({ apiUrl: 'https://openpartner.example.com' });\n *\n * // After login/signup, once you know who the user is:\n * await op.identify(user.id);\n *\n * With a captured referral it also hands the click to Stripe on its own:\n * Payment Links (buy.stripe.com) get `client_reference_id`, and Stripe\n * Pricing Table / Buy Button embeds get `client-reference-id`, including\n * ones added to the page later. A brand whose Stripe account is connected\n * to OpenPartner then needs no code for those checkouts. `stripe: false`\n * turns it off.\n *\n * How long a referral lasts. Safari deletes storage that a page's own\n * scripts wrote (localStorage, and cookies set through document.cookie)\n * after 7 days without a visit, so a visitor who clicks, leaves for a\n * fortnight and comes back to buy without signing up first arrives with\n * nothing. A cookie the brand's own server sets on the landing response\n * isn't capped that way: `referralCookieHeader` in `@openpartner/sdk/server`\n * writes one, and this SDK reads it back (`op_ref`), on every subdomain it\n * covers. Once identify() has run, the stitch lives server-side and no\n * browser storage matters.\n *\n * Consent. `consent: 'pending'` holds the referral in memory until\n * setConsent('granted'): nothing is read from or written to storage or\n * cookies, no Stripe link is tagged and nothing is sent until then.\n *\n * Landing report. When a click first arrives from the URL the SDK tells\n * OpenPartner (POST /attribution/landing), so Admin → Tracking health can\n * count the clicks that never reached a page running it. `reportLanding:\n * false` turns it off.\n *\n * No bundler? The same SDK ships as a script tag that starts itself\n * (auto.ts), served at /op.js by an OpenPartner portal.\n */\n\nimport { OpenPartnerError, requestJson, type IdentifyResponse } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { IdentifyResponse };\n\nexport interface StripeLinkingConfig {\n /** Extra Payment Link hosts — a custom domain set up for Payment Links\n * in Stripe (e.g. \"pay.yourbrand.com\"). buy.stripe.com is always on. */\n linkHosts?: string[];\n}\n\n/** Whether this visitor agreed to referral tracking. */\nexport type ConsentState = 'granted' | 'pending' | 'denied';\n\nexport interface OpenPartnerConfig {\n /** Base URL of your OpenPartner API, e.g. \"https://openpartner.example.com\". */\n apiUrl: string;\n /** First-party cookie name set by the click router. Default `_cref`. */\n cookieName?: string;\n /** Query-string parameter that carries the cref on the landing URL. Default `cref`. */\n queryParam?: string;\n /** localStorage key the captured cref is stashed under. Default `openpartner:cref`. */\n storageKey?: string;\n /**\n * Hand the captured referral to Stripe Payment Links and Pricing Table /\n * Buy Button embeds on the page. Default on; `false` turns it off.\n */\n stripe?: boolean | StripeLinkingConfig;\n /**\n * Also keep the referral in a first-party cookie, so your server can read\n * it straight off the request when it creates a Stripe Checkout Session\n * (`client_reference_id: req.cookies.op_ref`). Off by default; the\n * script-tag build turns it on. `domain` shares it across subdomains\n * (\".yourbrand.com\") when visitors land on one and check out on another.\n *\n * The SDK reads this cookie back whether or not it writes it (`op_ref`\n * unless `name` says otherwise): that's how a page on another subdomain,\n * or a visit after Safari cleared localStorage, finds the referral, and\n * how one your server set (`referralCookieHeader`) reaches identify().\n */\n referralCookie?: false | { name?: string; domain?: string; days?: number };\n /**\n * Whether the visitor has agreed to referral tracking. Default\n * `'granted'`: the SDK stores the referral as soon as it loads. Behind a\n * consent banner, start at `'pending'` and call `setConsent()` from its\n * callback. Until then the referral from the URL is held in memory only,\n * so a visitor who agrees on the landing page is still credited.\n * `'denied'` uses nothing.\n */\n consent?: ConsentState;\n /**\n * Tell OpenPartner when a partner click arrives on your site, so Admin →\n * Tracking health can show clicks lost between the partner link and your\n * page. Sends the click id once per click, nothing about the visitor.\n * Default on; `false` turns it off.\n */\n reportLanding?: boolean;\n /**\n * Called when a call to OpenPartner fails: identify(), or a landing\n * report OpenPartner answered with `click_not_found` (which usually means\n * `apiUrl` names another program; other landing failures are dropped).\n * Default: log to console. Pass a noop to suppress entirely or\n * hook into your observability stack.\n */\n onError?: (err: unknown) => void;\n}\n\nconst DEFAULTS = {\n cookieName: '_cref',\n queryParam: 'cref',\n storageKey: 'openpartner:cref',\n stripe: true,\n referralCookie: false,\n consent: 'granted',\n reportLanding: true,\n onError: (err: unknown) => {\n console.warn('[openpartner] request failed', err);\n },\n};\n\n/** Stripe accepts a client_reference_id of letters, digits, \"-\" and \"_\",\n * up to 200, and silently drops anything else. A click id always fits; a\n * value that doesn't came from somewhere else and isn't sent. */\nconst STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;\nconst STRIPE_EMBED_SELECTOR = 'stripe-pricing-table, stripe-buy-button';\nconst REFERRAL_COOKIE_DEFAULT = 'op_ref';\n\nexport class OpenPartner {\n private config: Required<OpenPartnerConfig>;\n private observer: MutationObserver | null = null;\n private consent: ConsentState;\n /** The URL's referral while consent is pending, held in memory only. */\n private held: string | null = null;\n /** This page's referral, kept here too: where localStorage throws it is\n * the only copy until the cookie is written. */\n private current: string | null = null;\n /** The Stripe links and embeds this SDK tagged, and with what, so a\n * withdrawn consent can take the tags back off. */\n private tagged: Array<{ el: Element; value: string }> = [];\n\n private constructor(config: OpenPartnerConfig) {\n this.config = { ...DEFAULTS, ...config } as Required<OpenPartnerConfig>;\n this.consent = this.config.consent;\n if (this.consent === 'granted') this.start();\n else if (this.consent === 'pending') this.held = this.queryCref();\n // A returning visitor who has since said no: drop what an earlier\n // visit stored.\n else this.clear();\n }\n\n static init(config: OpenPartnerConfig): OpenPartner {\n return new OpenPartner(config);\n }\n\n /** The cref we're holding onto, if any. Useful for debugging. Null until\n * consent is granted. */\n getCref(): string | null {\n if (this.consent !== 'granted') return null;\n return this.storageGet() ?? this.current ?? this.readCookie(this.referralCookieName());\n }\n\n /**\n * Captured referral token for this visitor. Pass to Stripe Checkout\n * as `client_reference_id` to enable webhook-based attribution\n * without an explicit identify() call:\n *\n * stripe.checkout.sessions.create({\n * client_reference_id: op.getReferral() ?? undefined,\n * // ...\n * });\n *\n * Returns null if the visitor didn't arrive via a partner link, or\n * hasn't agreed to tracking yet.\n */\n getReferral(): string | null {\n return this.getCref();\n }\n\n /** Explicitly discard the captured cref, the referral cookie, and the\n * references this SDK added to Stripe links and embeds. */\n clear(): void {\n this.held = null;\n this.current = null;\n this.untagStripe();\n this.storageRemove();\n if (typeof document === 'undefined') return;\n const name = this.referralCookieName();\n if (!this.readCookie(name)) return;\n const cfg = this.config.referralCookie;\n document.cookie = `${name}=; Max-Age=0; Path=/`;\n if (cfg && cfg.domain) document.cookie = `${name}=; Max-Age=0; Path=/; Domain=${cfg.domain}`;\n }\n\n /**\n * Record the visitor's answer to your consent banner. `'granted'` stores\n * the referral (the one held since the page loaded, or one already\n * stored), starts tagging Stripe links and sends the landing report.\n * `'denied'` stops all of it, takes the references it added off Stripe\n * links and embeds, and deletes what was stored. Calling it again is\n * safe.\n */\n setConsent(state: 'granted' | 'denied'): void {\n if (state === 'granted') {\n if (this.consent === 'granted') return;\n this.consent = state;\n this.start();\n return;\n }\n this.consent = state;\n this.stopLinkingStripe();\n this.clear();\n }\n\n /**\n * Add the referral to every Stripe Payment Link and Pricing Table / Buy\n * Button embed under `root` that doesn't carry a reference already.\n * Runs on its own at init and for anything added later; call it yourself\n * only if you build Stripe elements inside a shadow root. Returns how\n * many it changed.\n */\n linkStripe(root: ParentNode = document): number {\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === 'undefined') return 0;\n const hosts = new Set(['buy.stripe.com', ...this.stripeLinkHosts()]);\n let changed = 0;\n\n const anchors: HTMLAnchorElement[] = [];\n if (root instanceof HTMLAnchorElement) anchors.push(root);\n anchors.push(...Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]')));\n for (const a of anchors) {\n let url: URL;\n try {\n url = new URL(a.href);\n } catch {\n continue;\n }\n // One the brand set itself (or an earlier pass) stays as it is.\n if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has('client_reference_id')) continue;\n url.searchParams.set('client_reference_id', referral);\n a.href = url.toString();\n this.remember(a, referral);\n changed += 1;\n }\n\n const embeds: Element[] = [];\n if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);\n embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));\n for (const el of embeds) {\n if (el.hasAttribute('client-reference-id')) continue;\n el.setAttribute('client-reference-id', referral);\n this.remember(el, referral);\n changed += 1;\n }\n return changed;\n }\n\n private remember(el: Element, value: string): void {\n // Elements an SPA has thrown away go, so the list can't grow forever.\n this.tagged = this.tagged.filter((t) => t.el.isConnected);\n this.tagged.push({ el, value });\n }\n\n /** Take back the references this SDK added. One the brand set, or one\n * changed since, stays. */\n private untagStripe(): void {\n for (const { el, value } of this.tagged) {\n if (el instanceof HTMLAnchorElement) {\n let url: URL;\n try {\n url = new URL(el.href);\n } catch {\n continue;\n }\n if (url.searchParams.get('client_reference_id') !== value) continue;\n url.searchParams.delete('client_reference_id');\n el.href = url.toString();\n } else if (el.getAttribute('client-reference-id') === value) {\n el.removeAttribute('client-reference-id');\n }\n }\n this.tagged = [];\n }\n\n /** Stop watching the page for new Stripe links and embeds. */\n stopLinkingStripe(): void {\n this.observer?.disconnect();\n this.observer = null;\n }\n\n private stripeLinkHosts(): string[] {\n const cfg = this.config.stripe;\n return typeof cfg === 'object' && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];\n }\n\n /** Everything the SDK does once it may: store, mirror, link, report. */\n private start(): void {\n const landed = this.captureCref(this.held);\n this.held = null;\n this.writeReferralCookie();\n if (this.config.stripe !== false) this.watchStripe();\n if (landed && this.config.reportLanding !== false) void this.reportLanding(landed);\n }\n\n /**\n * Link what's on the page now, then anything added or re-pointed later\n * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor\n * who didn't come from a partner link costs nothing.\n *\n * Pricing Table and Buy Button embeds read client-reference-id when\n * Stripe's script renders them, so the script tag belongs in <head>,\n * ahead of the embed: the observer then sets the attribute as the\n * element is parsed, before Stripe upgrades it.\n */\n private watchStripe(): void {\n if (typeof document === 'undefined' || this.observer || !this.getReferral()) return;\n this.linkStripe(document);\n if (typeof MutationObserver === 'undefined') return;\n this.observer = new MutationObserver((records) => {\n for (const record of records) {\n if (record.type === 'attributes') {\n // An href re-pointed at a Payment Link.\n if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);\n continue;\n }\n for (const node of Array.from(record.addedNodes)) {\n if (node instanceof Element) this.linkStripe(node);\n }\n }\n });\n // Our own edits only ever add a missing reference, so the second pass\n // they trigger finds nothing to change.\n this.observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: ['href'],\n });\n }\n\n /** The cref on the landing URL, if any. */\n private queryCref(): string | null {\n if (typeof window === 'undefined') return null;\n return new URL(window.location.href).searchParams.get(this.config.queryParam);\n }\n\n /**\n * Capture the cref from the URL (or the one held while consent was\n * pending), else the router's cookie, else the referral cookie, and\n * stash it in localStorage so it survives SPA navigations and later\n * page loads. Returns the cref when it's a click arriving now, one this\n * browser didn't have yet, for the landing report.\n */\n private captureCref(held: string | null): string | null {\n if (typeof window === 'undefined') return null;\n\n const incoming = held ?? this.queryCref();\n if (incoming) {\n const isNew = this.storageGet() !== incoming;\n this.current = incoming;\n this.storageSet(incoming);\n return isNew ? incoming : null;\n }\n\n if (this.storageGet()) return null;\n // A page that isn't the landing page: the router's own cookie (a\n // self-host whose router shares the brand's domain), then the referral\n // cookie a sibling subdomain or the brand's server wrote.\n const restored = this.readCookie(this.config.cookieName) ?? this.readCookie(this.referralCookieName());\n if (restored) {\n this.current = restored;\n this.storageSet(restored);\n }\n return null;\n }\n\n private referralCookieName(): string {\n const cfg = this.config.referralCookie;\n return (cfg && cfg.name) || REFERRAL_COOKIE_DEFAULT;\n }\n\n /**\n * Mirror the referral into the brand's own first-party cookie (when\n * configured). Left alone when it already holds this referral: in Safari\n * rewriting it from script would trade a server-set cookie's full\n * lifetime for the 7-day cap. A new click overwrites it.\n */\n private writeReferralCookie(): void {\n const cfg = this.config.referralCookie;\n if (!cfg || typeof document === 'undefined') return;\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;\n const name = this.referralCookieName();\n if (this.readCookie(name) === referral) return;\n const maxAge = Math.round((cfg.days ?? 90) * 86400);\n const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';\n const domain = cfg.domain ? `; Domain=${cfg.domain}` : '';\n document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;\n }\n\n private readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null;\n const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));\n if (!match) return null;\n try {\n return decodeURIComponent(match[2]!);\n } catch {\n return null;\n }\n }\n\n // localStorage throws in some sandboxed iframes and with site data\n // blocked; the referral then lives in the cookie alone.\n private storageGet(): string | null {\n try {\n return typeof localStorage === 'undefined' ? null : localStorage.getItem(this.config.storageKey);\n } catch {\n return null;\n }\n }\n\n private storageSet(value: string): void {\n try {\n if (typeof localStorage !== 'undefined') localStorage.setItem(this.config.storageKey, value);\n } catch {\n // Storage unavailable; see storageGet.\n }\n }\n\n private storageRemove(): void {\n try {\n if (typeof localStorage !== 'undefined') localStorage.removeItem(this.config.storageKey);\n } catch {\n // Storage unavailable; see storageGet.\n }\n }\n\n /** Tell OpenPartner the click arrived. Best-effort, once per click. */\n private async reportLanding(cref: string): Promise<void> {\n if (!STRIPE_REFERENCE_RE.test(cref) || typeof fetch === 'undefined') return;\n try {\n await requestJson(`${this.config.apiUrl}/attribution/landing`, {\n method: 'POST',\n body: { cref },\n keepalive: true,\n });\n } catch (err) {\n // Only a click this program doesn't have is worth reporting: it\n // usually means apiUrl names another program. Anything else (offline,\n // rate limited, an API from before the landing report) is dropped:\n // the report is a diagnostic, never part of attribution.\n if (err instanceof OpenPartnerError && err.code === 'click_not_found') this.config.onError(err);\n }\n }\n\n /**\n * Stitch the current captured click to an authenticated user. Call\n * this on login, signup, or anywhere the user becomes known.\n *\n * No-op if no cref was captured, or consent isn't granted. Errors are\n * reported through `config.onError` by default — attribution is\n * best-effort from the browser — but you can await this and try/catch\n * if you want to surface failures explicitly.\n */\n async identify(userId: string): Promise<IdentifyResponse | null> {\n const cref = this.getCref();\n if (!cref) return null;\n\n try {\n return await requestJson<IdentifyResponse>(`${this.config.apiUrl}/attribution/identify`, {\n method: 'POST',\n body: { cref, userId, ts: Date.now() },\n keepalive: true,\n });\n } catch (err) {\n this.config.onError(err);\n return null;\n }\n }\n}\n","/**\n * Shared types + error class for both the browser and server entry\n * points.\n *\n * Keeping these in their own module means browser imports won't drag in\n * `fetch` polyfills or server-only code, and server imports won't touch\n * anything that references `window` or `document`.\n */\n\nexport class OpenPartnerError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n public readonly detail?: unknown,\n ) {\n super(message);\n this.name = 'OpenPartnerError';\n }\n}\n\nexport interface IdentifyResponse {\n ok: boolean;\n identityId: string | null;\n firstStitch: boolean;\n backfilledAttributions?: number;\n}\n\nexport interface EventAttribution {\n /**\n * 'not_attributed' only appears on a replay (`replayed: true`) of an event\n * that didn't attribute the first time; which miss it was isn't stored.\n * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —\n * recorded, never attributed.\n */\n status:\n | 'attributed'\n | 'no_identity'\n | 'no_click'\n | 'outside_window'\n | 'already_attributed'\n | 'not_attributed'\n | 'corrective';\n model?: 'last_click' | 'first_click' | 'linear' | 'position';\n touches?: Array<{\n clickId: string;\n partnerId: string;\n weight: number;\n attributionId: string;\n commissionId?: string;\n }>;\n}\n\nexport interface EventResponse {\n ok: boolean;\n eventId: string;\n attribution: EventAttribution;\n /** True when an earlier call with the same externalEventId already\n * recorded this event; eventId and attribution are that call's. */\n replayed?: boolean;\n}\n\nexport interface ConversionResponse extends EventResponse {\n /** The user the conversion was recorded under: the userId you passed, or\n * the click's own `click:<clickId>` user. */\n userId: string;\n}\n\n/**\n * Small fetch helper used by both entries. Normalizes non-2xx responses\n * into OpenPartnerError so callers can try/catch with a stable shape\n * instead of poking at Response objects themselves.\n */\nexport type JsonRequestInit = Omit<RequestInit, 'body'> & { body?: unknown };\n\nexport async function requestJson<T>(\n input: string,\n init: JsonRequestInit = {},\n fetchImpl: typeof fetch = globalThis.fetch,\n): Promise<T> {\n const headers = new Headers(init.headers);\n if (init.body !== undefined && !headers.has('content-type')) {\n headers.set('content-type', 'application/json');\n }\n const res = await fetchImpl(input, {\n ...init,\n headers,\n body:\n init.body === undefined\n ? undefined\n : typeof init.body === 'string'\n ? init.body\n : JSON.stringify(init.body),\n });\n\n const text = await res.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (!res.ok) {\n const errBody = (body ?? {}) as { error?: string; detail?: unknown };\n throw new OpenPartnerError(\n errBody.error ?? `${res.status} ${res.statusText}`,\n res.status,\n errBody.error,\n errBody.detail ?? body,\n );\n }\n\n return body as T;\n}\n"],"mappings":"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","names":[]}
|