@affiliateo/web 1.6.1 → 2.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 +233 -44
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/provider.d.ts +14 -0
- package/dist/provider.js +251 -41
- package/dist/types.d.ts +28 -0
- package/package.json +2 -7
- package/dist/metadata.d.ts +0 -21
- package/dist/metadata.js +0 -60
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
package/README.md
CHANGED
|
@@ -1,44 +1,233 @@
|
|
|
1
|
-
# @affiliateo/web
|
|
2
|
-
|
|
3
|
-
Affiliateo web SDK
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install @affiliateo/web
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Setup
|
|
12
|
-
|
|
13
|
-
1.
|
|
14
|
-
2.
|
|
15
|
-
3.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
# @affiliateo/web
|
|
2
|
+
|
|
3
|
+
Affiliateo web SDK. track affiliate visits and pass attribution metadata to your Stripe checkout.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @affiliateo/web
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
1. Connect your Stripe account on the Affiliateo dashboard
|
|
14
|
+
2. Wrap your app in `<AffiliateoProvider>` with your campaign ID
|
|
15
|
+
3. At checkout time, call `getAttribution()` and pass the result to Stripe metadata
|
|
16
|
+
|
|
17
|
+
### Step 1. wrap your app
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
// app/layout.tsx (Next.js App Router) or _app.tsx (Pages Router)
|
|
21
|
+
import { AffiliateoProvider } from '@affiliateo/web';
|
|
22
|
+
|
|
23
|
+
export default function RootLayout({ children }) {
|
|
24
|
+
return (
|
|
25
|
+
<AffiliateoProvider campaignId="YOUR_CAMPAIGN_ID">
|
|
26
|
+
{children}
|
|
27
|
+
</AffiliateoProvider>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The provider:
|
|
33
|
+
- Reads `?ref=` from the URL when a visitor arrives
|
|
34
|
+
- Stores attribution in `localStorage` so it survives navigation
|
|
35
|
+
- Sends pageview events to Affiliateo for click tracking
|
|
36
|
+
|
|
37
|
+
### Step 2. in your checkout button (client-side)
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
'use client'
|
|
41
|
+
import { getAttribution } from '@affiliateo/web';
|
|
42
|
+
|
|
43
|
+
async function handleCheckout() {
|
|
44
|
+
const attribution = getAttribution();
|
|
45
|
+
|
|
46
|
+
const res = await fetch('/api/checkout', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
body: JSON.stringify({ attribution }),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const { url } = await res.json();
|
|
52
|
+
window.location.href = url;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`getAttribution()` reads `affiliateo_ref`, `affiliateo_visitor_id`, and `affiliateo_campaign_id` from `localStorage` and returns them as an object, ready to pass to Stripe.
|
|
57
|
+
|
|
58
|
+
### Step 3. in your checkout API route (server-side)
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// app/api/checkout/route.ts
|
|
62
|
+
export async function POST(req) {
|
|
63
|
+
const { attribution } = await req.json();
|
|
64
|
+
|
|
65
|
+
const session = await stripe.checkout.sessions.create({
|
|
66
|
+
line_items: [{ price: 'price_xxx', quantity: 1 }],
|
|
67
|
+
mode: 'payment',
|
|
68
|
+
success_url: 'https://yoursite.com/success',
|
|
69
|
+
cancel_url: 'https://yoursite.com/cancel',
|
|
70
|
+
metadata: attribution,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return Response.json({ url: session.url });
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That's it. Affiliateo's webhook will automatically read the metadata and credit the right affiliate when the payment succeeds.
|
|
78
|
+
|
|
79
|
+
## How it works
|
|
80
|
+
|
|
81
|
+
1. Visitor clicks affiliate link → `yoursite.com?ref=pinkproton`
|
|
82
|
+
2. `<AffiliateoProvider>` reads `?ref=pinkproton` from the URL and saves it in `localStorage`
|
|
83
|
+
3. As the visitor browses, attribution stays in their browser
|
|
84
|
+
4. At checkout, `getAttribution()` reads localStorage and returns the attribution object
|
|
85
|
+
5. Your server passes it as Stripe metadata when creating the checkout session
|
|
86
|
+
6. When Stripe fires the `checkout.session.completed` webhook, Affiliateo reads the metadata and credits the affiliate
|
|
87
|
+
|
|
88
|
+
## Why `localStorage` and not cookies?
|
|
89
|
+
|
|
90
|
+
- **Not blocked by Safari ITP or most privacy tools**
|
|
91
|
+
- **No CSRF concerns**
|
|
92
|
+
|
|
93
|
+
## GDPR / ePrivacy. merchant responsibility
|
|
94
|
+
|
|
95
|
+
The SDK writes persistent identifiers (`affiliateo_visitor_id`, `affiliateo_ref`, `affiliateo_campaign_id`) to the visitor's `localStorage` and sends pageview events that include the visitor's IP, user agent, and approximate geolocation to Affiliateo's servers. Under EU ePrivacy (Article 5(3)) and GDPR, persistent identifiers stored on a visitor's device require prior consent the same way cookies do.
|
|
96
|
+
|
|
97
|
+
If you serve visitors in the EU/EEA, UK, or any jurisdiction with equivalent rules, **you are responsible for obtaining consent before this script loads**, the same way you would for Google Analytics, Meta Pixel, or any other analytics tool. Typical approaches:
|
|
98
|
+
|
|
99
|
+
- Gate the `<AffiliateoProvider>` (or the `<script src="…/t.js">` tag) behind your cookie banner's "accept" callback so it only loads after consent.
|
|
100
|
+
- Document Affiliateo in your privacy policy as a processor (see our [Data Processing Agreement](https://affiliateo.com/dpa)) and list the categories of data collected.
|
|
101
|
+
|
|
102
|
+
Affiliateo acts as your data processor for visitor analytics. You are the controller and decide the lawful basis for processing your visitors' data.
|
|
103
|
+
|
|
104
|
+
## Alternative: vanilla HTML (no React)
|
|
105
|
+
|
|
106
|
+
If you're not using React, use the tracking script directly instead of the provider:
|
|
107
|
+
|
|
108
|
+
```html
|
|
109
|
+
<script defer
|
|
110
|
+
data-campaign="YOUR_CAMPAIGN_ID"
|
|
111
|
+
src="https://affiliateo.com/t.js"
|
|
112
|
+
></script>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then in your checkout flow (vanilla JS):
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
const attribution = {
|
|
119
|
+
affiliateo_ref: localStorage.getItem('affiliateo_ref'),
|
|
120
|
+
affiliateo_visitor_id: localStorage.getItem('affiliateo_visitor_id'),
|
|
121
|
+
affiliateo_campaign_id: localStorage.getItem('affiliateo_campaign_id'),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Send to your server with the rest of your checkout payload
|
|
125
|
+
fetch('/api/checkout', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
body: JSON.stringify({ attribution }),
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Track conversion goals
|
|
132
|
+
|
|
133
|
+
Pageviews auto-track. For specific moments that matter to your funnel
|
|
134
|
+
(`signup_completed`, `trial_started`, `initiate_checkout`, etc.), use
|
|
135
|
+
`track()` or HTML data attributes.
|
|
136
|
+
|
|
137
|
+
### From React
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
'use client'
|
|
141
|
+
import { useEffect } from 'react'
|
|
142
|
+
import { useAffiliateRef } from '@affiliateo/web'
|
|
143
|
+
|
|
144
|
+
export default function SignupSuccess() {
|
|
145
|
+
const { track } = useAffiliateRef()
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
track('signup_completed', { plan: 'free' })
|
|
148
|
+
}, [])
|
|
149
|
+
return <div>Welcome!</div>
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### HTML data attributes (zero JS, works everywhere)
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<button data-affiliateo-goal="initiate_checkout"
|
|
157
|
+
data-affiliateo-goal-price="49"
|
|
158
|
+
data-affiliateo-goal-plan="pro">
|
|
159
|
+
Subscribe
|
|
160
|
+
</button>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The SDK auto-fires the goal on click. Optional `data-affiliateo-goal-*`
|
|
164
|
+
attributes become metadata (kebab-case → snake_case, up to 10 params).
|
|
165
|
+
|
|
166
|
+
### Scroll-depth tracking
|
|
167
|
+
|
|
168
|
+
```html
|
|
169
|
+
<section data-affiliateo-scroll="pricing_viewed">
|
|
170
|
+
...your pricing content...
|
|
171
|
+
</section>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Fires once when the section becomes 50%+ visible. Great for landing pages
|
|
175
|
+
where you want to know which sections convert.
|
|
176
|
+
|
|
177
|
+
### Goal naming rules
|
|
178
|
+
|
|
179
|
+
Lowercase letters, digits, underscores, hyphens, colons. Max 64 chars.
|
|
180
|
+
Metadata bounded to 4KB JSON.
|
|
181
|
+
|
|
182
|
+
## Identify signed-in users (cross-device stitching)
|
|
183
|
+
|
|
184
|
+
Without `identify()`, the same person browsing on phone and buying on
|
|
185
|
+
laptop counts as two visitors. Call it once after sign-in:
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
'use client'
|
|
189
|
+
import { useEffect } from 'react'
|
|
190
|
+
import { useAffiliateRef } from '@affiliateo/web'
|
|
191
|
+
import { useUser } from './your-auth'
|
|
192
|
+
|
|
193
|
+
export default function AuthBoundary({ children }) {
|
|
194
|
+
const { identify } = useAffiliateRef()
|
|
195
|
+
const user = useUser()
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (user) identify(user.id, { email: user.email })
|
|
198
|
+
}, [user])
|
|
199
|
+
return <>{children}</>
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Idempotent. safe to fire on every page load while a user is signed in.
|
|
204
|
+
|
|
205
|
+
## API
|
|
206
|
+
|
|
207
|
+
### `<AffiliateoProvider campaignId="..." apiUrl?="...">`
|
|
208
|
+
|
|
209
|
+
React provider. Wrap your app once. Reads `?ref=` from URL, persists to localStorage, sends pageview events, sets up data-attribute + scroll listeners.
|
|
210
|
+
|
|
211
|
+
Props:
|
|
212
|
+
- `campaignId` (required): your Affiliateo campaign ID
|
|
213
|
+
- `apiUrl` (optional): defaults to `https://affiliateo.com`
|
|
214
|
+
|
|
215
|
+
### `useAffiliateRef()`
|
|
216
|
+
|
|
217
|
+
React hook that returns `{ refCode, visitorId, track, identify }`.
|
|
218
|
+
|
|
219
|
+
- `track(eventName, metadata?)`. fire a custom goal
|
|
220
|
+
- `identify(userId, opts?)`. link this visitor to a merchant user_id;
|
|
221
|
+
`opts.email` optional
|
|
222
|
+
|
|
223
|
+
### `getAttribution()`
|
|
224
|
+
|
|
225
|
+
Client-side function. Returns an object with `affiliateo_ref`, `affiliateo_visitor_id`, and `affiliateo_campaign_id` from localStorage. Pass directly as Stripe `metadata`.
|
|
226
|
+
|
|
227
|
+
## Framework support
|
|
228
|
+
|
|
229
|
+
Works with any JavaScript framework or vanilla JS:
|
|
230
|
+
- **Next.js** (App Router & Pages Router)
|
|
231
|
+
- **Remix** / **React Router**
|
|
232
|
+
- **Vite + React**
|
|
233
|
+
- **Vanilla JS/HTML** (use the `<script>` tag instead)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { AffiliateoProvider, useAffiliateRef } from './provider';
|
|
1
|
+
export { AffiliateoProvider, useAffiliateRef, getAttribution } from './provider';
|
|
2
2
|
export type { AffiliateoConfig, AffiliateoContextValue } from './types';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
export { AffiliateoProvider, useAffiliateRef } from './provider';
|
|
2
|
+
export { AffiliateoProvider, useAffiliateRef, getAttribution } from './provider';
|
package/dist/provider.d.ts
CHANGED
|
@@ -4,6 +4,20 @@ import type { AffiliateoConfig, AffiliateoContextValue } from './types';
|
|
|
4
4
|
* Returns the affiliate ref code and visitor ID.
|
|
5
5
|
*/
|
|
6
6
|
export declare function useAffiliateRef(): AffiliateoContextValue;
|
|
7
|
+
/**
|
|
8
|
+
* Returns attribution data from localStorage for checkout flows.
|
|
9
|
+
* Call this client-side and include the result in your checkout request body.
|
|
10
|
+
*
|
|
11
|
+
* Example:
|
|
12
|
+
* ```ts
|
|
13
|
+
* const attribution = getAttribution()
|
|
14
|
+
* const res = await fetch('/api/checkout', {
|
|
15
|
+
* method: 'POST',
|
|
16
|
+
* body: JSON.stringify({ ...checkoutData, ...attribution }),
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function getAttribution(): Record<string, string>;
|
|
7
21
|
export declare function AffiliateoProvider({ campaignId, apiUrl, children, }: AffiliateoConfig & {
|
|
8
22
|
children: React.ReactNode;
|
|
9
23
|
}): import("react/jsx-runtime").JSX.Element;
|
package/dist/provider.js
CHANGED
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
4
4
|
const DEFAULT_API_URL = 'https://affiliateo.com';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const LS_VISITOR_ID = 'affiliateo_visitor_id';
|
|
6
|
+
const LS_REF = 'affiliateo_ref';
|
|
7
|
+
const LS_CAMPAIGN_ID = 'affiliateo_campaign_id';
|
|
8
|
+
// localStorage helpers
|
|
9
|
+
function lsGet(key) {
|
|
10
|
+
try {
|
|
11
|
+
return localStorage.getItem(key);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
8
14
|
return null;
|
|
9
|
-
|
|
10
|
-
return m ? m[2] : null;
|
|
15
|
+
}
|
|
11
16
|
}
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const secure = window.location.protocol === 'https:' ? ';Secure' : '';
|
|
18
|
-
document.cookie = `${name}=${value};expires=${dt.toUTCString()};path=/;SameSite=Lax${secure}`;
|
|
17
|
+
function lsSet(key, value) {
|
|
18
|
+
try {
|
|
19
|
+
localStorage.setItem(key, value);
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
19
22
|
}
|
|
20
23
|
// Generate UUID
|
|
21
24
|
function uuid() {
|
|
@@ -27,9 +30,12 @@ function uuid() {
|
|
|
27
30
|
return v.toString(16);
|
|
28
31
|
});
|
|
29
32
|
}
|
|
33
|
+
const asyncNoop = async () => { };
|
|
30
34
|
const AffiliateoContext = createContext({
|
|
31
35
|
refCode: null,
|
|
32
36
|
visitorId: null,
|
|
37
|
+
track: asyncNoop,
|
|
38
|
+
identify: asyncNoop,
|
|
33
39
|
});
|
|
34
40
|
/**
|
|
35
41
|
* Returns the affiliate ref code and visitor ID.
|
|
@@ -37,10 +43,38 @@ const AffiliateoContext = createContext({
|
|
|
37
43
|
export function useAffiliateRef() {
|
|
38
44
|
return useContext(AffiliateoContext);
|
|
39
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Returns attribution data from localStorage for checkout flows.
|
|
48
|
+
* Call this client-side and include the result in your checkout request body.
|
|
49
|
+
*
|
|
50
|
+
* Example:
|
|
51
|
+
* ```ts
|
|
52
|
+
* const attribution = getAttribution()
|
|
53
|
+
* const res = await fetch('/api/checkout', {
|
|
54
|
+
* method: 'POST',
|
|
55
|
+
* body: JSON.stringify({ ...checkoutData, ...attribution }),
|
|
56
|
+
* })
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function getAttribution() {
|
|
60
|
+
const data = {};
|
|
61
|
+
const vid = lsGet(LS_VISITOR_ID);
|
|
62
|
+
const ref = lsGet(LS_REF);
|
|
63
|
+
const cid = lsGet(LS_CAMPAIGN_ID);
|
|
64
|
+
if (vid)
|
|
65
|
+
data.affiliateo_visitor_id = vid;
|
|
66
|
+
if (ref)
|
|
67
|
+
data.affiliateo_ref = ref;
|
|
68
|
+
if (cid)
|
|
69
|
+
data.affiliateo_campaign_id = cid;
|
|
70
|
+
return data;
|
|
71
|
+
}
|
|
40
72
|
export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
41
73
|
const [state, setState] = useState({
|
|
42
74
|
refCode: null,
|
|
43
75
|
visitorId: null,
|
|
76
|
+
track: asyncNoop,
|
|
77
|
+
identify: asyncNoop,
|
|
44
78
|
});
|
|
45
79
|
const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
|
|
46
80
|
const campaignIdRef = useRef(campaignId);
|
|
@@ -49,28 +83,121 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
49
83
|
if (typeof window === 'undefined')
|
|
50
84
|
return;
|
|
51
85
|
// Get or create visitor ID
|
|
52
|
-
let vid =
|
|
86
|
+
let vid = lsGet(LS_VISITOR_ID);
|
|
53
87
|
if (!vid) {
|
|
54
88
|
vid = uuid();
|
|
55
89
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// Read ref from URL params
|
|
90
|
+
lsSet(LS_VISITOR_ID, vid);
|
|
91
|
+
lsSet(LS_CAMPAIGN_ID, campaignIdRef.current);
|
|
92
|
+
// Read ref from URL params or localStorage
|
|
59
93
|
const params = new URLSearchParams(window.location.search);
|
|
60
|
-
const ref = params.get('ref') || params.get('via') || params.get('source')
|
|
94
|
+
const ref = params.get('ref') || params.get('via') || params.get('source')
|
|
95
|
+
|| lsGet(LS_REF) || null;
|
|
61
96
|
if (ref) {
|
|
62
|
-
|
|
97
|
+
lsSet(LS_REF, ref);
|
|
98
|
+
}
|
|
99
|
+
const endpoint = `${apiBase}/api/v1/web/event`;
|
|
100
|
+
// track() fires custom goals (signup_completed, trial_started, etc.).
|
|
101
|
+
// Best-effort: a failed network call is swallowed so analytics never
|
|
102
|
+
// throws in the host app. Goal-name validation matches the server's
|
|
103
|
+
// gate so merchants get instant feedback if they pass garbage.
|
|
104
|
+
const track = async (eventName, metadata) => {
|
|
105
|
+
try {
|
|
106
|
+
if (!eventName || typeof eventName !== 'string')
|
|
107
|
+
return;
|
|
108
|
+
const name = eventName.trim().toLowerCase();
|
|
109
|
+
if (!name || name.length > 64 || !/^[a-z0-9_:-]+$/.test(name))
|
|
110
|
+
return;
|
|
111
|
+
const payload = {
|
|
112
|
+
type: 'custom',
|
|
113
|
+
campaign_id: campaignIdRef.current,
|
|
114
|
+
visitor_id: vid,
|
|
115
|
+
event_name: name,
|
|
116
|
+
};
|
|
117
|
+
if (metadata && typeof metadata === 'object')
|
|
118
|
+
payload.metadata = metadata;
|
|
119
|
+
const data = JSON.stringify(payload);
|
|
120
|
+
// sendBeacon doesn't accept a response, but for custom goals we
|
|
121
|
+
// also don't care about it. fire-and-forget is exactly the
|
|
122
|
+
// semantic we want. Same fetch fallback shape as pageview/sendEnd.
|
|
123
|
+
if (navigator.sendBeacon) {
|
|
124
|
+
navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
fetch(endpoint, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: data,
|
|
131
|
+
keepalive: true,
|
|
132
|
+
}).catch(() => { });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { /* swallow */ }
|
|
136
|
+
};
|
|
137
|
+
// identify() links the anonymous visitor_id to the merchant's
|
|
138
|
+
// user_id once they sign in. Required for cross-device / cross-
|
|
139
|
+
// subdomain funnels to stitch properly. POSTs to /api/v1/web/identify
|
|
140
|
+
// which updates visitors.user_id (+ optional user_email).
|
|
141
|
+
//
|
|
142
|
+
// Idempotent: safe to call on every page load. We only fire if a
|
|
143
|
+
// non-empty userId is provided; calls with a blank id are silently
|
|
144
|
+
// swallowed so merchants who guard the call with `if (user)` and
|
|
145
|
+
// pass a falsy id during hydration don't get errors.
|
|
146
|
+
const identify = async (userId, opts) => {
|
|
147
|
+
try {
|
|
148
|
+
if (!userId || typeof userId !== 'string')
|
|
149
|
+
return;
|
|
150
|
+
const uid = userId.trim();
|
|
151
|
+
if (!uid || uid.length > 256)
|
|
152
|
+
return;
|
|
153
|
+
const email = opts?.email ? String(opts.email).trim().slice(0, 320) : undefined;
|
|
154
|
+
const payload = {
|
|
155
|
+
campaign_id: campaignIdRef.current,
|
|
156
|
+
visitor_id: vid,
|
|
157
|
+
user_id: uid,
|
|
158
|
+
};
|
|
159
|
+
if (email)
|
|
160
|
+
payload.user_email = email;
|
|
161
|
+
const data = JSON.stringify(payload);
|
|
162
|
+
const url = `${apiBase}/api/v1/web/identify`;
|
|
163
|
+
if (navigator.sendBeacon) {
|
|
164
|
+
navigator.sendBeacon(url, new Blob([data], { type: 'application/json' }));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
fetch(url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: data,
|
|
171
|
+
keepalive: true,
|
|
172
|
+
}).catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch { /* swallow */ }
|
|
176
|
+
};
|
|
177
|
+
setState({ refCode: ref, visitorId: vid, track, identify });
|
|
178
|
+
function beacon(payload) {
|
|
179
|
+
const data = JSON.stringify(payload);
|
|
180
|
+
if (navigator.sendBeacon) {
|
|
181
|
+
navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
fetch(endpoint, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
187
|
+
body: data,
|
|
188
|
+
keepalive: true,
|
|
189
|
+
}).catch(() => { });
|
|
190
|
+
}
|
|
63
191
|
}
|
|
64
|
-
setState({ refCode: ref, visitorId: vid });
|
|
65
192
|
// Throttle: max 1 pageview per second
|
|
66
193
|
let lastSendTime = 0;
|
|
67
|
-
// Send pageview
|
|
68
194
|
function sendPageview() {
|
|
69
195
|
const now = Date.now();
|
|
70
196
|
if (now - lastSendTime < 1000)
|
|
71
197
|
return;
|
|
72
198
|
lastSendTime = now;
|
|
73
|
-
|
|
199
|
+
ended = false;
|
|
200
|
+
beacon({
|
|
74
201
|
type: 'pageview',
|
|
75
202
|
campaign_id: campaignIdRef.current,
|
|
76
203
|
visitor_id: vid,
|
|
@@ -78,34 +205,38 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
78
205
|
path: window.location.pathname,
|
|
79
206
|
hostname: window.location.hostname,
|
|
80
207
|
referrer: document.referrer || '',
|
|
81
|
-
ref: ref ||
|
|
208
|
+
ref: ref || lsGet(LS_REF) || '',
|
|
82
209
|
utm_source: params.get('utm_source') || '',
|
|
83
210
|
utm_medium: params.get('utm_medium') || '',
|
|
84
211
|
utm_campaign: params.get('utm_campaign') || '',
|
|
85
212
|
screen_width: window.innerWidth,
|
|
86
|
-
};
|
|
87
|
-
const data = JSON.stringify(payload);
|
|
88
|
-
// Always use fetch so we can see responses in DevTools Network tab
|
|
89
|
-
fetch(`${apiBase}/api/v1/web/event`, {
|
|
90
|
-
method: 'POST',
|
|
91
|
-
headers: { 'Content-Type': 'application/json' },
|
|
92
|
-
body: data,
|
|
93
|
-
keepalive: true,
|
|
94
|
-
}).then(async (r) => {
|
|
95
|
-
if (!r.ok) {
|
|
96
|
-
const text = await r.text().catch(() => '');
|
|
97
|
-
console.error(`[Affiliateo] Event failed (${r.status}):`, text);
|
|
98
|
-
}
|
|
99
|
-
}).catch((err) => {
|
|
100
|
-
console.error('[Affiliateo] Event send error:', err);
|
|
101
213
|
});
|
|
102
214
|
}
|
|
103
|
-
// Send
|
|
215
|
+
// Send session_end when user leaves
|
|
216
|
+
let ended = false;
|
|
217
|
+
function sendEnd() {
|
|
218
|
+
if (ended)
|
|
219
|
+
return;
|
|
220
|
+
ended = true;
|
|
221
|
+
beacon({ type: 'session_end', campaign_id: campaignIdRef.current, visitor_id: vid, ref: ref || lsGet(LS_REF) || '' });
|
|
222
|
+
}
|
|
223
|
+
function onVisibilityChange() {
|
|
224
|
+
if (document.visibilityState === 'hidden') {
|
|
225
|
+
sendEnd();
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
ended = false;
|
|
229
|
+
} // reset if they come back (tab switch)
|
|
230
|
+
}
|
|
231
|
+
// Send pageview on mount
|
|
104
232
|
if (!sentRef.current) {
|
|
105
233
|
sentRef.current = true;
|
|
106
234
|
sendPageview();
|
|
107
235
|
}
|
|
108
|
-
//
|
|
236
|
+
// Detect user leaving
|
|
237
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
238
|
+
window.addEventListener('pagehide', sendEnd);
|
|
239
|
+
// SPA support: detect URL changes
|
|
109
240
|
let lastPath = window.location.pathname;
|
|
110
241
|
function onUrlChange() {
|
|
111
242
|
if (window.location.pathname !== lastPath) {
|
|
@@ -113,9 +244,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
113
244
|
sendPageview();
|
|
114
245
|
}
|
|
115
246
|
}
|
|
116
|
-
// Listen for browser back/forward
|
|
117
247
|
window.addEventListener('popstate', onUrlChange);
|
|
118
|
-
// Patch pushState/replaceState to detect SPA navigations
|
|
119
248
|
const origPushState = history.pushState;
|
|
120
249
|
const origReplaceState = history.replaceState;
|
|
121
250
|
history.pushState = function (...args) {
|
|
@@ -126,8 +255,89 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
126
255
|
origReplaceState.apply(this, args);
|
|
127
256
|
onUrlChange();
|
|
128
257
|
};
|
|
258
|
+
// HTML-attribute goal tracking. match Datafast's `data-fast-goal`
|
|
259
|
+
// ergonomics. Any element with `data-affiliateo-goal="event_name"`
|
|
260
|
+
// fires that goal on click. Optional `data-affiliateo-goal-*`
|
|
261
|
+
// attributes become metadata (kebab-case to snake_case, max 10).
|
|
262
|
+
//
|
|
263
|
+
// Delegated listener on document.body so the merchant doesn't have
|
|
264
|
+
// to re-attach handlers when their SPA re-renders. Capture phase off
|
|
265
|
+
// so React's synthetic event ordering isn't disturbed.
|
|
266
|
+
function onDocClick(e) {
|
|
267
|
+
const target = e.target;
|
|
268
|
+
if (!(target instanceof Element))
|
|
269
|
+
return;
|
|
270
|
+
const el = target.closest('[data-affiliateo-goal]');
|
|
271
|
+
if (!el)
|
|
272
|
+
return;
|
|
273
|
+
const goal = el.getAttribute('data-affiliateo-goal');
|
|
274
|
+
if (!goal)
|
|
275
|
+
return;
|
|
276
|
+
const meta = {};
|
|
277
|
+
let count = 0;
|
|
278
|
+
for (const { name, value } of Array.from(el.attributes)) {
|
|
279
|
+
if (!name.startsWith('data-affiliateo-goal-'))
|
|
280
|
+
continue;
|
|
281
|
+
if (count >= 10)
|
|
282
|
+
break;
|
|
283
|
+
const key = name.slice('data-affiliateo-goal-'.length).replace(/-/g, '_');
|
|
284
|
+
if (key && value) {
|
|
285
|
+
meta[key] = String(value).slice(0, 255);
|
|
286
|
+
count++;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
track(goal, count ? meta : undefined);
|
|
290
|
+
}
|
|
291
|
+
document.addEventListener('click', onDocClick);
|
|
292
|
+
// Scroll-depth goal tracking. `data-affiliateo-scroll="event_name"`
|
|
293
|
+
// on any element fires that goal the first time the element enters
|
|
294
|
+
// the viewport. Use IntersectionObserver (cheap, off the scroll hot
|
|
295
|
+
// path) and unobserve after the first fire so the same element can't
|
|
296
|
+
// double-count if the user scrolls back up.
|
|
297
|
+
const scrollFired = new WeakSet();
|
|
298
|
+
let scrollObserver = null;
|
|
299
|
+
if (typeof IntersectionObserver !== 'undefined') {
|
|
300
|
+
scrollObserver = new IntersectionObserver((entries) => {
|
|
301
|
+
for (const entry of entries) {
|
|
302
|
+
if (!entry.isIntersecting)
|
|
303
|
+
continue;
|
|
304
|
+
if (scrollFired.has(entry.target))
|
|
305
|
+
continue;
|
|
306
|
+
scrollFired.add(entry.target);
|
|
307
|
+
const goal = entry.target.getAttribute('data-affiliateo-scroll');
|
|
308
|
+
if (goal)
|
|
309
|
+
track(goal);
|
|
310
|
+
scrollObserver?.unobserve(entry.target);
|
|
311
|
+
}
|
|
312
|
+
}, { threshold: 0.5 });
|
|
313
|
+
// Observe everything that exists at mount time.
|
|
314
|
+
document.querySelectorAll('[data-affiliateo-scroll]').forEach((el) => {
|
|
315
|
+
scrollObserver?.observe(el);
|
|
316
|
+
});
|
|
317
|
+
// SPA-rendered sections that appear later get observed via
|
|
318
|
+
// MutationObserver. Cheap because we only scan added nodes, not
|
|
319
|
+
// the whole tree on each mutation.
|
|
320
|
+
const mo = new MutationObserver((mutations) => {
|
|
321
|
+
for (const m of mutations) {
|
|
322
|
+
m.addedNodes.forEach((node) => {
|
|
323
|
+
if (!(node instanceof Element))
|
|
324
|
+
return;
|
|
325
|
+
if (node.hasAttribute('data-affiliateo-scroll'))
|
|
326
|
+
scrollObserver?.observe(node);
|
|
327
|
+
node.querySelectorAll?.('[data-affiliateo-scroll]').forEach((el) => scrollObserver?.observe(el));
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
mo.observe(document.body, { childList: true, subtree: true });
|
|
332
|
+
scrollObserver._mo = mo;
|
|
333
|
+
}
|
|
129
334
|
return () => {
|
|
335
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
336
|
+
window.removeEventListener('pagehide', sendEnd);
|
|
130
337
|
window.removeEventListener('popstate', onUrlChange);
|
|
338
|
+
document.removeEventListener('click', onDocClick);
|
|
339
|
+
scrollObserver?._mo?.disconnect();
|
|
340
|
+
scrollObserver?.disconnect();
|
|
131
341
|
history.pushState = origPushState;
|
|
132
342
|
history.replaceState = origReplaceState;
|
|
133
343
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -8,4 +8,32 @@ export interface AffiliateoContextValue {
|
|
|
8
8
|
refCode: string | null;
|
|
9
9
|
/** The visitor ID stored in the cookie */
|
|
10
10
|
visitorId: string | null;
|
|
11
|
+
/** Fire a custom goal. signup_completed, trial_started, feature_used, etc.
|
|
12
|
+
* Best-effort: failures are swallowed so analytics calls never throw in
|
|
13
|
+
* the host app. Event name must be lowercase letters / digits / `_-:`
|
|
14
|
+
* and ≤ 64 chars. Optional metadata is bounded to 4KB JSON. The funnel
|
|
15
|
+
* evaluator's step type 'custom' matches against the event name.
|
|
16
|
+
*
|
|
17
|
+
* Example:
|
|
18
|
+
* const { track } = useAffiliateRef()
|
|
19
|
+
* track('signup_completed', { plan: 'pro' }) */
|
|
20
|
+
track: (eventName: string, metadata?: Record<string, unknown>) => Promise<void>;
|
|
21
|
+
/** Link this anonymous visitor to a merchant user_id. Call after
|
|
22
|
+
* sign-in / sign-up so the funnel can stitch the same person across
|
|
23
|
+
* devices, subdomains, and sessions.
|
|
24
|
+
*
|
|
25
|
+
* Without identify(), browsing on phone + buying on laptop look like
|
|
26
|
+
* two unrelated visitors and the funnel can't connect them. With it,
|
|
27
|
+
* both events resolve to the same user_id and the funnel reports a
|
|
28
|
+
* single converted person.
|
|
29
|
+
*
|
|
30
|
+
* Idempotent: safe to call on every page load. Optional email is
|
|
31
|
+
* bounded to 320 chars (RFC 5321 max).
|
|
32
|
+
*
|
|
33
|
+
* Example:
|
|
34
|
+
* const { identify } = useAffiliateRef()
|
|
35
|
+
* useEffect(() => { if (user) identify(user.id, { email: user.email }) }, [user]) */
|
|
36
|
+
identify: (userId: string, opts?: {
|
|
37
|
+
email?: string;
|
|
38
|
+
}) => Promise<void>;
|
|
11
39
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@affiliateo/web",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Affiliateo web SDK — affiliate attribution and
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -10,11 +10,6 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
12
|
"require": "./dist/index.js"
|
|
13
|
-
},
|
|
14
|
-
"./server": {
|
|
15
|
-
"types": "./dist/server.d.ts",
|
|
16
|
-
"import": "./dist/server.js",
|
|
17
|
-
"require": "./dist/server.js"
|
|
18
13
|
}
|
|
19
14
|
},
|
|
20
15
|
"scripts": {
|
package/dist/metadata.d.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Extract affiliateo cookies from an incoming request.
|
|
3
|
-
* Works with Next.js (App Router & Pages Router), Express, and any framework
|
|
4
|
-
* that exposes cookies on the request object.
|
|
5
|
-
*
|
|
6
|
-
* Returns metadata object to pass to Stripe checkout session.
|
|
7
|
-
*/
|
|
8
|
-
export declare function getMetadata(req: {
|
|
9
|
-
cookies?: Record<string, string> | {
|
|
10
|
-
get?: (name: string) => {
|
|
11
|
-
value: string;
|
|
12
|
-
} | undefined;
|
|
13
|
-
};
|
|
14
|
-
headers?: {
|
|
15
|
-
cookie?: string;
|
|
16
|
-
} | Headers;
|
|
17
|
-
}): {
|
|
18
|
-
affiliateo_visitor_id?: string;
|
|
19
|
-
affiliateo_ref?: string;
|
|
20
|
-
campaign_id?: string;
|
|
21
|
-
};
|
package/dist/metadata.js
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Extract affiliateo cookies from an incoming request.
|
|
3
|
-
* Works with Next.js (App Router & Pages Router), Express, and any framework
|
|
4
|
-
* that exposes cookies on the request object.
|
|
5
|
-
*
|
|
6
|
-
* Returns metadata object to pass to Stripe checkout session.
|
|
7
|
-
*/
|
|
8
|
-
export function getMetadata(req) {
|
|
9
|
-
const metadata = {};
|
|
10
|
-
// Try Next.js App Router cookies (ReadonlyRequestCookies with .get())
|
|
11
|
-
if (req.cookies && typeof req.cookies === 'object' && 'get' in req.cookies && typeof req.cookies.get === 'function') {
|
|
12
|
-
const visitorId = req.cookies.get('affiliateo_visitor_id');
|
|
13
|
-
const ref = req.cookies.get('affiliateo_ref');
|
|
14
|
-
const campaignId = req.cookies.get('affiliateo_campaign_id');
|
|
15
|
-
if (visitorId?.value)
|
|
16
|
-
metadata.affiliateo_visitor_id = visitorId.value;
|
|
17
|
-
if (ref?.value)
|
|
18
|
-
metadata.affiliateo_ref = ref.value;
|
|
19
|
-
if (campaignId?.value)
|
|
20
|
-
metadata.campaign_id = campaignId.value;
|
|
21
|
-
return metadata;
|
|
22
|
-
}
|
|
23
|
-
// Try plain object cookies (Express, Pages Router, etc.)
|
|
24
|
-
if (req.cookies && typeof req.cookies === 'object') {
|
|
25
|
-
const cookies = req.cookies;
|
|
26
|
-
if (cookies.affiliateo_visitor_id)
|
|
27
|
-
metadata.affiliateo_visitor_id = cookies.affiliateo_visitor_id;
|
|
28
|
-
if (cookies.affiliateo_ref)
|
|
29
|
-
metadata.affiliateo_ref = cookies.affiliateo_ref;
|
|
30
|
-
if (cookies.affiliateo_campaign_id)
|
|
31
|
-
metadata.campaign_id = cookies.affiliateo_campaign_id;
|
|
32
|
-
return metadata;
|
|
33
|
-
}
|
|
34
|
-
// Fallback: parse cookie header manually
|
|
35
|
-
const cookieHeader = req.headers instanceof Headers
|
|
36
|
-
? req.headers.get('cookie')
|
|
37
|
-
: req.headers?.cookie;
|
|
38
|
-
if (cookieHeader) {
|
|
39
|
-
const cookies = parseCookies(cookieHeader);
|
|
40
|
-
if (cookies.affiliateo_visitor_id)
|
|
41
|
-
metadata.affiliateo_visitor_id = cookies.affiliateo_visitor_id;
|
|
42
|
-
if (cookies.affiliateo_ref)
|
|
43
|
-
metadata.affiliateo_ref = cookies.affiliateo_ref;
|
|
44
|
-
if (cookies.affiliateo_campaign_id)
|
|
45
|
-
metadata.campaign_id = cookies.affiliateo_campaign_id;
|
|
46
|
-
}
|
|
47
|
-
return metadata;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Parse a cookie header string into key-value pairs.
|
|
51
|
-
*/
|
|
52
|
-
function parseCookies(cookieHeader) {
|
|
53
|
-
const cookies = {};
|
|
54
|
-
for (const pair of cookieHeader.split(';')) {
|
|
55
|
-
const [key, ...rest] = pair.trim().split('=');
|
|
56
|
-
if (key)
|
|
57
|
-
cookies[key.trim()] = rest.join('=').trim();
|
|
58
|
-
}
|
|
59
|
-
return cookies;
|
|
60
|
-
}
|
package/dist/server.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { getMetadata } from './metadata';
|
package/dist/server.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { getMetadata } from './metadata';
|