@affiliateo/elements-react
v1.2.0
Published
React components for embedding Affiliateo Elements (referral link, balance, payouts and identity) inside your own app.
Maintainers
Readme
@affiliateo/elements-react
React components for embedding Affiliateo Elements inside your own app. Your users get their referral link, watch their earnings, and cash out without ever leaving your product.
This is a thin, typed binding over the hosted embed.js script. The elements, security, and versioning live on the Affiliateo side, so a new element or theme token never means a package upgrade.
Install
npm install @affiliateo/elements-reactreact and react-dom (18+) are peer dependencies.
How it works
- Your backend mints a session with your secret
afk_key. - Your frontend hands the returned client secret to
<AffiliateoElements>. - Each element renders as an iframe on the Affiliateo domain, inside your page.
Your secret key never reaches the browser, and the affiliate's data never passes through your servers.
1. Mint a session (your backend)
One endpoint of your own that mints a session for the logged-in user. The afk_ key stays on your server.
// POST /api/affiliateo-session
app.post('/api/affiliateo-session', async (req, res) => {
const r = await fetch(
`https://affiliateo.com/api/v1/businesses/${SLUG}/apps/${APP_ID}/affiliates/embed-session`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AFFILIATEO_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: req.user.email, // who this session is for
components: ['affiliate', 'balance', 'withdraw'],
allowed_origins: ['https://app.example.com'], // pages allowed to frame it
// Optional: theme from first paint, and load a brand font.
appearance: { colorPrimary: '#1754D8', borderRadius: '12px' },
fonts: [{ family: 'Inter', src: 'https://cdn.example.com/inter.woff2' }],
}),
},
)
res.json({ client_secret: (await r.json()).client_secret })
})2. Mount (your frontend)
Wrap the part of your app that shows elements in one <AffiliateoElements> provider, then drop in the elements you want.
'use client'
import {
AffiliateoElements,
AffiliateElement,
BalanceElement,
WithdrawElement,
} from '@affiliateo/elements-react'
export function AffiliatePanel() {
return (
<AffiliateoElements
fetchClientSecret={() =>
fetch('/api/affiliateo-session')
.then((r) => r.json())
.then((d) => d.client_secret)
}
>
<AffiliateElement />
<BalanceElement />
<WithdrawElement />
</AffiliateoElements>
)
}fetchClientSecret is a function, not a string, so the SDK can call it again on its own: it re-mints shortly before each hourly session expiry (and on tab wake after a sleep) and swaps the element to the new session. A page left open keeps working — just make sure the function mints a fresh secret each call.
You never set the height. Each element measures itself and resizes its own frame; initialHeight is only the placeholder shown until the first measurement arrives.
Which to use, per screen
Elements and the REST API are designed to mix, and which side a screen belongs on is mostly a performance question. Every element is its own iframe, so a page stacking four pays for four loads before it paints and your app cannot cache any of them. Anything the API already returns is faster built yourself and can be cached, so the screen renders on open.
Build link, stats, products, activity and balance from
GET /affiliates?email= + GET /apps/{appId} + GET /affiliates/conversions.
Mount elements only for withdraw, payouts and identity: the three that
collect bank details, open the account-wide wallet, or run ID capture. Mounting
everything still works, it is just slower and uncacheable.
The recurring figures are on the elements too. <StatsElement> and
<AffiliateElement> show MRR, ARR, subscriptions and not-renewing beneath
clicks/sales/earned whenever that affiliate has subscriptions, and render nothing
at all when they do not. Same GET /affiliates?email= read through the same
code, so an element and a hand-built screen cannot report different MRR for one
person. The one figure no element shows is churn_rate: whoever is looking is
the affiliate, and a percentage on their own traffic is a score, so it stays
owner-side.
The QR needs no building either. links.qr on that same response is a plain
public image URL, so the whole thing is one <img>:
<img src={affiliate.links.qr} alt="Referral QR code" width={256} height={256} />https://affiliateo.com/qr/{short_code}.svg (or .png, which is what email
clients need — they strip inline SVG). No key, no session, no SDK, cached
forever. It also sends Access-Control-Allow-Origin: *, which is what a
Download button actually requires: a cross-origin <a download> is ignored by
browsers and navigates to the file instead, so you have to fetch it into a blob
first.
async function download(url) {
const blob = await fetch(url).then((r) => r.blob())
const href = URL.createObjectURL(blob)
Object.assign(document.createElement('a'), { href, download: 'referral-qr.png' }).click()
URL.revokeObjectURL(href)
}Optional ?dark= and ?light= take hex colours, and ?size= sizes the raster.
The pair must stay dark-on-light with real contrast or the request is a 400: a
low-contrast or inverted code is one that will not scan, and finding that out
after the flyers are printed is not a recoverable mistake. The image encodes the
SHORT link tagged ?s=qr, so scans separate from link taps in the affiliate's
click breakdown without you doing anything.
The qr ELEMENT is still there and still the right call when you want our
styled card with its copy-and-download affordances rather than a bare image.
Lists are the sharpest case. The embedded activity and payouts elements show
the 25 most recent and neither paginate nor filter, so an affiliate with a
year of sales cannot reach the 26th and cannot ask to see just their refunds,
while GET /affiliates/conversions is cursor-paginated (?limit=,
?starting_after=) and filterable by date (?from=&to=) and by type
(?type=refund,chargeback, comma-separated, any of subscription, one_time,
renewal, trial, refund, chargeback).
Filter on our side, not in yours. Both filters are applied in the query,
upstream of the cursor, so a filtered feed pages through matching rows only.
Filtering rows you have already fetched looks equivalent and is not: it can only
search what is on screen, so a Refunds chip finds nothing for someone whose
single refund sits 300 rows down, and the chip reads as broken. An unknown
?type= value comes back 400 rather than an empty page, so a typo can never be
mistaken for "this affiliate has no refunds".
In our own apps that becomes one menu beside the list heading, five entries over
six raw types: All types, Sales (?type=subscription,one_time),
Renewals (renewal), Trials (trial), Refunds
(?type=refund,chargeback). Whether a first sale renews, and whether a reversal
was the customer asking or the bank taking, are not distinctions an affiliate
filters on: both mean money back. The rows still say Sale and Chargeback
individually, so the difference stays visible where it is useful.
Copy is not the catch. The API returns data, never labels, but you do not have to
write your own translations: the exact strings our elements use are published at
https://affiliateo.com/locales/{lang}.json in all 16 languages. Lift
embed.appEarnings.* for the buckets and their hint line, embed.activity.* for
row types and empty states, embed.filter.preset.* for the date chips, and
embed.link.format* for the picker. The embed.activity.*
type words double as the labels for a Type menu, since a row reading "Refund"
and a filter offering "Refund" should not be two different translations of the
same word. Copy the values into your own catalogue at
build time rather than fetching live, since those files are the elements' runtime
asset and not a versioned API. Keep the money words verbatim even if you reword
the rest: Paid means already in their Affiliateo balance and not yet in
their bank, so "Paid out" is the wording to avoid.
Components
| Component | Shows | Confirms identity |
| --- | --- | --- |
| <AffiliateElement> | Referral link + clicks, sales, earned, and MRR/subscriptions | No |
| <LinkElement> | Just the referral link, with a format dropdown | No |
| <QrElement> | A scannable QR code of the referral link | No |
| <StatsElement> | Clicks, sales and total earned, plus MRR/ARR/subscriptions when they have any | No |
| <ProductsElement> | Your catalogue with per-product commission | No |
| <ActivityElement> | Recent sales; refunds as negatives | No |
| <BalanceElement> | Wallet: ready, pending, settling | Yes |
| <WithdrawElement> | Balance + a button to cash out | Yes |
| <IdentityElement> | Get verified and paid-ready | Yes |
The three money/identity components open a small confirmation window on affiliateo.com before they render, because an Affiliateo wallet is per person and spans every program the affiliate is in. Withdrawing and identity checks continue in that same window, so your page never rebuilds bank collection or ID capture and never needs camera permission.
Each accepts initialHeight, title, className, and style. For a component chosen at runtime, use <AffiliateoElement component="balance" />.
<WithdrawElement> additionally accepts flow="inline", which renders the full cash-out form in place (amount, payout speed, bank, fees) instead of the balance plus a button that opens the hosted portal in a popup. The popup stays the default because the sign-in confirm works best top-level; opt in when a popup would jar on your page.
When the sign-in lapses
One confirm covers every gated element and keeps covering it: confirming signs the person in on Affiliateo's side, so the other gated elements on the page recognise that session with no second email, and so does the next visit. The step-up itself lasts an hour and lives only in the element's memory; the sign-in behind it outlives that.
When it eventually lapses the element goes back to asking, and says so. Only worth handling if YOUR page remembers the signed-in state somewhere:
<BalanceElement onLocked={() => setSignedIn(false)} />Without it, whatever you unhid on the strength of that memory keeps showing next to a login form.
Make it feel instant
Mount every element when your page opens and show or hide them as tabs switch, instead of mounting each one on tab-click. A hidden element still loads, so its tab opens already rendered with fresh data. This is safe for the money elements too: pre-mounted, they simply show their confirm gate until the person uses them.
Showing your own skeleton over an element? Hide it on onReady, not on the
iframe's load event: load fires when the document arrives, which is
before the element has fetched its data and laid out, so a placeholder
removed there uncovers an empty box. onReady fires after layout, with your
appearance already applied, and again after any reload (a finished
withdrawal, an hourly session refresh).
<BalanceElement onReady={() => setLoading(false)} />Theming
Pass appearance at mint for a first paint already in your brand (no flash). To restyle live, for example on a dark-mode toggle, pass appearance to the provider and update it:
If your page is dark, send colorBackground. An element paints a ground only
when you give it one, and that ground covers the whole element. Leave it out and
the element stays transparent, which is supported, but the browser's own backdrop
shows wherever your content does not reach, and that backdrop is white.
That is worth stating because it is invisible while you build. A white backdrop behind a light page looks exactly like a correct element, so a dark theme can ship looking perfect in development and render as a white slab for every real visitor. If you theme dark at all, send the token.
colorScheme (light or dark) is the companion and is usually unnecessary: it
is inferred from your colorText, since light text means a dark page. Send it only
when that inference is wrong.
Anything unusable is dropped rather than failing the mint, and comes back in
ignored_appearance_keys. Check that field first when a colour does not appear.
<AffiliateoElements fetchClientSecret={...} appearance={{ colorBackground: '#191919', colorText: '#eee' }}>Colours, gradients, fonts, sizes, radii, spacing, and button and link styling are all tokens. colorBackground, colorSurface, and buttonColor also take a CSS gradient. The full list is in the docs. Custom fonts are loaded at mint (the fonts array above), not through appearance.
Server components
The provider and elements are client components (they use browser APIs and the DOM). In Next.js App Router, render them from a file with 'use client', or import them into a client component. Mounting only happens in the browser.
Native apps (iOS / Android)
For a native app, do not use this package. Mint the session with "platform": "native" (omit allowed_origins) and open the returned element_urls entry top-level in a WKWebView or android.webkit.WebView. See the docs.
Troubleshooting
- Blank element. Your page's origin is probably not in
allowed_origins. The browser console will say the frame was refused. - "Session expired". Make sure
fetchClientSecretmints a fresh secret each call rather than returning a cached string. - Confirmation window blocked. It opens from a user tap; a pop-up blocker will stop one opened on load.
License
MIT
