react-native-revnix 1.7.0 → 1.9.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 +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +29 -9
- package/dist/blocks/locale.js +5 -0
- package/dist/blocks/types.d.ts +20 -5
- package/dist/blocks/types.js +20 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +43 -5
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +11 -1
- package/dist/esm/blocks/locale.js +5 -0
- package/dist/esm/blocks/types.js +18 -0
- package/dist/esm/client.js +44 -6
- package/dist/esm/errors.js +9 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/store/index.js +1 -1
- package/dist/esm/ui/RevnixBlockPaywall.js +51 -31
- package/dist/esm/ui/RevnixPaywall.js +11 -2
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/store/index.js +1 -1
- package/dist/store.d.ts +7 -1
- package/dist/types.d.ts +3 -0
- package/dist/ui/RevnixBlockPaywall.js +50 -30
- package/dist/ui/RevnixPaywall.js +11 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/RevnixStore.m +1 -0
- package/ios/RevnixStore.swift +3 -2
- package/package.json +1 -1
- package/src/blocks/locale.ts +7 -0
- package/src/blocks/types.ts +35 -5
- package/src/client.ts +48 -7
- package/src/errors.ts +10 -0
- package/src/index.ts +1 -0
- package/src/store/index.ts +2 -2
- package/src/store.ts +7 -1
- package/src/types.ts +3 -0
- package/src/ui/RevnixBlockPaywall.tsx +103 -46
- package/src/ui/RevnixPaywall.tsx +11 -2
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -104,6 +104,7 @@ Your API key and deployment URL are in the dashboard under Settings. See [API ke
|
|
|
104
104
|
**Revnix handles the hard parts of subscriptions in a small, developer-friendly SDK.**
|
|
105
105
|
|
|
106
106
|
- `purchase(productId)` runs the whole flow: store sheet, server-side receipt validation, entitlement, transaction finish. `restore()` brings purchases back on a new device.
|
|
107
|
+
- Consumables on Google Play: list their ids in `consumableProductIds` so they are consumed after Revnix records the purchase and can be bought again.
|
|
107
108
|
- Renewals, purchases on other devices and Ask to Buy approvals arrive on their own and are registered automatically.
|
|
108
109
|
- Entitlements and placements are cached on device, so paid users stay unlocked offline.
|
|
109
110
|
- Failed registrations are retried automatically when the app comes back online.
|
|
@@ -9,6 +9,7 @@ import com.android.billingclient.api.BillingClient.ProductType
|
|
|
9
9
|
import com.android.billingclient.api.BillingClientStateListener
|
|
10
10
|
import com.android.billingclient.api.BillingFlowParams
|
|
11
11
|
import com.android.billingclient.api.BillingResult
|
|
12
|
+
import com.android.billingclient.api.ConsumeParams
|
|
12
13
|
import com.android.billingclient.api.PendingPurchasesParams
|
|
13
14
|
import com.android.billingclient.api.ProductDetails
|
|
14
15
|
import com.android.billingclient.api.Purchase
|
|
@@ -96,10 +97,9 @@ class RevnixStoreModule(context: ReactApplicationContext) :
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
// ponytail: acknowledges every purchase; consumable INAPP products need consumeAsync to be bought again
|
|
100
100
|
@ReactMethod
|
|
101
|
-
fun finish(purchaseToken: String, promise: Promise) {
|
|
102
|
-
if (purchaseToken in acknowledged) {
|
|
101
|
+
fun finish(purchaseToken: String, consume: Boolean, promise: Promise) {
|
|
102
|
+
if (!consume && purchaseToken in acknowledged) {
|
|
103
103
|
promise.resolve(null)
|
|
104
104
|
return
|
|
105
105
|
}
|
|
@@ -108,13 +108,32 @@ class RevnixStoreModule(context: ReactApplicationContext) :
|
|
|
108
108
|
promise.reject("store_error", "${setup.responseCode} ${setup.debugMessage}")
|
|
109
109
|
return@withBilling
|
|
110
110
|
}
|
|
111
|
-
val
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
val acknowledge = { onResult: (BillingResult) -> Unit ->
|
|
112
|
+
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build()
|
|
113
|
+
billing.acknowledgePurchase(params) { result ->
|
|
114
|
+
if (result.responseCode == BillingResponseCode.OK) acknowledged.add(purchaseToken)
|
|
115
|
+
onResult(result)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!consume) {
|
|
119
|
+
acknowledge { result ->
|
|
120
|
+
if (result.responseCode == BillingResponseCode.OK) promise.resolve(null)
|
|
121
|
+
else promise.reject("store_error", "acknowledgePurchase failed: ${result.responseCode} ${result.debugMessage}")
|
|
122
|
+
}
|
|
123
|
+
return@withBilling
|
|
124
|
+
}
|
|
125
|
+
val params = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build()
|
|
126
|
+
billing.consumeAsync(params) { consumed, _ ->
|
|
127
|
+
if (consumed.responseCode == BillingResponseCode.OK) {
|
|
114
128
|
acknowledged.add(purchaseToken)
|
|
115
129
|
promise.resolve(null)
|
|
116
130
|
} else {
|
|
117
|
-
|
|
131
|
+
acknowledge { acked ->
|
|
132
|
+
promise.reject(
|
|
133
|
+
"store_error",
|
|
134
|
+
"consumeAsync failed: ${consumed.responseCode} ${consumed.debugMessage}; acknowledgePurchase fallback: ${acked.responseCode} ${acked.debugMessage}",
|
|
135
|
+
)
|
|
136
|
+
}
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
}
|
|
@@ -193,7 +212,7 @@ class RevnixStoreModule(context: ReactApplicationContext) :
|
|
|
193
212
|
if (result.responseCode == BillingResponseCode.OK) {
|
|
194
213
|
queryPurchases(ProductType.SUBS) { subs ->
|
|
195
214
|
queryPurchases(ProductType.INAPP) { inapp ->
|
|
196
|
-
(subs
|
|
215
|
+
(subs.filterNot { it.isAcknowledged } + inapp).forEach { emit(toMap(it)) }
|
|
197
216
|
}
|
|
198
217
|
}
|
|
199
218
|
}
|
|
@@ -210,7 +229,7 @@ class RevnixStoreModule(context: ReactApplicationContext) :
|
|
|
210
229
|
BillingResponseCode.ITEM_ALREADY_OWNED -> queryPurchases(ProductType.SUBS) { subs ->
|
|
211
230
|
queryPurchases(ProductType.INAPP) { inapp ->
|
|
212
231
|
val owned = (subs + inapp).firstOrNull { waiting.productId in it.products }
|
|
213
|
-
if (owned != null) waiting.promise.resolve(toMap(owned))
|
|
232
|
+
if (owned != null) waiting.promise.resolve(toMap(owned).apply { putBoolean("alreadyOwned", true) })
|
|
214
233
|
else waiting.promise.reject("store_error", "${result.responseCode} ${result.debugMessage}")
|
|
215
234
|
}
|
|
216
235
|
}
|
|
@@ -274,6 +293,7 @@ class RevnixStoreModule(context: ReactApplicationContext) :
|
|
|
274
293
|
putString("transactionId", purchase.purchaseToken)
|
|
275
294
|
putString("token", purchase.purchaseToken)
|
|
276
295
|
putDouble("occurredAt", purchase.purchaseTime.toDouble())
|
|
296
|
+
putBoolean("acknowledged", purchase.isAcknowledged)
|
|
277
297
|
}
|
|
278
298
|
}
|
|
279
299
|
|
package/dist/blocks/locale.js
CHANGED
|
@@ -191,6 +191,11 @@ function localizeBlockDoc(doc, locale) {
|
|
|
191
191
|
...doc,
|
|
192
192
|
defaultLocale: chain[0],
|
|
193
193
|
blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
|
|
194
|
+
...(doc.screens
|
|
195
|
+
? {
|
|
196
|
+
screens: doc.screens.map((screen) => screen.map((b) => localizeBlock(b, lookup))),
|
|
197
|
+
}
|
|
198
|
+
: {}),
|
|
194
199
|
};
|
|
195
200
|
}
|
|
196
201
|
const LINK_LABEL_ALIASES = {
|
package/dist/blocks/types.d.ts
CHANGED
|
@@ -112,12 +112,14 @@ export interface BlockStyle {
|
|
|
112
112
|
/** REV-262: draw a block only while its plan card is (or is not) the selected
|
|
113
113
|
* one. Ignored outside a package card — a root block is always drawn. */
|
|
114
114
|
export type BlockVisibility = "selected" | "unselected";
|
|
115
|
-
|
|
115
|
+
/** `close` dismisses the paywall; `next`/`back` move between onboarding
|
|
116
|
+
* screens and only appear inside `screens`. */
|
|
117
|
+
export type BlockAction = "close" | "next" | "back";
|
|
116
118
|
export interface TextBlock {
|
|
117
119
|
id: string;
|
|
118
120
|
type: "text";
|
|
119
121
|
text: string;
|
|
120
|
-
/**
|
|
122
|
+
/** What tapping this block does. See BlockAction. */
|
|
121
123
|
action?: BlockAction;
|
|
122
124
|
style?: BlockStyle;
|
|
123
125
|
/** REV-262: merged over `style`, field by field, while the block sits in a
|
|
@@ -134,7 +136,7 @@ export interface ImageBlock {
|
|
|
134
136
|
shape?: "rect" | "circle";
|
|
135
137
|
fit?: "cover" | "contain";
|
|
136
138
|
placeholder?: string;
|
|
137
|
-
/**
|
|
139
|
+
/** What tapping this block does. See BlockAction. */
|
|
138
140
|
action?: BlockAction;
|
|
139
141
|
style?: BlockStyle;
|
|
140
142
|
/** REV-262: merged over `style`, field by field, while the block sits in a
|
|
@@ -182,8 +184,9 @@ export interface ButtonBlock {
|
|
|
182
184
|
id: string;
|
|
183
185
|
type: "button";
|
|
184
186
|
label: string;
|
|
185
|
-
/** `close` turns this button into a dismiss ("Not now")
|
|
186
|
-
* purchase CTA, which is what a
|
|
187
|
+
/** `close` turns this button into a dismiss ("Not now"), and `next`/`back`
|
|
188
|
+
* into onboarding navigation, instead of the purchase CTA, which is what a
|
|
189
|
+
* button means by default. */
|
|
187
190
|
action?: BlockAction;
|
|
188
191
|
style?: BlockStyle;
|
|
189
192
|
/** REV-262: merged over `style`, field by field, while the block sits in a
|
|
@@ -311,6 +314,10 @@ export interface PaywallBlockDoc {
|
|
|
311
314
|
accentInk: string;
|
|
312
315
|
fontFamily?: string;
|
|
313
316
|
blocks: PaywallBlock[];
|
|
317
|
+
/** Onboarding: intro screens shown in order before `blocks`, which stays
|
|
318
|
+
* the last (purchase) screen. They share the doc's palette, layout and
|
|
319
|
+
* locale table. Absent or empty = `blocks` alone. */
|
|
320
|
+
screens?: PaywallBlock[][];
|
|
314
321
|
/** REV-271: the language the blocks' own copy is written in. Absent = "en".
|
|
315
322
|
* Never a key in a stored doc's `locales` — the tree IS that language's
|
|
316
323
|
* version. A localized copy names the language it was localized to. */
|
|
@@ -341,3 +348,11 @@ export declare const DESIGN_CANVAS: {
|
|
|
341
348
|
* bug this feature exists to fix. Two close buttons is merely ugly, so the
|
|
342
349
|
* tie breaks toward always having one. */
|
|
343
350
|
export declare function hasCloseAction(blocks: PaywallBlock[]): boolean;
|
|
351
|
+
export declare function onboardingScreen(doc: PaywallBlockDoc, index: number): {
|
|
352
|
+
blocks: PaywallBlock[];
|
|
353
|
+
dots?: {
|
|
354
|
+
count: number;
|
|
355
|
+
active: number;
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
export declare function stepScreen(doc: PaywallBlockDoc, index: number, action: BlockAction | undefined): number | undefined;
|
package/dist/blocks/types.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.DESIGN_CANVAS = void 0;
|
|
15
15
|
exports.hasCloseAction = hasCloseAction;
|
|
16
|
+
exports.onboardingScreen = onboardingScreen;
|
|
17
|
+
exports.stepScreen = stepScreen;
|
|
16
18
|
/** The device screen `canvas` designs are authored against. */
|
|
17
19
|
exports.DESIGN_CANVAS = { width: 393, height: 852 };
|
|
18
20
|
/** Does this tree author a dismiss affordance that is CERTAIN to render?
|
|
@@ -43,3 +45,21 @@ function hasCloseAction(blocks) {
|
|
|
43
45
|
: (b.type === "text" || b.type === "image" || b.type === "button") &&
|
|
44
46
|
b.action === "close");
|
|
45
47
|
}
|
|
48
|
+
function onboardingScreen(doc, index) {
|
|
49
|
+
const intro = doc.screens?.[index];
|
|
50
|
+
if (intro === undefined)
|
|
51
|
+
return { blocks: doc.blocks };
|
|
52
|
+
return {
|
|
53
|
+
blocks: intro,
|
|
54
|
+
dots: { count: doc.screens.length + 1, active: index },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function stepScreen(doc, index, action) {
|
|
58
|
+
if (index >= (doc.screens?.length ?? 0))
|
|
59
|
+
return undefined;
|
|
60
|
+
if (action === "next")
|
|
61
|
+
return index + 1;
|
|
62
|
+
if (action === "back" && index > 0)
|
|
63
|
+
return index - 1;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare class RevnixClient {
|
|
|
27
27
|
private readonly onDeferredDeepLink?;
|
|
28
28
|
private readonly onAttribution?;
|
|
29
29
|
private readonly deviceIntegrity?;
|
|
30
|
+
private readonly consumableProductIds;
|
|
30
31
|
private integrityRequest;
|
|
31
32
|
private deferredDeepLinkClaimed;
|
|
32
33
|
private landingUrl;
|
|
@@ -106,6 +107,7 @@ export declare class RevnixClient {
|
|
|
106
107
|
/** Re-registers every purchase the store still holds for this device and
|
|
107
108
|
* resolves how many registered. Per-purchase failures go to onDiagnostic. */
|
|
108
109
|
restore(): Promise<number>;
|
|
110
|
+
private isConsumable;
|
|
109
111
|
private requireStore;
|
|
110
112
|
private registerStoreTransaction;
|
|
111
113
|
private postPurchase;
|
package/dist/client.js
CHANGED
|
@@ -97,6 +97,32 @@ const pendingKey = (p) => `${p.source}:${p.token}:${p.transactionId}`;
|
|
|
97
97
|
// revoked key, blocked transfer) would be retried every launch forever;
|
|
98
98
|
// unknown non-Revnix errors count as transient to err on the keeping side.
|
|
99
99
|
const worthQueueing = (err) => !(err instanceof errors_js_1.RevnixError) || err.isRetryable;
|
|
100
|
+
const ATTRIBUTION_QUERY_PARAMS = new Set([
|
|
101
|
+
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", "campaign_id",
|
|
102
|
+
"pid", "source", "af_channel", "medium", "c", "campaign", "term", "af_ad",
|
|
103
|
+
"content", "gclid", "gbraid", "wbraid", "ttclid", "sccid", "twclid",
|
|
104
|
+
"revnix_link", "web_id",
|
|
105
|
+
]);
|
|
106
|
+
function queryParamName(pair) {
|
|
107
|
+
const raw = pair.split("=")[0] ?? "";
|
|
108
|
+
try {
|
|
109
|
+
return decodeURIComponent(raw.replace(/\+/g, " ")).toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return raw.toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function attributionOnlyUrl(rawUrl) {
|
|
116
|
+
const url = rawUrl.trim();
|
|
117
|
+
const parts = /^([a-z][a-z0-9+.-]*:)(\/\/[^/?#]*)?([^?#]*)(?:\?([^#]*))?/i.exec(url);
|
|
118
|
+
if (!parts)
|
|
119
|
+
return url.split(/[?#]/)[0] ?? "";
|
|
120
|
+
const [, scheme, authority = "", path, query = ""] = parts;
|
|
121
|
+
const kept = query
|
|
122
|
+
.split("&")
|
|
123
|
+
.filter((pair) => ATTRIBUTION_QUERY_PARAMS.has(queryParamName(pair)));
|
|
124
|
+
return `${scheme}${authority.replace(/^\/\/.*@/, "//")}${path}${kept.length > 0 ? `?${kept.join("&")}` : ""}`;
|
|
125
|
+
}
|
|
100
126
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
101
127
|
// REV-199 (CTO-R2 M24): ±20% on every scheduled delay.
|
|
102
128
|
const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
|
|
@@ -207,6 +233,7 @@ class RevnixClient {
|
|
|
207
233
|
this.onDeferredDeepLink = config.onDeferredDeepLink;
|
|
208
234
|
this.onAttribution = config.onAttribution;
|
|
209
235
|
this.deviceIntegrity = config.deviceIntegrity;
|
|
236
|
+
this.consumableProductIds = config.consumableProductIds ?? [];
|
|
210
237
|
this.readYourWritesDelaysMs =
|
|
211
238
|
config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
|
|
212
239
|
this.customerIdPromise = this.loadCustomerId(config.customerId);
|
|
@@ -217,7 +244,11 @@ class RevnixClient {
|
|
|
217
244
|
this.reportAdvertisingId(config.advertisingId);
|
|
218
245
|
const store = (0, store_js_1.getStoreAdapter)();
|
|
219
246
|
if (store) {
|
|
220
|
-
this.stopStoreObserver = store.observe((tx) =>
|
|
247
|
+
this.stopStoreObserver = store.observe((tx) => {
|
|
248
|
+
if (tx.acknowledged && !this.isConsumable(tx))
|
|
249
|
+
return;
|
|
250
|
+
void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err));
|
|
251
|
+
});
|
|
221
252
|
}
|
|
222
253
|
// REV-157: warm the placement cache at launch, so the first paywall of the
|
|
223
254
|
// session can render offline. Fire-and-forget; a failed key just stays on
|
|
@@ -378,6 +409,10 @@ class RevnixClient {
|
|
|
378
409
|
const tx = await store.purchase(productId, { customerId });
|
|
379
410
|
if (!tx)
|
|
380
411
|
return null;
|
|
412
|
+
if (tx.alreadyOwned && this.isConsumable(tx)) {
|
|
413
|
+
await this.registerStoreTransaction(store, tx, opts);
|
|
414
|
+
throw new errors_js_1.PurchaseAlreadyOwnedError(`Revnix: an earlier purchase of ${productId} was still owned and has been registered; buy again`);
|
|
415
|
+
}
|
|
381
416
|
return this.registerStoreTransaction(store, tx, opts);
|
|
382
417
|
}
|
|
383
418
|
/** Re-registers every purchase the store still holds for this device and
|
|
@@ -396,6 +431,9 @@ class RevnixClient {
|
|
|
396
431
|
}
|
|
397
432
|
return restored;
|
|
398
433
|
}
|
|
434
|
+
isConsumable(tx) {
|
|
435
|
+
return tx.store === "google" && this.consumableProductIds.includes(tx.productId);
|
|
436
|
+
}
|
|
399
437
|
requireStore() {
|
|
400
438
|
const store = (0, store_js_1.getStoreAdapter)();
|
|
401
439
|
if (!store) {
|
|
@@ -404,8 +442,8 @@ class RevnixClient {
|
|
|
404
442
|
return store;
|
|
405
443
|
}
|
|
406
444
|
async registerStoreTransaction(store, tx, opts, finish = true) {
|
|
407
|
-
const finishTx = () => finish
|
|
408
|
-
? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
|
|
445
|
+
const finishTx = (consume = false) => finish
|
|
446
|
+
? store.finish(tx.transactionId, consume).catch((err) => this.diag("storeFinish", err))
|
|
409
447
|
: undefined;
|
|
410
448
|
try {
|
|
411
449
|
const result = await this.registerPurchase({
|
|
@@ -418,7 +456,7 @@ class RevnixClient {
|
|
|
418
456
|
signedTransactionInfo: tx.signedTransactionInfo,
|
|
419
457
|
...opts,
|
|
420
458
|
});
|
|
421
|
-
await finishTx();
|
|
459
|
+
await finishTx(this.isConsumable(tx));
|
|
422
460
|
return result;
|
|
423
461
|
}
|
|
424
462
|
catch (err) {
|
|
@@ -1099,7 +1137,7 @@ class RevnixClient {
|
|
|
1099
1137
|
return;
|
|
1100
1138
|
}
|
|
1101
1139
|
void this.rememberDeepLink(url);
|
|
1102
|
-
const extra = { url: url.slice(0, 1024) };
|
|
1140
|
+
const extra = { url: attributionOnlyUrl(url).slice(0, 1024) };
|
|
1103
1141
|
const batch = this.launchBatch;
|
|
1104
1142
|
if (batch === null) {
|
|
1105
1143
|
this.fireImplicit("deeplink_open", extra);
|
package/dist/errors.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export declare class RevnixError extends Error {
|
|
|
11
11
|
export declare class PurchaseBlockedError extends RevnixError {
|
|
12
12
|
constructor(message: string);
|
|
13
13
|
}
|
|
14
|
+
/** purchase() found an earlier Google Play consumable of this product still
|
|
15
|
+
* owned. It was registered and consumed instead of charging again; ask the
|
|
16
|
+
* customer to buy again. */
|
|
17
|
+
export declare class PurchaseAlreadyOwnedError extends Error {
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
14
20
|
/** C7: the request didn't complete within the configured timeout (default
|
|
15
21
|
* 10s). `fetch` has no default timeout on React Native / browsers, so without
|
|
16
22
|
* this a slow or dead backend would hang the app until the OS TCP timeout. */
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.RevnixRateLimitError = exports.RevnixAuthError = exports.RevnixNetworkError = exports.RevnixTimeoutError = exports.PurchaseBlockedError = exports.RevnixError = void 0;
|
|
3
|
+
exports.RevnixRateLimitError = exports.RevnixAuthError = exports.RevnixNetworkError = exports.RevnixTimeoutError = exports.PurchaseAlreadyOwnedError = exports.PurchaseBlockedError = exports.RevnixError = void 0;
|
|
4
4
|
class RevnixError extends Error {
|
|
5
5
|
constructor(status, message) {
|
|
6
6
|
super(message);
|
|
@@ -24,6 +24,16 @@ class PurchaseBlockedError extends RevnixError {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
exports.PurchaseBlockedError = PurchaseBlockedError;
|
|
27
|
+
/** purchase() found an earlier Google Play consumable of this product still
|
|
28
|
+
* owned. It was registered and consumed instead of charging again; ask the
|
|
29
|
+
* customer to buy again. */
|
|
30
|
+
class PurchaseAlreadyOwnedError extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "PurchaseAlreadyOwnedError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
exports.PurchaseAlreadyOwnedError = PurchaseAlreadyOwnedError;
|
|
27
37
|
/** C7: the request didn't complete within the configured timeout (default
|
|
28
38
|
* 10s). `fetch` has no default timeout on React Native / browsers, so without
|
|
29
39
|
* this a slow or dead backend would hang the app until the OS TCP timeout. */
|
|
@@ -183,6 +183,11 @@ export function localizeBlockDoc(doc, locale) {
|
|
|
183
183
|
...doc,
|
|
184
184
|
defaultLocale: chain[0],
|
|
185
185
|
blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
|
|
186
|
+
...(doc.screens
|
|
187
|
+
? {
|
|
188
|
+
screens: doc.screens.map((screen) => screen.map((b) => localizeBlock(b, lookup))),
|
|
189
|
+
}
|
|
190
|
+
: {}),
|
|
186
191
|
};
|
|
187
192
|
}
|
|
188
193
|
const LINK_LABEL_ALIASES = {
|
package/dist/esm/blocks/types.js
CHANGED
|
@@ -39,3 +39,21 @@ export function hasCloseAction(blocks) {
|
|
|
39
39
|
: (b.type === "text" || b.type === "image" || b.type === "button") &&
|
|
40
40
|
b.action === "close");
|
|
41
41
|
}
|
|
42
|
+
export function onboardingScreen(doc, index) {
|
|
43
|
+
const intro = doc.screens?.[index];
|
|
44
|
+
if (intro === undefined)
|
|
45
|
+
return { blocks: doc.blocks };
|
|
46
|
+
return {
|
|
47
|
+
blocks: intro,
|
|
48
|
+
dots: { count: doc.screens.length + 1, active: index },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function stepScreen(doc, index, action) {
|
|
52
|
+
if (index >= (doc.screens?.length ?? 0))
|
|
53
|
+
return undefined;
|
|
54
|
+
if (action === "next")
|
|
55
|
+
return index + 1;
|
|
56
|
+
if (action === "back" && index > 0)
|
|
57
|
+
return index - 1;
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
package/dist/esm/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
|
|
1
|
+
import { PurchaseAlreadyOwnedError, PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
|
|
2
2
|
import { generateAnonymousId, generateEventId, generateViewId, resolveStorage, } from "./storage.js";
|
|
3
3
|
import { DEVICE_HEADER, INSTALLED_AT_KEY, compactPayload, detectDeviceFacts, encodeDeviceHeader, } from "./device.js";
|
|
4
4
|
import { SDK_VERSION } from "./version.js";
|
|
@@ -93,6 +93,32 @@ const pendingKey = (p) => `${p.source}:${p.token}:${p.transactionId}`;
|
|
|
93
93
|
// revoked key, blocked transfer) would be retried every launch forever;
|
|
94
94
|
// unknown non-Revnix errors count as transient to err on the keeping side.
|
|
95
95
|
const worthQueueing = (err) => !(err instanceof RevnixError) || err.isRetryable;
|
|
96
|
+
const ATTRIBUTION_QUERY_PARAMS = new Set([
|
|
97
|
+
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", "campaign_id",
|
|
98
|
+
"pid", "source", "af_channel", "medium", "c", "campaign", "term", "af_ad",
|
|
99
|
+
"content", "gclid", "gbraid", "wbraid", "ttclid", "sccid", "twclid",
|
|
100
|
+
"revnix_link", "web_id",
|
|
101
|
+
]);
|
|
102
|
+
function queryParamName(pair) {
|
|
103
|
+
const raw = pair.split("=")[0] ?? "";
|
|
104
|
+
try {
|
|
105
|
+
return decodeURIComponent(raw.replace(/\+/g, " ")).toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return raw.toLowerCase();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function attributionOnlyUrl(rawUrl) {
|
|
112
|
+
const url = rawUrl.trim();
|
|
113
|
+
const parts = /^([a-z][a-z0-9+.-]*:)(\/\/[^/?#]*)?([^?#]*)(?:\?([^#]*))?/i.exec(url);
|
|
114
|
+
if (!parts)
|
|
115
|
+
return url.split(/[?#]/)[0] ?? "";
|
|
116
|
+
const [, scheme, authority = "", path, query = ""] = parts;
|
|
117
|
+
const kept = query
|
|
118
|
+
.split("&")
|
|
119
|
+
.filter((pair) => ATTRIBUTION_QUERY_PARAMS.has(queryParamName(pair)));
|
|
120
|
+
return `${scheme}${authority.replace(/^\/\/.*@/, "//")}${path}${kept.length > 0 ? `?${kept.join("&")}` : ""}`;
|
|
121
|
+
}
|
|
96
122
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
97
123
|
// REV-199 (CTO-R2 M24): ±20% on every scheduled delay.
|
|
98
124
|
const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
|
|
@@ -203,6 +229,7 @@ export class RevnixClient {
|
|
|
203
229
|
this.onDeferredDeepLink = config.onDeferredDeepLink;
|
|
204
230
|
this.onAttribution = config.onAttribution;
|
|
205
231
|
this.deviceIntegrity = config.deviceIntegrity;
|
|
232
|
+
this.consumableProductIds = config.consumableProductIds ?? [];
|
|
206
233
|
this.readYourWritesDelaysMs =
|
|
207
234
|
config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
|
|
208
235
|
this.customerIdPromise = this.loadCustomerId(config.customerId);
|
|
@@ -213,7 +240,11 @@ export class RevnixClient {
|
|
|
213
240
|
this.reportAdvertisingId(config.advertisingId);
|
|
214
241
|
const store = getStoreAdapter();
|
|
215
242
|
if (store) {
|
|
216
|
-
this.stopStoreObserver = store.observe((tx) =>
|
|
243
|
+
this.stopStoreObserver = store.observe((tx) => {
|
|
244
|
+
if (tx.acknowledged && !this.isConsumable(tx))
|
|
245
|
+
return;
|
|
246
|
+
void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err));
|
|
247
|
+
});
|
|
217
248
|
}
|
|
218
249
|
// REV-157: warm the placement cache at launch, so the first paywall of the
|
|
219
250
|
// session can render offline. Fire-and-forget; a failed key just stays on
|
|
@@ -374,6 +405,10 @@ export class RevnixClient {
|
|
|
374
405
|
const tx = await store.purchase(productId, { customerId });
|
|
375
406
|
if (!tx)
|
|
376
407
|
return null;
|
|
408
|
+
if (tx.alreadyOwned && this.isConsumable(tx)) {
|
|
409
|
+
await this.registerStoreTransaction(store, tx, opts);
|
|
410
|
+
throw new PurchaseAlreadyOwnedError(`Revnix: an earlier purchase of ${productId} was still owned and has been registered; buy again`);
|
|
411
|
+
}
|
|
377
412
|
return this.registerStoreTransaction(store, tx, opts);
|
|
378
413
|
}
|
|
379
414
|
/** Re-registers every purchase the store still holds for this device and
|
|
@@ -392,6 +427,9 @@ export class RevnixClient {
|
|
|
392
427
|
}
|
|
393
428
|
return restored;
|
|
394
429
|
}
|
|
430
|
+
isConsumable(tx) {
|
|
431
|
+
return tx.store === "google" && this.consumableProductIds.includes(tx.productId);
|
|
432
|
+
}
|
|
395
433
|
requireStore() {
|
|
396
434
|
const store = getStoreAdapter();
|
|
397
435
|
if (!store) {
|
|
@@ -400,8 +438,8 @@ export class RevnixClient {
|
|
|
400
438
|
return store;
|
|
401
439
|
}
|
|
402
440
|
async registerStoreTransaction(store, tx, opts, finish = true) {
|
|
403
|
-
const finishTx = () => finish
|
|
404
|
-
? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
|
|
441
|
+
const finishTx = (consume = false) => finish
|
|
442
|
+
? store.finish(tx.transactionId, consume).catch((err) => this.diag("storeFinish", err))
|
|
405
443
|
: undefined;
|
|
406
444
|
try {
|
|
407
445
|
const result = await this.registerPurchase({
|
|
@@ -414,7 +452,7 @@ export class RevnixClient {
|
|
|
414
452
|
signedTransactionInfo: tx.signedTransactionInfo,
|
|
415
453
|
...opts,
|
|
416
454
|
});
|
|
417
|
-
await finishTx();
|
|
455
|
+
await finishTx(this.isConsumable(tx));
|
|
418
456
|
return result;
|
|
419
457
|
}
|
|
420
458
|
catch (err) {
|
|
@@ -1095,7 +1133,7 @@ export class RevnixClient {
|
|
|
1095
1133
|
return;
|
|
1096
1134
|
}
|
|
1097
1135
|
void this.rememberDeepLink(url);
|
|
1098
|
-
const extra = { url: url.slice(0, 1024) };
|
|
1136
|
+
const extra = { url: attributionOnlyUrl(url).slice(0, 1024) };
|
|
1099
1137
|
const batch = this.launchBatch;
|
|
1100
1138
|
if (batch === null) {
|
|
1101
1139
|
this.fireImplicit("deeplink_open", extra);
|
package/dist/esm/errors.js
CHANGED
|
@@ -19,6 +19,15 @@ export class PurchaseBlockedError extends RevnixError {
|
|
|
19
19
|
this.name = "PurchaseBlockedError";
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
/** purchase() found an earlier Google Play consumable of this product still
|
|
23
|
+
* owned. It was registered and consumed instead of charging again; ask the
|
|
24
|
+
* customer to buy again. */
|
|
25
|
+
export class PurchaseAlreadyOwnedError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "PurchaseAlreadyOwnedError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
22
31
|
/** C7: the request didn't complete within the configured timeout (default
|
|
23
32
|
* 10s). `fetch` has no default timeout on React Native / browsers, so without
|
|
24
33
|
* this a slow or dead backend would hang the app until the OS TCP timeout. */
|
package/dist/esm/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { RevnixClient } from "./client.js";
|
|
2
2
|
import { setLocale } from "./blocks/locale.js";
|
|
3
3
|
export { RevnixClient } from "./client.js";
|
|
4
|
-
export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
|
|
4
|
+
export { PurchaseAlreadyOwnedError, PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
|
|
5
5
|
export { purchaseInputFromIap } from "./iap.js";
|
|
6
6
|
export { setStoreAdapter, } from "./store.js";
|
|
7
7
|
export { memoryStorage, generateAnonymousId } from "./storage.js";
|
package/dist/esm/store/index.js
CHANGED
|
@@ -6,7 +6,7 @@ export const RevnixStore = native
|
|
|
6
6
|
? {
|
|
7
7
|
purchase: (productId, { customerId }) => native.purchase(productId, customerId),
|
|
8
8
|
restore: () => native.restore(),
|
|
9
|
-
finish: (transactionId) => native.finish(transactionId),
|
|
9
|
+
finish: (transactionId, consume) => native.finish(transactionId, consume === true),
|
|
10
10
|
observe(onTransaction) {
|
|
11
11
|
const sub = new NativeEventEmitter(NativeModules.RevnixStore).addListener("revnixStoreTransaction", (tx) => onTransaction(tx));
|
|
12
12
|
return () => sub.remove();
|