react-native-revnix 1.4.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/LICENSE +21 -0
- package/README.md +766 -0
- package/dist/blocks/background.d.ts +101 -0
- package/dist/blocks/background.js +165 -0
- package/dist/blocks/color.d.ts +88 -0
- package/dist/blocks/color.js +329 -0
- package/dist/blocks/locale.d.ts +42 -0
- package/dist/blocks/locale.js +263 -0
- package/dist/blocks/selection.d.ts +49 -0
- package/dist/blocks/selection.js +109 -0
- package/dist/blocks/style.d.ts +93 -0
- package/dist/blocks/style.js +365 -0
- package/dist/blocks/tags.d.ts +12 -0
- package/dist/blocks/tags.js +121 -0
- package/dist/blocks/types.d.ts +301 -0
- package/dist/blocks/types.js +44 -0
- package/dist/client.d.ts +402 -0
- package/dist/client.js +1653 -0
- package/dist/device.d.ts +29 -0
- package/dist/device.js +137 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +68 -0
- package/dist/esm/blocks/background.js +160 -0
- package/dist/esm/blocks/color.js +318 -0
- package/dist/esm/blocks/locale.js +255 -0
- package/dist/esm/blocks/selection.js +100 -0
- package/dist/esm/blocks/style.js +356 -0
- package/dist/esm/blocks/tags.js +117 -0
- package/dist/esm/blocks/types.js +40 -0
- package/dist/esm/client.js +1649 -0
- package/dist/esm/device.js +130 -0
- package/dist/esm/errors.js +59 -0
- package/dist/esm/iap.js +101 -0
- package/dist/esm/implicit.js +63 -0
- package/dist/esm/index.js +29 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/storage.js +78 -0
- package/dist/esm/types.js +4 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
- package/dist/esm/ui/RevnixPaywall.js +890 -0
- package/dist/esm/ui/index.js +6 -0
- package/dist/esm/version.js +4 -0
- package/dist/iap.d.ts +20 -0
- package/dist/iap.js +104 -0
- package/dist/implicit.d.ts +69 -0
- package/dist/implicit.js +67 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +50 -0
- package/dist/storage.d.ts +14 -0
- package/dist/storage.js +85 -0
- package/dist/types.d.ts +463 -0
- package/dist/types.js +5 -0
- package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
- package/dist/ui/RevnixBlockPaywall.js +742 -0
- package/dist/ui/RevnixPaywall.d.ts +148 -0
- package/dist/ui/RevnixPaywall.js +927 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +25 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/package.json +79 -0
- package/src/blocks/background.ts +232 -0
- package/src/blocks/color.ts +352 -0
- package/src/blocks/locale.ts +271 -0
- package/src/blocks/paywall-background-wire.json +12 -0
- package/src/blocks/paywall-selection-wire.json +149 -0
- package/src/blocks/selection.ts +161 -0
- package/src/blocks/style.ts +411 -0
- package/src/blocks/tags.ts +125 -0
- package/src/blocks/types.ts +352 -0
- package/src/client.ts +1993 -0
- package/src/device.ts +146 -0
- package/src/errors.ts +69 -0
- package/src/iap.ts +137 -0
- package/src/implicit.ts +129 -0
- package/src/index.ts +82 -0
- package/src/storage.ts +92 -0
- package/src/types.ts +528 -0
- package/src/ui/RevnixBlockPaywall.tsx +1423 -0
- package/src/ui/RevnixPaywall.tsx +1542 -0
- package/src/ui/index.ts +43 -0
- package/src/version.ts +4 -0
- package/ui.d.ts +1 -0
- package/ui.js +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
# revnix-react
|
|
2
|
+
|
|
3
|
+
Revnix SDK for React Native / JavaScript. Pure TypeScript over the Revnix
|
|
4
|
+
`/v1` REST API, no native modules, no dependencies. Pairs with
|
|
5
|
+
`react-native-iap` (or any StoreKit 2 / Play Billing wrapper) for purchases.
|
|
6
|
+
Ships CJS + ESM (tree-shakeable); every request carries an
|
|
7
|
+
`X-Revnix-SDK: revnix-react/<version>` header (`SDK_VERSION` is exported).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install revnix-react
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Revnix } from "revnix-react";
|
|
19
|
+
import { MMKV } from "react-native-mmkv";
|
|
20
|
+
|
|
21
|
+
const mmkv = new MMKV();
|
|
22
|
+
|
|
23
|
+
Revnix.configure({
|
|
24
|
+
apiKey: "rvx_pk_test_…", // dashboard → Settings → API keys
|
|
25
|
+
baseUrl: "https://<deployment>.convex.site", // Convex site URL
|
|
26
|
+
storage: {
|
|
27
|
+
getItem: (k) => mmkv.getString(k) ?? null,
|
|
28
|
+
setItem: (k, v) => mmkv.set(k, v),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`Revnix.configure()` returns the `RevnixClient`; afterwards `Revnix.client`
|
|
34
|
+
is available anywhere (it throws if used before `configure`). Re-running
|
|
35
|
+
`configure()` disposes the previous client first (stopping its retry drain,
|
|
36
|
+
read-your-writes polls, implicit triggers and foreground listener, safe under
|
|
37
|
+
dev fast-refresh). You
|
|
38
|
+
can also construct `new RevnixClient(config)` directly; call `dispose()` when
|
|
39
|
+
you discard one.
|
|
40
|
+
|
|
41
|
+
### API keys
|
|
42
|
+
|
|
43
|
+
The dashboard issues two kinds of key, and the key fixes the app **and**
|
|
44
|
+
environment server-side; a test key can never write into production:
|
|
45
|
+
|
|
46
|
+
- **Publishable** `rvx_pk_live_…` / `rvx_pk_test_…`: safe to embed in the
|
|
47
|
+
app. Can read entitlements, register its own purchases, report installs and
|
|
48
|
+
paywall views/closes, and set non-reserved attributes.
|
|
49
|
+
- **Secret** `rvx_sk_live_…` / `rvx_sk_test_…`: server-side only, never in
|
|
50
|
+
an app binary. Additionally allowed to call `identify()`, `alias()` and
|
|
51
|
+
`getIdentity()` (the identity graph is server-only), plus the server-only
|
|
52
|
+
grants/attributes endpoints.
|
|
53
|
+
|
|
54
|
+
Calling a secret-gated method with a publishable key fails with
|
|
55
|
+
`RevnixAuthError` (403). Legacy `rvx_live_…`/`rvx_test_…` keys still work and
|
|
56
|
+
have full access.
|
|
57
|
+
|
|
58
|
+
### Config options
|
|
59
|
+
|
|
60
|
+
| Option | Default | What it does |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `apiKey` (required) | n/a | Sent as `Authorization: Bearer <key>` |
|
|
63
|
+
| `baseUrl` (required) | n/a | Deployment site URL; trailing slashes stripped. Must be `https://`, `http://` is allowed only for localhost/private-network dev hosts (`localhost`, `127.*`, `10.*`, `192.168.*`, `172.16-31.*`, `[::1]`) |
|
|
64
|
+
| `storage` | auto | Persistent adapter; see below |
|
|
65
|
+
| `customerId` | n/a | Start as this customer instead of loading/generating an anonymous id |
|
|
66
|
+
| `requestTimeoutMs` | `10000` | Per-request timeout; slow/dead backend fails fast with `RevnixTimeoutError` |
|
|
67
|
+
| `readYourWritesDelaysMs` | `[250, 500, 1000, 2000]` | Post-purchase entitlement re-fetch schedule; `[]` disables |
|
|
68
|
+
| `entitlementsTtlMs` | `30000` | In-memory soft TTL on `getEntitlements()`/`isEntitled()`: a server read younger than this answers repeat calls from memory. `0` restores always-fetch |
|
|
69
|
+
| `offlineMaxCacheAgeMs` | 14 days | Max age of a cache-served entitlements snapshot; older snapshots serve every entitlement as `isActive: false` |
|
|
70
|
+
| `reportInstalls` | `true` | `POST /v1/installs` on first launch (forced off on non-persistent storage) |
|
|
71
|
+
| `installMetadata` | n/a | `{ platform?, appVersion? }` attached to the install report (superseded by `device`) |
|
|
72
|
+
| `device` | detected | `{ platform?, osVersion?, appVersion?, locale?, currency?, storefront?, model?, sandbox? }` sent with every placement resolve as `X-Revnix-Device`, evaluated by targeting rules on that request and stored as `device.*` attributes. Browser detects platform/OS/locale; React Native apps pass what `react-native-device-info` gives them. `false` disables |
|
|
73
|
+
| `prefetchPlacements` | n/a | Placement keys resolved-and-cached at construction (fire-and-forget), so the session's first paywall renders even offline |
|
|
74
|
+
| `onDiagnostic` | n/a | Callback invoked on every swallowed background failure (retry drains, prefetches, install/paywall reports, storage errors); see Diagnostics |
|
|
75
|
+
| `fetch` | global fetch | Custom fetch (tests, polyfills) |
|
|
76
|
+
| `onImplicitPaywall` | n/a | Handler for implicit placement moments (`app_launch`, `session_start`, etc.), configured in the dashboard |
|
|
77
|
+
| `implicitPlacements` | whether `onImplicitPaywall` is set | Explicit off switch for implicit placements, even when `onImplicitPaywall` is set |
|
|
78
|
+
| `onDeferredDeepLink` | n/a | Delivers the link the customer clicked before install, at most once. Since 1.2.2 |
|
|
79
|
+
| `onAttribution` | n/a | Fires after the first-launch install report and whenever the install-attribution verdict changes. Turns on the background fetch for it (since 1.2.4) |
|
|
80
|
+
| `lifecycle` | `visibilitychange` listener in a browser, nothing under React Native | How the SDK learns the app came to the foreground; `false` disables foreground detection |
|
|
81
|
+
| `sessionTimeoutMs` | `1800000` (30 minutes) | How long the app must be backgrounded before the return counts as a new session |
|
|
82
|
+
|
|
83
|
+
`getDeviceHeader()` returns the encoded `X-Revnix-Device` value this client
|
|
84
|
+
sends, or `null` when `device: false`.
|
|
85
|
+
|
|
86
|
+
**Storage:** pass a persistent `storage` adapter (MMKV/AsyncStorage on React
|
|
87
|
+
Native). On the web, omitting it automatically falls back to `localStorage`.
|
|
88
|
+
If neither is available the SDK runs memory-only: it still works, but it
|
|
89
|
+
warns loudly and **disables install reporting**; otherwise every launch
|
|
90
|
+
would look like a new anonymous customer and inflate your install metrics
|
|
91
|
+
(prior purchases would also not be attached across launches). A storage
|
|
92
|
+
adapter that *throws* never breaks the client either: reads/writes are
|
|
93
|
+
guarded, the failure is reported via `onDiagnostic`, and the customer id
|
|
94
|
+
survives in memory for the session. `memoryStorage()` is exported for tests.
|
|
95
|
+
|
|
96
|
+
Anonymous ids (`rvx_anon_…`) are minted with `crypto.getRandomValues`; there
|
|
97
|
+
is deliberately no `Math.random` fallback. On runtimes without WebCrypto,
|
|
98
|
+
polyfill `globalThis.crypto` or pass an explicit `customerId`.
|
|
99
|
+
|
|
100
|
+
## Identity
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// on-device (publishable key):
|
|
104
|
+
await Revnix.client.getCustomerId(); // rvx_anon_… until identified
|
|
105
|
+
await Revnix.client.logout(); // fresh anonymous customer
|
|
106
|
+
|
|
107
|
+
// server-side only (secret rvx_sk_ key, throws locally on a publishable key):
|
|
108
|
+
import { RevnixClient } from "revnix-react";
|
|
109
|
+
|
|
110
|
+
const revnix = new RevnixClient({
|
|
111
|
+
apiKey: process.env.REVNIX_SECRET_KEY!, // rvx_sk_…
|
|
112
|
+
baseUrl: "https://<deployment>.convex.site",
|
|
113
|
+
customerId: deviceCustomerId, // what the device sent from getCustomerId()
|
|
114
|
+
reportInstalls: false,
|
|
115
|
+
});
|
|
116
|
+
await revnix.identify(user.uid); // on login, merges anonymous history
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Pass the device's id in as `customerId`: without it the server-side client
|
|
120
|
+
generates its own anonymous id and merges that instead, so the device's
|
|
121
|
+
history is never merged. Build one client per request.
|
|
122
|
+
|
|
123
|
+
- `identify(userId, { strategy })`: `"merge"` (default) folds the anonymous
|
|
124
|
+
history into the identified customer; `"block"` refuses when both ids
|
|
125
|
+
already have history. No-op (no network call) when already identified as
|
|
126
|
+
that id. **Requires a secret or legacy key**, on a publishable key the SDK
|
|
127
|
+
throws `RevnixAuthError` locally, before any network call; run it from your
|
|
128
|
+
backend instead and let the device pick up the canonical id on its next
|
|
129
|
+
`registerPurchase()` (its response always carries the caller's canonical id).
|
|
130
|
+
- `alias(aliasId)`: attach an alternate id to the current customer (the
|
|
131
|
+
alias must be a fresh id; use `identify()` to merge existing customers).
|
|
132
|
+
Also secret-gated.
|
|
133
|
+
- `getIdentity()`: resolves the current id's identity cluster:
|
|
134
|
+
`{ canonicalId, isAlias, anonymous, linkedIds }`. **Also secret-gated**:
|
|
135
|
+
the response is an identity graph, so the server refuses publishable keys
|
|
136
|
+
and the SDK throws `RevnixAuthError` locally on one, before any network call.
|
|
137
|
+
- `logout()`: generates a fresh anonymous customer locally (never merges,
|
|
138
|
+
never re-reports an install).
|
|
139
|
+
|
|
140
|
+
## Purchases
|
|
141
|
+
|
|
142
|
+
Set `appAccountToken` (iOS) / `obfuscatedAccountIdAndroid` to the Revnix
|
|
143
|
+
customer id when requesting the purchase, then register it:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { purchaseInputFromIap, PurchaseBlockedError } from "revnix-react";
|
|
147
|
+
|
|
148
|
+
// in your purchaseUpdatedListener, before finishTransaction:
|
|
149
|
+
try {
|
|
150
|
+
await Revnix.client.registerPurchase(
|
|
151
|
+
purchaseInputFromIap(purchase, Platform.OS === "ios" ? "apple" : "google")
|
|
152
|
+
);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof PurchaseBlockedError) {
|
|
155
|
+
// token belongs to another account and the app forbids transfer
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Registration and the store's own webhook share an idempotency key; whichever
|
|
162
|
+
arrives first wins, the other becomes a no-op. On Apple, `purchaseInputFromIap`
|
|
163
|
+
extracts the StoreKit 2 JWS from `jwsRepresentation` (or `purchaseToken`,
|
|
164
|
+
shape-checked), prefers the JWS payload's own `originalTransactionId` as the
|
|
165
|
+
token, and sends the JWS as `signedTransactionInfo` automatically, so
|
|
166
|
+
purchases registered through the helper arrive *proven*, not provisional,
|
|
167
|
+
with no extra work. Google purchases map to
|
|
168
|
+
`token = transactionId = purchaseToken` (deliberately: it matches the
|
|
169
|
+
webhook's purchase key, not the Play `orderId`).
|
|
170
|
+
|
|
171
|
+
**Offline resilience:** if registration fails for a *transient* reason
|
|
172
|
+
(network, timeout, 429, 5xx) the purchase is persisted to a retry queue and
|
|
173
|
+
the error is rethrown; the queue drains automatically on the next app launch
|
|
174
|
+
(or call `retryPendingPurchases()` yourself). Permanent rejections, 400 bad
|
|
175
|
+
input, 401/403 auth, and `PurchaseBlockedError` (409), are never queued, and
|
|
176
|
+
one that slipped into the queue earlier is drained on the next retry pass.
|
|
177
|
+
Repeated failures of the same purchase collapse to one queue entry.
|
|
178
|
+
|
|
179
|
+
**Read-your-writes:** after a successful `registerPurchase` the SDK re-fetches
|
|
180
|
+
entitlements until the response's ledger cursor covers the purchase, so
|
|
181
|
+
`getEntitlements()` / `isEntitled()` right after buying normally include the
|
|
182
|
+
purchase, and the offline cache is warm at exactly the moment it
|
|
183
|
+
matters. Tune or disable via `readYourWritesDelaysMs` (pass `[]` to disable).
|
|
184
|
+
Each delay gets ±20% jitter, a 429's `Retry-After` is honored as extra sleep,
|
|
185
|
+
and every poll bypasses the entitlements soft TTL so it always reads the
|
|
186
|
+
server. The poll is skipped when the token turned out to belong to another
|
|
187
|
+
customer, and it resolves with the last read when the schedule runs out, it
|
|
188
|
+
never throws from the schedule itself. For out-of-band grants (e.g. your
|
|
189
|
+
server granted an entitlement and returned its `seq`),
|
|
190
|
+
`waitForEntitlements(seq)` runs the same poll on demand.
|
|
191
|
+
|
|
192
|
+
`customerId` in the result is **the id this caller should use going forward**, its own canonical id. It differs from the one you sent only when identity
|
|
193
|
+
resolution merged that id away (a restore after login), so the SDK follows it
|
|
194
|
+
and persists it.
|
|
195
|
+
|
|
196
|
+
When the token belongs to someone else, that is reported separately and the
|
|
197
|
+
SDK stays where it is: `ownedByOtherCustomer: true`, plus `tokenOwner` naming
|
|
198
|
+
the holder for secret-key callers only, a publishable key learns the outcome
|
|
199
|
+
but never another customer's id. `transferred: true` means the token was
|
|
200
|
+
reassigned *to you*.
|
|
201
|
+
|
|
202
|
+
**Store proof, send `signedTransactionInfo`.** A publishable key ships inside
|
|
203
|
+
your app binary, so "this device bought pro.monthly" is an assertion until a
|
|
204
|
+
store backs it. Pass StoreKit 2's
|
|
205
|
+
`VerificationResult.jwsRepresentation` as `signedTransactionInfo` and the
|
|
206
|
+
purchase is proven: Revnix verifies it against Apple's certificate chain and
|
|
207
|
+
checks that it names this exact transaction.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
await Revnix.client.registerPurchase({
|
|
211
|
+
source: "apple",
|
|
212
|
+
token: transaction.originalID,
|
|
213
|
+
transactionId: transaction.id,
|
|
214
|
+
productId: transaction.productID,
|
|
215
|
+
expiresAt: transaction.expirationDate,
|
|
216
|
+
signedTransactionInfo: verificationResult.jwsRepresentation, // ← proof
|
|
217
|
+
});
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Without proof the purchase is still accepted, but the result carries
|
|
221
|
+
`provisional: true`: the entitlement is granted for a short window rather than
|
|
222
|
+
the full period, and no revenue is recorded, until Apple's or Google's own
|
|
223
|
+
notification for the charge confirms it, which normally happens within
|
|
224
|
+
seconds and promotes the purchase automatically. On Google there is no
|
|
225
|
+
client-side receipt; if you have connected Play credentials, Revnix verifies
|
|
226
|
+
the purchase token directly with Play instead, and a token Play rejects is
|
|
227
|
+
refused with `403`. Once your installed base sends proof, an app can require
|
|
228
|
+
it outright (`projects.setRequirePurchaseProof`), after which unproven
|
|
229
|
+
registrations get `403` rather than a provisional grant.
|
|
230
|
+
|
|
231
|
+
**Transfer trust (server-side):** the backend only honors a transfer (or an
|
|
232
|
+
imported-history merge) for a claim it can verify: a secret `rvx_sk_…` key,
|
|
233
|
+
or that same `signedTransactionInfo`. A publishable-key registration without
|
|
234
|
+
proof still binds a *free* token, but a token bound to another customer stays
|
|
235
|
+
put and the response carries `refused: "unverified-claim"`, with your own
|
|
236
|
+
customer id untouched, so a refused claim never moves the device onto the
|
|
237
|
+
rightful owner's identity.
|
|
238
|
+
|
|
239
|
+
## Entitlements & placements
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const { entitlements, cursor } = await Revnix.client.getEntitlements();
|
|
243
|
+
const pro = await Revnix.client.isEntitled("pro");
|
|
244
|
+
const { offering, paywall } = await Revnix.client.resolvePlacement("paywall_main");
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`getEntitlements()` is network-first with an **offline cache**: on success the
|
|
248
|
+
response is cached (per customer id, snapshots for up to 4 customers are
|
|
249
|
+
kept, oldest evicted, so an offline launch right after `identify()`/`logout()`
|
|
250
|
+
still has a gate) and returned with `stale: false`. On a *transient* failure
|
|
251
|
+
(offline, timeout, 429, 5xx) the last cached snapshot is returned with
|
|
252
|
+
`stale: true` and its original `fetchedAt`; a deliberate server rejection
|
|
253
|
+
(401/403/404/409, e.g. a revoked key) is **always rethrown, cache or not**,
|
|
254
|
+
so the cache never defeats a kill switch. It only throws on transient
|
|
255
|
+
failures when there is nothing cached. `isEntitled()` goes through the same
|
|
256
|
+
path, so entitlement gates keep working offline. `getCachedEntitlements()`
|
|
257
|
+
reads the cache without touching the network (returns `null` when empty), useful for instant startup gating.
|
|
258
|
+
|
|
259
|
+
**Soft TTL & coalescing:** a server read younger than `entitlementsTtlMs`
|
|
260
|
+
(default 30 s) answers repeat `getEntitlements()`/`isEntitled()` calls from
|
|
261
|
+
memory with no request, and concurrent calls share one in-flight request; so a screen full of entitlement gates costs one fetch. Set
|
|
262
|
+
`entitlementsTtlMs: 0` to always hit the network; `waitForEntitlements()`
|
|
263
|
+
and the post-purchase poll always bypass the TTL. Practical consequence:
|
|
264
|
+
out-of-band entitlement changes (a webhook-driven grant or revoke) become
|
|
265
|
+
visible within ~30 s, not instantly.
|
|
266
|
+
|
|
267
|
+
**Local expiry grace:** cache-served entitlements are re-evaluated against the
|
|
268
|
+
device clock, an entitlement whose `expiresAt` passed more than **3 days**
|
|
269
|
+
ago is served as `isActive: false`, so a long-lapsed subscription never stays
|
|
270
|
+
active offline forever. Within those 3 days it stays active (the auto-renewal
|
|
271
|
+
an offline device cannot see yet). Independently of per-entitlement expiry, a
|
|
272
|
+
cached snapshot older than `offlineMaxCacheAgeMs` (default **14 days**), or
|
|
273
|
+
one that appears to be from the *future* because the device clock rolled back
|
|
274
|
+
more than 5 minutes, is served with every entitlement inactive, so even a
|
|
275
|
+
lifetime grant cannot be farmed by keeping a device offline forever. Fresh
|
|
276
|
+
server reads are authoritative and never re-evaluated.
|
|
277
|
+
|
|
278
|
+
`resolvePlacement(placementKey)` resolves the latest *published* catalog
|
|
279
|
+
revision. Both `offering` and `paywall` are nullable; guard before rendering
|
|
280
|
+
(a placement can exist with nothing attached, and an unpublished environment
|
|
281
|
+
is an error).
|
|
282
|
+
|
|
283
|
+
**Placements are offline-safe too:** each successful `resolvePlacement` caches
|
|
284
|
+
its resolution (per placement key); when the live read fails *transiently*
|
|
285
|
+
the cached copy is returned with `stale: true`, so the paywall renders the
|
|
286
|
+
last published version rather than not at all; it only throws when nothing
|
|
287
|
+
is cached. Deliberate answers pass through the cache: a deleted placement
|
|
288
|
+
(404) or an unpublished environment (409) still throws, so a pulled paywall
|
|
289
|
+
stays pulled.
|
|
290
|
+
`getCachedPlacement(placementKey)` reads that cache with no network call, and
|
|
291
|
+
`prefetchPlacements` (config) warms it at app start.
|
|
292
|
+
|
|
293
|
+
## Implicit placements
|
|
294
|
+
|
|
295
|
+
Six placements resolve without a `resolvePlacement` call: `app_install`,
|
|
296
|
+
`app_launch`, `session_start`, `deeplink_open`, `paywall_decline` and
|
|
297
|
+
`transaction_abandon` are moments the SDK recognises itself, so an operator
|
|
298
|
+
can attach a paywall to any of them from the dashboard with no release. Five
|
|
299
|
+
need no app code; `deeplink_open` needs the `handleDeepLink` call shown
|
|
300
|
+
below on native (the web SDK reports the landing URL itself since 1.2.4, see
|
|
301
|
+
below). **Off by default**: passing
|
|
302
|
+
`onImplicitPaywall` is what turns
|
|
303
|
+
them on; without it the SDK makes no extra requests for the other five
|
|
304
|
+
moments, though `handleDeepLink` always sends its one report regardless.
|
|
305
|
+
With `onImplicitPaywall` set, the SDK asks `GET /v1/config` once at
|
|
306
|
+
construction and fires for the moments the dashboard has configured plus
|
|
307
|
+
`session_start`, which the server always lists so every app records
|
|
308
|
+
`session.started`; an app that configured nothing costs one cached
|
|
309
|
+
`/v1/config` response per launch and one `session_start` trigger per
|
|
310
|
+
session.
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
Revnix.configure({
|
|
314
|
+
apiKey, baseUrl, storage,
|
|
315
|
+
onImplicitPaywall: ({ placement, resolution }) => present(resolution),
|
|
316
|
+
// session_start needs foreground detection. A browser is handled for you
|
|
317
|
+
// (visibilitychange); React Native passes an adapter over AppState.
|
|
318
|
+
lifecycle: {
|
|
319
|
+
onStateChange: (handler) => {
|
|
320
|
+
const sub = AppState.addEventListener("change", (s) =>
|
|
321
|
+
handler(s === "active" ? "foreground" : "background"));
|
|
322
|
+
return () => sub.remove();
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// deeplink_open is the one moment the SDK cannot see itself: the URL
|
|
328
|
+
// reaches your router.
|
|
329
|
+
Linking.getInitialURL().then((url) => url && Revnix.client.handleDeepLink(url));
|
|
330
|
+
Linking.addEventListener("url", ({ url }) => Revnix.client.handleDeepLink(url));
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`getInitialURL()` is the cold start (the link that launched a closed app);
|
|
334
|
+
the `url` event is a link arriving while the app is already running. Run
|
|
335
|
+
both once, at module scope, not inside a component effect that can remount
|
|
336
|
+
(StrictMode runs effects twice in development). A full JS reload (dev
|
|
337
|
+
reload, an OTA update) makes `getInitialURL()` return the same URL again,
|
|
338
|
+
and `handleDeepLink` would report that open twice.
|
|
339
|
+
|
|
340
|
+
On the web (`document` present) the SDK does this step itself (since 1.2.4;
|
|
341
|
+
on 1.2.3 call `handleDeepLink(window.location.href)` yourself): a landing
|
|
342
|
+
URL whose query
|
|
343
|
+
carries `utm_*` or `revnix_link` is reported once at construction, no host
|
|
344
|
+
code needed. Call `handleDeepLink` yourself only for client-side navigations
|
|
345
|
+
or for URLs without those params. A call with the
|
|
346
|
+
same landing URL is skipped once so it doesn't double-report; anything
|
|
347
|
+
after that reports normally.
|
|
348
|
+
|
|
349
|
+
`handleDeepLink` also recognises the dashboard's QR/link preview
|
|
350
|
+
(`…?revnix_preview=<token>`) and routes it through the same
|
|
351
|
+
`onImplicitPaywall` handler with `placement: "revnix_preview"`, no extra
|
|
352
|
+
wiring needed. Purchases are disabled on that display: `RevnixPaywall`
|
|
353
|
+
(classic or designed) shows an alert instead of calling `onPurchase`, and no
|
|
354
|
+
paywall view/close/interaction is sent to analytics for it. A host rendering
|
|
355
|
+
`RevnixBlockPaywall` directly, without `RevnixPaywall`, is not covered: it
|
|
356
|
+
takes no `placementKey` and has no way to know the display is a preview.
|
|
357
|
+
|
|
358
|
+
The SDK never presents for you; it does not own your navigation stack. You
|
|
359
|
+
get the same `PlacementResolution` as `resolvePlacement` returns. When you
|
|
360
|
+
render it, **pass `resolution.placementKey` to `RevnixPaywall` /
|
|
361
|
+
`logPaywallShown`**: that marks the display as implicit, which is the only
|
|
362
|
+
thing that stops a `paywall_decline` paywall from firing `paywall_decline`
|
|
363
|
+
again when dismissed. A cold start hands you at most one paywall (install →
|
|
364
|
+
launch → session, first that resolves wins), and a deep link on the first
|
|
365
|
+
frame waits for that decision. A close is a decline: never report one for a
|
|
366
|
+
display that ended in a purchase. `sessionTimeoutMs` (default 30 min) is how
|
|
367
|
+
long the app must be backgrounded for the return to count as a session.
|
|
368
|
+
|
|
369
|
+
## Deferred deep links
|
|
370
|
+
|
|
371
|
+
A click on a Revnix link (`https://<deployment>.convex.site/l/<token>`,
|
|
372
|
+
created on the Links page) sends Android to Google Play with the link's query
|
|
373
|
+
string as the install referrer, iOS to the App Store, and desktop to the
|
|
374
|
+
link's fallback URL, and remembers every non-Android click for the link's
|
|
375
|
+
click window (1 hour by default, set per link). `POST /v1/installs` (fired
|
|
376
|
+
automatically on first launch) can come back with the link the install
|
|
377
|
+
matched: exact on Android, from that install referrer; probabilistic on iOS,
|
|
378
|
+
from a same-network click or impression within the link's window, so it can
|
|
379
|
+
be wrong on a shared network. Only a link whose scheme
|
|
380
|
+
matches your app's configured URL scheme is ever returned. Pass
|
|
381
|
+
`onDeferredDeepLink` to get it, delivered at most once per install; route the
|
|
382
|
+
URL yourself, optionally also through `handleDeepLink` for `deeplink_open`
|
|
383
|
+
paywall rules:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
Revnix.configure({
|
|
387
|
+
apiKey, baseUrl, storage,
|
|
388
|
+
onDeferredDeepLink: (url, match) => router.push(url),
|
|
389
|
+
});
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
On Android, the same link can also arrive later through the Play Install
|
|
393
|
+
Referrer service; read it yourself and hand the raw string to
|
|
394
|
+
`Revnix.client.handleInstallReferrer(referrer)`, which also feeds the
|
|
395
|
+
`deferredDeepLink` response into the same `onDeferredDeepLink` handler.
|
|
396
|
+
|
|
397
|
+
An email service provider (Mailchimp, SendGrid) rewrites links through its
|
|
398
|
+
own click-tracking domain before your customer ever sees them; pass those
|
|
399
|
+
through `resolveDeepLink` first, then to your router and `handleDeepLink`:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
const url = await Revnix.client.resolveDeepLink(clickedUrl);
|
|
403
|
+
router.push(url);
|
|
404
|
+
Revnix.client.handleDeepLink(url);
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
A failure (bad input, network error) hands the URL back unchanged. The
|
|
408
|
+
result can still be an http(s) URL if the chain could not be unwrapped, so
|
|
409
|
+
check the scheme before routing on it.
|
|
410
|
+
|
|
411
|
+
## Apple Search Ads attribution
|
|
412
|
+
|
|
413
|
+
iOS-only, and only for a host that mints its own token: `AAAttribution
|
|
414
|
+
.attributionToken()` is an iOS framework call with no JavaScript binding, so
|
|
415
|
+
this package cannot read it for you. Mint it in a native module and hand the
|
|
416
|
+
raw string to `Revnix.client.handleAttributionToken(token)`, which reports it
|
|
417
|
+
alongside the install and resolves to Apple's verdict:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
const verdict = await Revnix.client.handleAttributionToken(token);
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- `"resolved"`: Apple named a Search Ads campaign. Final.
|
|
424
|
+
- `"organic"`: Apple answered, but this was not an ASA install. Final.
|
|
425
|
+
- `"pending"`: a freshly minted token is unregistered on Apple's side for a
|
|
426
|
+
few seconds; worth minting a fresh token and retrying on the next cold
|
|
427
|
+
start.
|
|
428
|
+
|
|
429
|
+
Safe to leave unawaited: it never throws, and resolves to `undefined` if
|
|
430
|
+
the client is disposed, the token is blank, storage is memory-only, or the
|
|
431
|
+
request failed.
|
|
432
|
+
|
|
433
|
+
## Install attribution
|
|
434
|
+
|
|
435
|
+
Needs 1.2.4 or later. On 1.2.3, call `GET /v1/customers/{id}/attribution`
|
|
436
|
+
yourself.
|
|
437
|
+
|
|
438
|
+
`GET /v1/customers/{id}/attribution` returns the install-attribution verdict
|
|
439
|
+
for the current customer: `installMatch` (`"referrer"` / `"click"` /
|
|
440
|
+
`"impression"` / `"organic"`), `attributedAt`, the campaign facts frozen at
|
|
441
|
+
that judgment (`source`, `medium`, `campaign`, `term`, `content`), the
|
|
442
|
+
evidence behind it (`referrerSource`, `matchSignals`), `linkToken`, and
|
|
443
|
+
`reattributedAt` when a later signal re-attributed the customer.
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
const attribution = await Revnix.client.getAttribution();
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
`null` means no install has been recorded yet (a normal cold-start race) or
|
|
450
|
+
the request failed. Pass `onAttribution` to be told about it instead of
|
|
451
|
+
polling: it fires after the first-launch install report resolves and after
|
|
452
|
+
`handleInstallReferrer` / `handleAttributionToken`, whenever the verdict
|
|
453
|
+
differs from the last one delivered (persisted, so an unchanged verdict never
|
|
454
|
+
fires twice). The SDK does not poll: call `getAttribution()` (e.g. at launch)
|
|
455
|
+
to pick up a later re-attribution, which also fires `onAttribution` when it
|
|
456
|
+
changed.
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
Revnix.configure({
|
|
460
|
+
apiKey, baseUrl, storage,
|
|
461
|
+
onAttribution: (attribution) => analytics.setUserProperties(attribution),
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
The SDK never fetches attribution on its own unless `onAttribution` is set.
|
|
466
|
+
|
|
467
|
+
## Ad revenue
|
|
468
|
+
|
|
469
|
+
Needs 1.2.4 or later. On 1.2.3, call `POST /v1/ad-revenue` yourself.
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
Revnix.client.logAdRevenue({ revenue: 0.0032, currency: "USD", network: "admob" });
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Input: `revenue` (major units, must be > 0), `currency` (ISO 4217), and
|
|
476
|
+
optional `network`, `mediation`, `adUnit`, `placement`, `format`, `eventId`.
|
|
477
|
+
Fire-and-forget: it never blocks or throws, and a non-positive or non-finite
|
|
478
|
+
`revenue` sends nothing.
|
|
479
|
+
|
|
480
|
+
## Custom events
|
|
481
|
+
|
|
482
|
+
Report an in-app event Revnix doesn't already track — not a purchase, which
|
|
483
|
+
reports through `registerPurchase`.
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
Revnix.client.track("level_up", { level: 5, difficulty: "hard" });
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
`event` must match `^[a-z0-9_]{1,64}$` and is recorded as `custom.<event>`;
|
|
490
|
+
`properties` is an optional flat object of strings/numbers/booleans. An
|
|
491
|
+
invalid `event` name sends nothing. Fire-and-forget, like `logAdRevenue`.
|
|
492
|
+
|
|
493
|
+
## MMP attribution
|
|
494
|
+
|
|
495
|
+
If you already run an MMP (Adjust, AppsFlyer, Singular, Branch, Kochava,
|
|
496
|
+
Tenjin, Airbridge), call `setAttribution` from its attribution callback so
|
|
497
|
+
Revnix credits revenue to the right network/campaign.
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
// Adjust's attribution callback
|
|
501
|
+
adjustAttributionListener((attribution) => {
|
|
502
|
+
Revnix.client.setAttribution({
|
|
503
|
+
provider: "adjust",
|
|
504
|
+
network: attribution.network,
|
|
505
|
+
campaign: attribution.campaign,
|
|
506
|
+
adGroup: attribution.adgroup,
|
|
507
|
+
creative: attribution.creative,
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// AppsFlyer's onConversionDataSuccess
|
|
512
|
+
onConversionDataSuccess((data) => {
|
|
513
|
+
if (data.af_status === "Organic") return;
|
|
514
|
+
Revnix.client.setAttribution({
|
|
515
|
+
provider: "appsflyer",
|
|
516
|
+
network: data.media_source,
|
|
517
|
+
campaign: data.campaign,
|
|
518
|
+
adGroup: data.af_adset,
|
|
519
|
+
creative: data.af_ad,
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Fire-and-forget, like `logAdRevenue`: it never blocks or throws.
|
|
525
|
+
|
|
526
|
+
## Uninstall measurement
|
|
527
|
+
|
|
528
|
+
Revnix measures uninstalls the way Adjust/AppsFlyer do: hand it the device's
|
|
529
|
+
push token, and once a day a silent push probes every registered device;
|
|
530
|
+
when APNs/FCM reports a token dead, the customer gets an `app.uninstalled`
|
|
531
|
+
event. Register the token whenever your push library delivers or refreshes
|
|
532
|
+
one:
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
// iOS: your push library's registration callback (APNs device token)
|
|
536
|
+
// Android: your push library's onNewToken / getToken()
|
|
537
|
+
messaging().onTokenRefresh((token) => Revnix.client.setPushToken(token));
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Fire-and-forget, like `setAttribution`: it never blocks or throws, dedupes
|
|
541
|
+
per customer+token, and the same token passed to `setPushToken` again after
|
|
542
|
+
`logout()`/`identify()` changed the customer is sent again. **iOS/Android
|
|
543
|
+
only** — on web it's a no-op, there is no device
|
|
544
|
+
push token to collect. Requires the app to already have push set up (iOS:
|
|
545
|
+
Push Notifications + Background Modes → Remote notifications capability;
|
|
546
|
+
Android: Firebase Cloud Messaging); no notification permission needed, the
|
|
547
|
+
probe is a silent/data-only message. See
|
|
548
|
+
[Uninstall measurement](https://revnix.io/docs/uninstall-measurement).
|
|
549
|
+
|
|
550
|
+
## Last deep link
|
|
551
|
+
|
|
552
|
+
The SDK remembers the most recent link it saw, from either `handleDeepLink`
|
|
553
|
+
or a delivered deferred deep link, so you can ask for it again later, for
|
|
554
|
+
example after login or onboarding swallowed the original delivery:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
const last = await Revnix.client.getLastDeepLink();
|
|
558
|
+
if (last) router.push(last.url);
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
`last` is `{ url, receivedAt }` or `null` when nothing has been recorded yet.
|
|
562
|
+
A dashboard preview link (`…?revnix_preview=<token>`) is never recorded. The
|
|
563
|
+
value is not cleared on logout.
|
|
564
|
+
|
|
565
|
+
## A/B tests
|
|
566
|
+
|
|
567
|
+
`resolvePlacement` sends the current customer id, so a running A/B test on
|
|
568
|
+
that placement serves this customer their assigned variant. The returned
|
|
569
|
+
`offering` and `paywall` are **already the variant's**; render what you get.
|
|
570
|
+
The assignment is exposed only so you can attribute your own analytics:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
const { offering, paywall, experiment } = await Revnix.client.resolvePlacement(
|
|
574
|
+
"paywall_main"
|
|
575
|
+
);
|
|
576
|
+
if (experiment) {
|
|
577
|
+
analytics.track("paywall_shown", {
|
|
578
|
+
experiment: experiment.key,
|
|
579
|
+
variant: experiment.variantId,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
`experiment` is `null` when no running test covers the placement. Assignment
|
|
585
|
+
is sticky per customer, survives `identify()` merges, and enrolls the
|
|
586
|
+
customer exactly once; the server records an `experiment.enrolled` ledger
|
|
587
|
+
event in the same transaction as the assignment.
|
|
588
|
+
|
|
589
|
+
### Targeting: `setAttributes`
|
|
590
|
+
|
|
591
|
+
A test can be narrowed to an *audience*: conditions over customer
|
|
592
|
+
attributes. `setAttributes` is how a mobile app supplies the facts those
|
|
593
|
+
conditions read:
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
await Revnix.client.setAttributes({
|
|
597
|
+
country: "US",
|
|
598
|
+
app_version: "4.2.0",
|
|
599
|
+
lifetime_orders: 3,
|
|
600
|
+
stale_key: null, // null deletes
|
|
601
|
+
});
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
Unlike the fire-and-forget beacons this **awaits** the write, because the very
|
|
605
|
+
next `resolvePlacement` may depend on it and silently racing the audience
|
|
606
|
+
check would make targeting look broken. Set the attributes an audience targets
|
|
607
|
+
*before* the first resolve on a covered placement: eligibility is evaluated at
|
|
608
|
+
that resolve, and a customer described only afterwards enrolls on their next
|
|
609
|
+
one.
|
|
610
|
+
|
|
611
|
+
This is the one write under `/v1/customers/*` a publishable key may make. Two
|
|
612
|
+
server rules bound it, and each rejects the whole batch rather than applying
|
|
613
|
+
part of it: `email` and `username` are reserved (secret key, from your own
|
|
614
|
+
backend), and an attribute your backend already set cannot be changed from a
|
|
615
|
+
device.
|
|
616
|
+
|
|
617
|
+
## Diagnostics
|
|
618
|
+
|
|
619
|
+
The SDK swallows failures it can recover from (retry-queue drains, placement
|
|
620
|
+
prefetches, install and paywall-view reports, storage read/write errors). `onDiagnostic` surfaces them: it is called with `{ op, error, at }` for every
|
|
621
|
+
such failure (ops include `retryPendingPurchases`, `prefetchPlacement`,
|
|
622
|
+
`waitForEntitlements`, `reportInstall`, `logPaywallShown`, `logPaywallClosed`,
|
|
623
|
+
`logPaywallEvent`, `paywall.render`, `storageRead`, `storageWrite`,
|
|
624
|
+
`handleInstallReferrer`, `handleAttributionToken`, `resolveDeepLink`,
|
|
625
|
+
`getLastDeepLink`, `rememberDeepLink`, `onDeferredDeepLink`,
|
|
626
|
+
`onImplicitPaywall`, `implicitConfig`, `implicitLaunch`,
|
|
627
|
+
`implicit:<placement>`, `preview`, `lifecycleSubscribe`, `session`,
|
|
628
|
+
`getAttribution`, `onAttribution`, `logAdRevenue`, `track`, `setAttribution`,
|
|
629
|
+
`setPushToken`);
|
|
630
|
+
exceptions thrown by your callback are swallowed. The count of background
|
|
631
|
+
failures since the last report also rides the next request as an
|
|
632
|
+
`X-Revnix-Bg-Failures: <n>` header, so the backend can see silent client pain
|
|
633
|
+
without any app-side wiring. One related guard: a 200 response with a
|
|
634
|
+
non-JSON body (captive portal, misbehaving proxy) throws a *retryable*
|
|
635
|
+
`RevnixError` instead of returning garbage.
|
|
636
|
+
|
|
637
|
+
`paywall.render` is the one op nothing on the network produces: a block
|
|
638
|
+
paywall reporting a paint string it could not read (a gradient, a clip, a
|
|
639
|
+
tile size this build does not draw). `RevnixPaywall` routes those through
|
|
640
|
+
`client.reportRenderDiagnostic(message)`, so a host that already wired
|
|
641
|
+
`onDiagnostic` sees render fallbacks with no new wiring. Call it yourself only
|
|
642
|
+
if you are drawing paywall blocks with your own renderer.
|
|
643
|
+
|
|
644
|
+
## Errors
|
|
645
|
+
|
|
646
|
+
All API errors extend `RevnixError` (`status` + `isRetryable`, which is true
|
|
647
|
+
for status 0, 429, and 5xx):
|
|
648
|
+
|
|
649
|
+
| Class | Status | When |
|
|
650
|
+
|---|---|---|
|
|
651
|
+
| `RevnixTimeoutError` | 0 | Request exceeded `requestTimeoutMs` |
|
|
652
|
+
| `RevnixNetworkError` | 0 | fetch failed (offline, DNS, TLS) |
|
|
653
|
+
| `RevnixAuthError` | 401/403 | Bad/revoked key, or publishable key on a secret-gated endpoint |
|
|
654
|
+
| `PurchaseBlockedError` | 409 | Purchase token owned by another customer, transfers blocked |
|
|
655
|
+
| `RevnixRateLimitError` | 429 | Rate limited; `retryAfterMs` when the server sent `Retry-After` |
|
|
656
|
+
| `RevnixError` | other | Anything else; message from the server's `error` field |
|
|
657
|
+
|
|
658
|
+
## Rendering the paywall (React Native)
|
|
659
|
+
|
|
660
|
+
`RevnixPaywall` renders a published paywall design exactly as the dashboard
|
|
661
|
+
paywall builder previews it; layout, copy, accent color, hero image, badge,
|
|
662
|
+
highlighted package, feature list, social proof, and offer framing all come
|
|
663
|
+
from the placement's `paywall.config`. The app supplies localized store prices
|
|
664
|
+
(StoreKit / Play Billing) and the purchase handlers, so the display never
|
|
665
|
+
disagrees with the charge.
|
|
666
|
+
|
|
667
|
+
`config.template` selects one of **nine layouts**, `focus`, `feature-list`,
|
|
668
|
+
`minimal` (the three original templates, byte-identical to how they always
|
|
669
|
+
rendered) plus `hero`, `timeline`, `plans`, `feature-grid`, `offer`, and
|
|
670
|
+
`reveal`. The dashboard's 200-template gallery is presets over these nine, so
|
|
671
|
+
a preset never introduces a shape this component cannot draw. Two optional
|
|
672
|
+
blocks compose onto any layout: `config.review` (star rating, quote, author,
|
|
673
|
+
and a `count` line under the CTA) and `config.offer` (struck-through anchor
|
|
674
|
+
price on the highlighted package, urgency line above the CTA).
|
|
675
|
+
|
|
676
|
+
```tsx
|
|
677
|
+
import { RevnixPaywall } from "revnix-react/ui";
|
|
678
|
+
|
|
679
|
+
const { paywall, offering } = await Revnix.client.resolvePlacement("paywall_main");
|
|
680
|
+
if (!paywall || !offering) return null; // both are nullable; guard first
|
|
681
|
+
|
|
682
|
+
<RevnixPaywall
|
|
683
|
+
config={paywall.config}
|
|
684
|
+
packages={offering.packages.map((pkg) => ({
|
|
685
|
+
packageId: pkg.packageId,
|
|
686
|
+
title: pkg.product?.displayName ?? pkg.productId,
|
|
687
|
+
priceLabel: storePriceFor(pkg.productId), // localized, from the store
|
|
688
|
+
}))}
|
|
689
|
+
onPurchase={(packageId) => startPurchase(packageId)}
|
|
690
|
+
loading={purchasing}
|
|
691
|
+
onRestore={restorePurchases}
|
|
692
|
+
onTerms={openTerms}
|
|
693
|
+
onPrivacy={openPrivacy}
|
|
694
|
+
client={Revnix.client} // auto-reports paywall.viewed on mount
|
|
695
|
+
placementKey="paywall_main"
|
|
696
|
+
/>;
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
- Pass `client` to report a `paywall.viewed` event once per mount (opt out
|
|
700
|
+
with `disableViewTracking`); or call `client.logPaywallShown()` yourself, it's fire-and-forget and never throws.
|
|
701
|
+
- Report the **end** of the display too: `logPaywallShown()` hands back the
|
|
702
|
+
`viewId` it minted, and `client.logPaywallClosed(viewId, …)` closes that one
|
|
703
|
+
display. The shared id is what makes it idempotent, so a retry or a
|
|
704
|
+
double-dismiss can never count two. Without the close, a funnel knows how
|
|
705
|
+
many saw the paywall but not how many left without buying.
|
|
706
|
+
- `client.logPaywallEvent(event, viewId, options?)` reports what happened **in
|
|
707
|
+
between**, one of six: `selected`, `purchase_started`,
|
|
708
|
+
`purchase_abandoned`, `purchase_failed`, `restore`, `error`. `RevnixPaywall`
|
|
709
|
+
sends `selected`, `purchase_started`, `restore` and a no-products `error`
|
|
710
|
+
for you; the purchase *outcome* is yours, because your app performs the
|
|
711
|
+
store call. All six are pure ledger history: over-reporting can skew a
|
|
712
|
+
report, never grant or revoke access.
|
|
713
|
+
- Theming: dark by default, `config.mode: "light"` switches to the light
|
|
714
|
+
theme; override any token via the `theme` prop
|
|
715
|
+
(`DEFAULT_PAYWALL_THEME` / `LIGHT_PAYWALL_THEME` are exported).
|
|
716
|
+
- Selection can be controlled (`selectedPackageId` + `onSelectPackage`) or
|
|
717
|
+
left internal (defaults to the highlighted package).
|
|
718
|
+
- Pass `onClose` to let the customer dismiss: without it no close is drawn;
|
|
719
|
+
with `client` the close is reported as `paywall.closed`.
|
|
720
|
+
- Designed (block) paywalls: `locale` picks the copy language (defaults to
|
|
721
|
+
the device's), `gradient` takes your LinearGradient (expo-linear-gradient
|
|
722
|
+
or react-native-linear-gradient), `blur` takes a BlurView,
|
|
723
|
+
`fontFamilyMap` maps design fonts to registered faces, `onDiagnostic`
|
|
724
|
+
receives render fallbacks when no `client` is passed.
|
|
725
|
+
- Unreleased (on `main`, not in 1.3.0): `Revnix.setLocale(tag)` (also
|
|
726
|
+
exported from `revnix-react/ui`) forces every designed paywall mounted
|
|
727
|
+
after the call into `tag`'s language; `Revnix.setLocale()` clears it. A
|
|
728
|
+
`locale` prop still wins for that one view. The links block's Restore,
|
|
729
|
+
Terms and Privacy labels follow the paywall's language.
|
|
730
|
+
|
|
731
|
+
The `/ui` entrypoint is separate from the core so pure-JS consumers never
|
|
732
|
+
load `react` / `react-native` (both are peer dependencies used only by `/ui`).
|
|
733
|
+
|
|
734
|
+
## Endpoints used
|
|
735
|
+
|
|
736
|
+
| Method | Endpoint | Key |
|
|
737
|
+
|---|---|---|
|
|
738
|
+
| `registerPurchase` | `POST /v1/purchases` | any |
|
|
739
|
+
| `getEntitlements` / `waitForEntitlements` | `GET /v1/customers/{id}/entitlements` | any |
|
|
740
|
+
| `getIdentity` | `GET /v1/customers/{id}/identity` | secret |
|
|
741
|
+
| `resolvePlacement` | `GET /v1/placements/{key}/offering` | any |
|
|
742
|
+
| install report | `POST /v1/installs` | any |
|
|
743
|
+
| `logPaywallShown` | `POST /v1/paywalls/viewed` | any |
|
|
744
|
+
| `logPaywallClosed` | `POST /v1/paywalls/closed` | any |
|
|
745
|
+
| `logPaywallEvent` | `POST /v1/paywalls/events` | any |
|
|
746
|
+
| `setAttributes` | `POST /v1/customers/{id}/attributes` | any |
|
|
747
|
+
| `identify` | `POST /v1/identify` | secret |
|
|
748
|
+
| `alias` | `POST /v1/customers/{id}/alias` | secret |
|
|
749
|
+
| implicit placement config | `GET /v1/config` | any |
|
|
750
|
+
| implicit moments / `handleDeepLink` | `POST /v1/placements/triggered` | any |
|
|
751
|
+
| dashboard preview link | `GET /v1/paywalls/preview/{token}` | any |
|
|
752
|
+
| `resolveDeepLink` | `GET /v1/links/resolve` | any (since 1.2.2) |
|
|
753
|
+
| `handleInstallReferrer` / `handleAttributionToken` | `POST /v1/installs` | any |
|
|
754
|
+
| `getAttribution` | `GET /v1/customers/{id}/attribution` | any (since 1.2.4) |
|
|
755
|
+
| `logAdRevenue` | `POST /v1/ad-revenue` | any (since 1.2.4) |
|
|
756
|
+
| `setPushToken` | `POST /v1/push-token` | any (since 1.3.0) |
|
|
757
|
+
| `track` | `POST /v1/events` | any (since 1.3.0) |
|
|
758
|
+
| `setAttribution` | `POST /v1/attribution` | any (since 1.3.0) |
|
|
759
|
+
|
|
760
|
+
## Development
|
|
761
|
+
|
|
762
|
+
```sh
|
|
763
|
+
npm install
|
|
764
|
+
npm test
|
|
765
|
+
npm run build # CJS + ESM (dist/ and dist/esm/)
|
|
766
|
+
```
|