react-native-revnix 1.6.0 → 1.8.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 +16 -0
- package/android/build.gradle +3 -0
- package/android/consumer-rules.pro +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
- package/dist/blocks/locale.js +12 -0
- package/dist/blocks/selection.d.ts +7 -1
- package/dist/blocks/selection.js +9 -0
- package/dist/blocks/tags.d.ts +3 -0
- package/dist/blocks/tags.js +33 -0
- package/dist/blocks/types.d.ts +65 -8
- package/dist/blocks/types.js +21 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +73 -5
- package/dist/esm/blocks/locale.js +12 -0
- package/dist/esm/blocks/selection.js +8 -0
- package/dist/esm/blocks/tags.js +31 -0
- package/dist/esm/blocks/types.js +19 -0
- package/dist/esm/client.js +73 -5
- package/dist/esm/store/index.js +7 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +145 -44
- package/dist/esm/ui/RevnixPaywall.js +11 -2
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/store/index.d.ts +8 -0
- package/dist/store/index.js +10 -1
- package/dist/types.d.ts +32 -0
- package/dist/ui/RevnixBlockPaywall.js +141 -40
- package/dist/ui/RevnixPaywall.js +11 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/RevnixStore.m +4 -0
- package/ios/RevnixStore.swift +29 -0
- package/package.json +1 -1
- package/src/blocks/locale.ts +14 -0
- package/src/blocks/selection.ts +13 -0
- package/src/blocks/tags.ts +38 -0
- package/src/blocks/types.ts +86 -7
- package/src/client.ts +90 -8
- package/src/index.ts +1 -0
- package/src/store/index.ts +15 -0
- package/src/types.ts +30 -0
- package/src/ui/RevnixBlockPaywall.tsx +265 -42
- package/src/ui/RevnixPaywall.tsx +11 -2
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -145,6 +145,22 @@ Learn more in [A/B tests](https://www.revnix.io/docs/ab-tests).
|
|
|
145
145
|
- **MMP forwarding** to AppsFlyer and Adjust, **ad revenue** logging and **uninstall measurement**. See [MMP attribution](https://www.revnix.io/docs/mmp-attribution), [Ad revenue](https://www.revnix.io/docs/ad-revenue) and [Uninstall measurement](https://www.revnix.io/docs/uninstall-measurement).
|
|
146
146
|
- **Fraud prevention**: anonymous-IP and click-injection checks keep paid channels honest. See [Fraud prevention](https://www.revnix.io/docs/fraud-prevention).
|
|
147
147
|
|
|
148
|
+
### Device integrity
|
|
149
|
+
|
|
150
|
+
Pass `nativeDeviceIntegrity` so install reports carry Apple App Attest or Google Play Integrity evidence. With "Require device integrity" on in the dashboard, installs without it lose their attribution.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { nativeDeviceIntegrity } from "react-native-revnix/store";
|
|
154
|
+
|
|
155
|
+
Revnix.configure({
|
|
156
|
+
apiKey: "rvx_pk_live_…",
|
|
157
|
+
baseUrl: "https://<your-deployment>.convex.site",
|
|
158
|
+
deviceIntegrity: nativeDeviceIntegrity,
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
It runs once per customer, waits at most 10 seconds and never blocks the report. Needs App Attest on iOS or Google Play services on Android; simulators and emulators send no evidence.
|
|
163
|
+
|
|
148
164
|
## Real-time analytics for your React Native app
|
|
149
165
|
|
|
150
166
|

|
package/android/build.gradle
CHANGED
|
@@ -11,6 +11,7 @@ android {
|
|
|
11
11
|
|
|
12
12
|
defaultConfig {
|
|
13
13
|
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
14
|
+
consumerProguardFiles "consumer-rules.pro"
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
compileOptions {
|
|
@@ -31,4 +32,6 @@ repositories {
|
|
|
31
32
|
dependencies {
|
|
32
33
|
implementation "com.facebook.react:react-native:+"
|
|
33
34
|
implementation "com.android.billingclient:billing:8.3.0"
|
|
35
|
+
implementation "com.google.android.play:integrity:1.6.0"
|
|
36
|
+
compileOnly "com.google.android.gms:play-services-ads-identifier:18.2.0"
|
|
34
37
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
-dontwarn com.google.android.gms.ads.identifier.**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.revnix.reactnative
|
|
2
2
|
|
|
3
|
+
import android.util.Base64
|
|
3
4
|
import android.util.Log
|
|
4
5
|
import com.android.billingclient.api.AcknowledgePurchaseParams
|
|
5
6
|
import com.android.billingclient.api.BillingClient
|
|
@@ -21,6 +22,10 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
|
21
22
|
import com.facebook.react.bridge.ReactMethod
|
|
22
23
|
import com.facebook.react.bridge.WritableMap
|
|
23
24
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
25
|
+
import com.google.android.gms.ads.identifier.AdvertisingIdClient
|
|
26
|
+
import com.google.android.play.core.integrity.IntegrityManagerFactory
|
|
27
|
+
import com.google.android.play.core.integrity.IntegrityTokenRequest
|
|
28
|
+
import java.security.MessageDigest
|
|
24
29
|
import java.util.concurrent.ConcurrentHashMap
|
|
25
30
|
import java.util.concurrent.atomic.AtomicReference
|
|
26
31
|
|
|
@@ -115,6 +120,44 @@ class RevnixStoreModule(context: ReactApplicationContext) :
|
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
@ReactMethod
|
|
124
|
+
fun deviceIntegrity(customerId: String, promise: Promise) {
|
|
125
|
+
val nonce = Base64.encodeToString(
|
|
126
|
+
MessageDigest.getInstance("SHA-256").digest(customerId.toByteArray(Charsets.UTF_8)),
|
|
127
|
+
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP,
|
|
128
|
+
)
|
|
129
|
+
try {
|
|
130
|
+
IntegrityManagerFactory.create(reactApplicationContext.applicationContext)
|
|
131
|
+
.requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build())
|
|
132
|
+
.addOnSuccessListener { response ->
|
|
133
|
+
promise.resolve(
|
|
134
|
+
Arguments.createMap().apply {
|
|
135
|
+
putString("platform", "android")
|
|
136
|
+
putString("token", response.token())
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
.addOnFailureListener { promise.resolve(null) }
|
|
141
|
+
.addOnCanceledListener { promise.resolve(null) }
|
|
142
|
+
} catch (err: Exception) {
|
|
143
|
+
promise.resolve(null)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@ReactMethod
|
|
148
|
+
fun advertisingId(promise: Promise) {
|
|
149
|
+
Thread {
|
|
150
|
+
try {
|
|
151
|
+
val info = AdvertisingIdClient.getAdvertisingIdInfo(reactApplicationContext.applicationContext)
|
|
152
|
+
promise.resolve(
|
|
153
|
+
info.id.takeUnless { info.isLimitAdTrackingEnabled || it == "00000000-0000-0000-0000-000000000000" },
|
|
154
|
+
)
|
|
155
|
+
} catch (err: Throwable) {
|
|
156
|
+
promise.reject("advertising_id_unavailable", err.message, err)
|
|
157
|
+
}
|
|
158
|
+
}.start()
|
|
159
|
+
}
|
|
160
|
+
|
|
118
161
|
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
|
|
119
162
|
if (result.responseCode != BillingResponseCode.OK) {
|
|
120
163
|
settle(pending.getAndSet(null) ?: return, result)
|
package/dist/blocks/locale.js
CHANGED
|
@@ -123,7 +123,14 @@ function localizeBlock(block, lookup) {
|
|
|
123
123
|
case "text":
|
|
124
124
|
return { ...block, text: at("text", block.text) };
|
|
125
125
|
case "button":
|
|
126
|
+
case "toggle":
|
|
126
127
|
return { ...block, label: at("label", block.label) };
|
|
128
|
+
case "timer":
|
|
129
|
+
return {
|
|
130
|
+
...block,
|
|
131
|
+
format: opt("format", block.format),
|
|
132
|
+
expiredText: opt("expiredText", block.expiredText),
|
|
133
|
+
};
|
|
127
134
|
case "list":
|
|
128
135
|
return {
|
|
129
136
|
...block,
|
|
@@ -184,6 +191,11 @@ function localizeBlockDoc(doc, locale) {
|
|
|
184
191
|
...doc,
|
|
185
192
|
defaultLocale: chain[0],
|
|
186
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
|
+
: {}),
|
|
187
199
|
};
|
|
188
200
|
}
|
|
189
201
|
const LINK_LABEL_ALIASES = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BlockStyle, PaywallBlock, PaywallBlockDoc } from "./types.js";
|
|
1
|
+
import type { BlockStyle, PaywallBlock, PaywallBlockDoc, ToggleBlock } from "./types.js";
|
|
2
2
|
import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
|
|
3
3
|
/** Where a block sits: inside the selected plan card, inside another plan
|
|
4
4
|
* card, or outside any plan card (undefined). */
|
|
@@ -9,6 +9,12 @@ export type SelectionContext = "selected" | "unselected" | undefined;
|
|
|
9
9
|
export declare function selectedPackageId(packages: ReadonlyArray<{
|
|
10
10
|
packageId: string;
|
|
11
11
|
}>, ...candidates: Array<string | undefined>): string | undefined;
|
|
12
|
+
export declare function toggleSelection(block: ToggleBlock, packages: ReadonlyArray<{
|
|
13
|
+
packageId: string;
|
|
14
|
+
}>, selectedId: string | undefined): {
|
|
15
|
+
on: boolean;
|
|
16
|
+
target: string;
|
|
17
|
+
} | null;
|
|
12
18
|
/** The context a package-bearing card opens for itself and its subtree. */
|
|
13
19
|
export declare function packageContext(pkg: {
|
|
14
20
|
packageId: string;
|
package/dist/blocks/selection.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.CANVAS_MAX_WIDTH = void 0;
|
|
11
11
|
exports.selectedPackageId = selectedPackageId;
|
|
12
|
+
exports.toggleSelection = toggleSelection;
|
|
12
13
|
exports.packageContext = packageContext;
|
|
13
14
|
exports.effectiveStyle = effectiveStyle;
|
|
14
15
|
exports.isVisible = isVisible;
|
|
@@ -26,6 +27,14 @@ function selectedPackageId(packages, ...candidates) {
|
|
|
26
27
|
}
|
|
27
28
|
return packages[0]?.packageId;
|
|
28
29
|
}
|
|
30
|
+
function toggleSelection(block, packages, selectedId) {
|
|
31
|
+
const on = packages[block.onPackageIndex];
|
|
32
|
+
const off = packages[block.offPackageIndex];
|
|
33
|
+
if (on === undefined || off === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
const isOn = on.packageId === selectedId;
|
|
36
|
+
return { on: isOn, target: (isOn ? off : on).packageId };
|
|
37
|
+
}
|
|
29
38
|
/** The context a package-bearing card opens for itself and its subtree. */
|
|
30
39
|
function packageContext(pkg, selectedId) {
|
|
31
40
|
if (pkg === undefined)
|
package/dist/blocks/tags.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
|
|
2
|
+
import type { TimerBlock } from "./types.js";
|
|
2
3
|
/** Minor units per major unit. Not every currency is a hundredth: JPY and KRW
|
|
3
4
|
* have no minor unit at all, so dividing by 100 would understate a price by
|
|
4
5
|
* 100×. Intl knows each currency's exponent — ask it rather than assume.
|
|
@@ -10,3 +11,5 @@ export declare function minorUnitsForCurrency(currency: string): number;
|
|
|
10
11
|
/** Fills a copy template from one package. `all` is the rest of the offering,
|
|
11
12
|
* which {save_percent} needs to have something to compare against. */
|
|
12
13
|
export declare function resolveTags(text: string, pkg: RevnixPaywallPackage | undefined, all?: RevnixPaywallPackage[]): string;
|
|
14
|
+
export declare function formatCountdown(format: string, remaining: number): string;
|
|
15
|
+
export declare function timerText(block: TimerBlock, shownAt: number, now: number): string | null;
|
package/dist/blocks/tags.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.minorUnitsForCurrency = minorUnitsForCurrency;
|
|
15
15
|
exports.resolveTags = resolveTags;
|
|
16
|
+
exports.formatCountdown = formatCountdown;
|
|
17
|
+
exports.timerText = timerText;
|
|
16
18
|
/** Renewal cycles, in months. Lifetime and one-time products have no cycle. */
|
|
17
19
|
const MONTHS = {
|
|
18
20
|
weekly: 1 / 4.345,
|
|
@@ -119,3 +121,34 @@ function resolveTags(text, pkg, all = []) {
|
|
|
119
121
|
}
|
|
120
122
|
});
|
|
121
123
|
}
|
|
124
|
+
function formatCountdown(format, remaining) {
|
|
125
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
126
|
+
let span = Infinity;
|
|
127
|
+
const part = (token, unit) => {
|
|
128
|
+
const value = Math.floor((remaining % span) / unit);
|
|
129
|
+
if (format.includes(token))
|
|
130
|
+
span = unit;
|
|
131
|
+
return value;
|
|
132
|
+
};
|
|
133
|
+
const days = part("{d}", 86400);
|
|
134
|
+
const hours = part("{hh}", 3600);
|
|
135
|
+
const minutes = part("{mm}", 60);
|
|
136
|
+
const seconds = part("{ss}", 1);
|
|
137
|
+
return format
|
|
138
|
+
.replace(/\{d\}/g, String(days))
|
|
139
|
+
.replace(/\{hh\}/g, pad(hours))
|
|
140
|
+
.replace(/\{mm\}/g, pad(minutes))
|
|
141
|
+
.replace(/\{ss\}/g, pad(seconds));
|
|
142
|
+
}
|
|
143
|
+
function timerText(block, shownAt, now) {
|
|
144
|
+
const deadline = block.endsAt !== undefined
|
|
145
|
+
? Date.parse(block.endsAt)
|
|
146
|
+
: shownAt + (block.duration ?? 0) * 1000;
|
|
147
|
+
const remaining = Number.isNaN(deadline)
|
|
148
|
+
? 0
|
|
149
|
+
: Math.max(0, Math.floor((deadline - now) / 1000));
|
|
150
|
+
if (remaining > 0) {
|
|
151
|
+
return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
|
|
152
|
+
}
|
|
153
|
+
return block.expiredText ?? null;
|
|
154
|
+
}
|
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
|
|
@@ -229,13 +232,55 @@ export interface SpacerBlock {
|
|
|
229
232
|
selectedStyle?: BlockStyle;
|
|
230
233
|
visibility?: BlockVisibility;
|
|
231
234
|
}
|
|
235
|
+
/** Countdown text. `endsAt` is one deadline for everyone; `duration` counts
|
|
236
|
+
* from the moment this paywall view opened. At zero it draws `expiredText`,
|
|
237
|
+
* or nothing when that is absent. Tokens in `format`: {d} {hh} {mm} {ss}. */
|
|
238
|
+
export interface TimerBlock {
|
|
239
|
+
id: string;
|
|
240
|
+
type: "timer";
|
|
241
|
+
endsAt?: string;
|
|
242
|
+
duration?: number;
|
|
243
|
+
format?: string;
|
|
244
|
+
expiredText?: string;
|
|
245
|
+
style?: BlockStyle;
|
|
246
|
+
selectedStyle?: BlockStyle;
|
|
247
|
+
visibility?: BlockVisibility;
|
|
248
|
+
}
|
|
249
|
+
/** A muted looping clip. React Native has no built-in player, so this SDK
|
|
250
|
+
* draws `poster` in its place, sized like an image block. */
|
|
251
|
+
export interface VideoBlock {
|
|
252
|
+
id: string;
|
|
253
|
+
type: "video";
|
|
254
|
+
url: string;
|
|
255
|
+
poster?: string;
|
|
256
|
+
fit?: "cover" | "contain";
|
|
257
|
+
style?: BlockStyle;
|
|
258
|
+
selectedStyle?: BlockStyle;
|
|
259
|
+
visibility?: BlockVisibility;
|
|
260
|
+
}
|
|
261
|
+
/** The free-trial switch: ON while package `onPackageIndex` is selected, and
|
|
262
|
+
* tapping it selects the other one. Hidden when the offering lacks either
|
|
263
|
+
* index. */
|
|
264
|
+
export interface ToggleBlock {
|
|
265
|
+
id: string;
|
|
266
|
+
type: "toggle";
|
|
267
|
+
label: string;
|
|
268
|
+
onPackageIndex: number;
|
|
269
|
+
offPackageIndex: number;
|
|
270
|
+
style?: BlockStyle;
|
|
271
|
+
selectedStyle?: BlockStyle;
|
|
272
|
+
visibility?: BlockVisibility;
|
|
273
|
+
}
|
|
232
274
|
/** The one container block. `layout` picks how children are placed: column /
|
|
233
275
|
* row are flex lines, `stack` layers them (children position with
|
|
234
|
-
* style.inset or the edge offsets),
|
|
276
|
+
* style.inset or the edge offsets), `grid` is an N-column grid, and
|
|
277
|
+
* `carousel` pages through its children one swipe at a time. */
|
|
235
278
|
export interface CardBlock {
|
|
236
279
|
id: string;
|
|
237
280
|
type: "card";
|
|
238
|
-
layout?: "column" | "row" | "stack" | "grid";
|
|
281
|
+
layout?: "column" | "row" | "stack" | "grid" | "carousel";
|
|
282
|
+
/** carousel only: seconds per page before it moves on by itself. */
|
|
283
|
+
autoAdvance?: number;
|
|
239
284
|
/** Renders this container once per package in the attached offering. Its
|
|
240
285
|
* subtree reads the package through tag variables. */
|
|
241
286
|
repeat?: "packages";
|
|
@@ -254,7 +299,7 @@ export interface CardBlock {
|
|
|
254
299
|
selectedStyle?: BlockStyle;
|
|
255
300
|
visibility?: BlockVisibility;
|
|
256
301
|
}
|
|
257
|
-
export type PaywallBlock = TextBlock | ImageBlock | ListBlock | ProductsBlock | ButtonBlock | LinksBlock | LineBlock | SpacerBlock | CardBlock;
|
|
302
|
+
export type PaywallBlock = TextBlock | ImageBlock | ListBlock | ProductsBlock | ButtonBlock | LinksBlock | LineBlock | SpacerBlock | TimerBlock | VideoBlock | ToggleBlock | CardBlock;
|
|
258
303
|
/** The published document: screen palette plus the block tree. */
|
|
259
304
|
export interface PaywallBlockDoc {
|
|
260
305
|
version: number;
|
|
@@ -269,6 +314,10 @@ export interface PaywallBlockDoc {
|
|
|
269
314
|
accentInk: string;
|
|
270
315
|
fontFamily?: string;
|
|
271
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[][];
|
|
272
321
|
/** REV-271: the language the blocks' own copy is written in. Absent = "en".
|
|
273
322
|
* Never a key in a stored doc's `locales` — the tree IS that language's
|
|
274
323
|
* version. A localized copy names the language it was localized to. */
|
|
@@ -299,3 +348,11 @@ export declare const DESIGN_CANVAS: {
|
|
|
299
348
|
* bug this feature exists to fix. Two close buttons is merely ugly, so the
|
|
300
349
|
* tie breaks toward always having one. */
|
|
301
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?
|
|
@@ -38,7 +40,26 @@ function hasCloseAction(blocks) {
|
|
|
38
40
|
: b.type === "card"
|
|
39
41
|
? b.repeat === undefined &&
|
|
40
42
|
b.packageIndex === undefined &&
|
|
43
|
+
b.layout !== "carousel" &&
|
|
41
44
|
hasCloseAction(b.children)
|
|
42
45
|
: (b.type === "text" || b.type === "image" || b.type === "button") &&
|
|
43
46
|
b.action === "close");
|
|
44
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
|
@@ -26,6 +26,8 @@ export declare class RevnixClient {
|
|
|
26
26
|
private readonly onImplicitPaywall?;
|
|
27
27
|
private readonly onDeferredDeepLink?;
|
|
28
28
|
private readonly onAttribution?;
|
|
29
|
+
private readonly deviceIntegrity?;
|
|
30
|
+
private integrityRequest;
|
|
29
31
|
private deferredDeepLinkClaimed;
|
|
30
32
|
private landingUrl;
|
|
31
33
|
private lastDeepLinkWrite;
|
|
@@ -279,6 +281,7 @@ export declare class RevnixClient {
|
|
|
279
281
|
* report the token dead. iOS/Android only (no-op on web),
|
|
280
282
|
* fire-and-forget. */
|
|
281
283
|
setPushToken(token: string): void;
|
|
284
|
+
private reportAdvertisingId;
|
|
282
285
|
/** REV-272: the two moments that happen ON a paywall, with the loop guard
|
|
283
286
|
* applied. `fromPaywallId` travels so the server can refuse to hand back
|
|
284
287
|
* the very paywall being dismissed — the backstop for a host that reports
|
|
@@ -416,6 +419,7 @@ export declare class RevnixClient {
|
|
|
416
419
|
/** Fire-and-forget install report (REV-093). Never blocks startup and
|
|
417
420
|
* never throws — the backend absorbs retries idempotently per id. */
|
|
418
421
|
private reportInstall;
|
|
422
|
+
private integrityFor;
|
|
419
423
|
/** REV-268: assemble and encode the device facts once. Runtime-detected
|
|
420
424
|
* facts first, the app's `device` over them, then the three the SDK owns.
|
|
421
425
|
* `installedAt` is the first launch this storage ever saw — written on
|
package/dist/client.js
CHANGED
|
@@ -19,9 +19,11 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
|
|
|
19
19
|
const PLACEMENTS_CACHE_KEY = "revnix.placements";
|
|
20
20
|
const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
|
|
21
21
|
const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
|
|
22
|
+
const LAST_AD_ID_KEY = "revnix.lastAdId";
|
|
22
23
|
const EVENT_QUEUE_KEY = "revnix.eventQueue";
|
|
23
24
|
const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
|
|
24
25
|
const DEFAULT_TIMEOUT_MS = 10000;
|
|
26
|
+
const INTEGRITY_TIMEOUT_MS = 10000;
|
|
25
27
|
const EVENT_QUEUE_CAP = 1000;
|
|
26
28
|
const EVENT_BATCH_SIZE = 100;
|
|
27
29
|
const EVENT_FLUSH_THRESHOLD = 20;
|
|
@@ -102,6 +104,19 @@ const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
|
|
|
102
104
|
// from memory. Short enough that a webhook-driven change is picked up within
|
|
103
105
|
// half a minute; long enough that gating five screens costs one request.
|
|
104
106
|
const DEFAULT_ENTITLEMENTS_TTL_MS = 30000;
|
|
107
|
+
const fitsWithin = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
|
|
108
|
+
function wellFormedIntegrity(evidence) {
|
|
109
|
+
if (typeof evidence !== "object" || evidence === null)
|
|
110
|
+
return undefined;
|
|
111
|
+
const { platform, keyId, attestation, token } = evidence;
|
|
112
|
+
if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16384)) {
|
|
113
|
+
return { platform, keyId, attestation };
|
|
114
|
+
}
|
|
115
|
+
if (platform === "android" && fitsWithin(token, 16384)) {
|
|
116
|
+
return { platform, token };
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
105
120
|
class RevnixClient {
|
|
106
121
|
constructor(config) {
|
|
107
122
|
/** True while the current id is a generated anon id living on
|
|
@@ -114,6 +129,7 @@ class RevnixClient {
|
|
|
114
129
|
// client (dev fast-refresh re-running configure()) does not keep retrying
|
|
115
130
|
// and prefetching alongside its successor.
|
|
116
131
|
this.disposed = false;
|
|
132
|
+
this.integrityRequest = null;
|
|
117
133
|
this.deferredDeepLinkClaimed = false;
|
|
118
134
|
this.landingUrl = null;
|
|
119
135
|
this.lastDeepLinkWrite = Promise.resolve();
|
|
@@ -190,12 +206,15 @@ class RevnixClient {
|
|
|
190
206
|
this.onDiagnostic = config.onDiagnostic;
|
|
191
207
|
this.onDeferredDeepLink = config.onDeferredDeepLink;
|
|
192
208
|
this.onAttribution = config.onAttribution;
|
|
209
|
+
this.deviceIntegrity = config.deviceIntegrity;
|
|
193
210
|
this.readYourWritesDelaysMs =
|
|
194
211
|
config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
|
|
195
212
|
this.customerIdPromise = this.loadCustomerId(config.customerId);
|
|
196
213
|
// C7: retry any purchases that failed to register on a previous launch, so
|
|
197
214
|
// a paying user never stays locked out because a POST didn't land.
|
|
198
215
|
void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
|
|
216
|
+
if (config.advertisingId)
|
|
217
|
+
this.reportAdvertisingId(config.advertisingId);
|
|
199
218
|
const store = (0, store_js_1.getStoreAdapter)();
|
|
200
219
|
if (store) {
|
|
201
220
|
this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
|
|
@@ -971,6 +990,7 @@ class RevnixClient {
|
|
|
971
990
|
...(attribution.campaign ? { campaign: attribution.campaign } : {}),
|
|
972
991
|
...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
|
|
973
992
|
...(attribution.creative ? { creative: attribution.creative } : {}),
|
|
993
|
+
...(await this.integrityFor(customerId)),
|
|
974
994
|
});
|
|
975
995
|
await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
|
|
976
996
|
})
|
|
@@ -1004,6 +1024,21 @@ class RevnixClient {
|
|
|
1004
1024
|
})
|
|
1005
1025
|
.catch((err) => this.diag("setPushToken", err));
|
|
1006
1026
|
}
|
|
1027
|
+
reportAdvertisingId(source) {
|
|
1028
|
+
void Promise.resolve()
|
|
1029
|
+
.then(source)
|
|
1030
|
+
.then(async (value) => {
|
|
1031
|
+
const id = value || null;
|
|
1032
|
+
const customerId = await this.customerIdPromise;
|
|
1033
|
+
const payload = JSON.stringify({ customerId, id });
|
|
1034
|
+
const last = await this.storage.getItem(LAST_AD_ID_KEY);
|
|
1035
|
+
if (last === payload || (id === null && last === null))
|
|
1036
|
+
return;
|
|
1037
|
+
await this.setAttributes({ gps_adid: id });
|
|
1038
|
+
await this.storage.setItem(LAST_AD_ID_KEY, payload);
|
|
1039
|
+
})
|
|
1040
|
+
.catch((err) => this.diag("advertisingId", err));
|
|
1041
|
+
}
|
|
1007
1042
|
/** REV-272: the two moments that happen ON a paywall, with the loop guard
|
|
1008
1043
|
* applied. `fromPaywallId` travels so the server can refuse to hand back
|
|
1009
1044
|
* the very paywall being dismissed — the backstop for a host that reports
|
|
@@ -1226,12 +1261,13 @@ class RevnixClient {
|
|
|
1226
1261
|
if (this.ephemeralAnonId)
|
|
1227
1262
|
return;
|
|
1228
1263
|
void this.customerIdPromise
|
|
1229
|
-
.then((customerId) => this.request("POST", "/v1/installs", {
|
|
1264
|
+
.then(async (customerId) => this.request("POST", "/v1/installs", {
|
|
1230
1265
|
customerId,
|
|
1231
1266
|
occurredAt: Date.now(),
|
|
1232
1267
|
sdkVersion: version_js_1.SDK_VERSION,
|
|
1233
1268
|
installReferrer: referrer.slice(0, 1024),
|
|
1234
1269
|
...this.installMetadata,
|
|
1270
|
+
...(await this.integrityFor(customerId)),
|
|
1235
1271
|
}))
|
|
1236
1272
|
.then((body) => {
|
|
1237
1273
|
this.refreshAttribution();
|
|
@@ -1266,12 +1302,13 @@ class RevnixClient {
|
|
|
1266
1302
|
if (this.ephemeralAnonId)
|
|
1267
1303
|
return Promise.resolve(undefined);
|
|
1268
1304
|
return this.customerIdPromise
|
|
1269
|
-
.then((customerId) => this.request("POST", "/v1/installs", {
|
|
1305
|
+
.then(async (customerId) => this.request("POST", "/v1/installs", {
|
|
1270
1306
|
customerId,
|
|
1271
1307
|
occurredAt: Date.now(),
|
|
1272
1308
|
sdkVersion: version_js_1.SDK_VERSION,
|
|
1273
1309
|
attributionToken: token.slice(0, 2048),
|
|
1274
1310
|
...this.installMetadata,
|
|
1311
|
+
...(await this.integrityFor(customerId)),
|
|
1275
1312
|
}))
|
|
1276
1313
|
.then((body) => {
|
|
1277
1314
|
this.refreshAttribution();
|
|
@@ -1604,12 +1641,15 @@ class RevnixClient {
|
|
|
1604
1641
|
/** Fire-and-forget install report (REV-093). Never blocks startup and
|
|
1605
1642
|
* never throws — the backend absorbs retries idempotently per id. */
|
|
1606
1643
|
reportInstall(customerId) {
|
|
1607
|
-
|
|
1644
|
+
const occurredAt = Date.now();
|
|
1645
|
+
void this.integrityFor(customerId)
|
|
1646
|
+
.then((integrity) => this.request("POST", "/v1/installs", {
|
|
1608
1647
|
customerId,
|
|
1609
|
-
occurredAt
|
|
1648
|
+
occurredAt,
|
|
1610
1649
|
sdkVersion: version_js_1.SDK_VERSION,
|
|
1611
1650
|
...this.installMetadata,
|
|
1612
|
-
|
|
1651
|
+
...integrity,
|
|
1652
|
+
}))
|
|
1613
1653
|
.then((body) => {
|
|
1614
1654
|
if (!this.ephemeralAnonId)
|
|
1615
1655
|
this.refreshAttribution();
|
|
@@ -1617,6 +1657,34 @@ class RevnixClient {
|
|
|
1617
1657
|
})
|
|
1618
1658
|
.catch((err) => this.diag("reportInstall", err));
|
|
1619
1659
|
}
|
|
1660
|
+
integrityFor(customerId) {
|
|
1661
|
+
const source = this.deviceIntegrity;
|
|
1662
|
+
if (!source)
|
|
1663
|
+
return Promise.resolve({});
|
|
1664
|
+
if (this.integrityRequest?.customerId !== customerId) {
|
|
1665
|
+
let timer;
|
|
1666
|
+
const timeout = new Promise((_, reject) => {
|
|
1667
|
+
timer = setTimeout(() => reject(new Error("device integrity timed out")), INTEGRITY_TIMEOUT_MS);
|
|
1668
|
+
});
|
|
1669
|
+
const body = Promise.race([
|
|
1670
|
+
Promise.resolve().then(() => source(customerId)),
|
|
1671
|
+
timeout,
|
|
1672
|
+
])
|
|
1673
|
+
.then((evidence) => {
|
|
1674
|
+
const integrity = wellFormedIntegrity(evidence);
|
|
1675
|
+
if (!integrity)
|
|
1676
|
+
throw new Error("device integrity unavailable");
|
|
1677
|
+
return { integrity };
|
|
1678
|
+
})
|
|
1679
|
+
.catch((err) => {
|
|
1680
|
+
this.diag("deviceIntegrity", err);
|
|
1681
|
+
return {};
|
|
1682
|
+
})
|
|
1683
|
+
.finally(() => clearTimeout(timer));
|
|
1684
|
+
this.integrityRequest = { customerId, body };
|
|
1685
|
+
}
|
|
1686
|
+
return this.integrityRequest.body;
|
|
1687
|
+
}
|
|
1620
1688
|
/** REV-268: assemble and encode the device facts once. Runtime-detected
|
|
1621
1689
|
* facts first, the app's `device` over them, then the three the SDK owns.
|
|
1622
1690
|
* `installedAt` is the first launch this storage ever saw — written on
|
|
@@ -115,7 +115,14 @@ function localizeBlock(block, lookup) {
|
|
|
115
115
|
case "text":
|
|
116
116
|
return { ...block, text: at("text", block.text) };
|
|
117
117
|
case "button":
|
|
118
|
+
case "toggle":
|
|
118
119
|
return { ...block, label: at("label", block.label) };
|
|
120
|
+
case "timer":
|
|
121
|
+
return {
|
|
122
|
+
...block,
|
|
123
|
+
format: opt("format", block.format),
|
|
124
|
+
expiredText: opt("expiredText", block.expiredText),
|
|
125
|
+
};
|
|
119
126
|
case "list":
|
|
120
127
|
return {
|
|
121
128
|
...block,
|
|
@@ -176,6 +183,11 @@ export function localizeBlockDoc(doc, locale) {
|
|
|
176
183
|
...doc,
|
|
177
184
|
defaultLocale: chain[0],
|
|
178
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
|
+
: {}),
|
|
179
191
|
};
|
|
180
192
|
}
|
|
181
193
|
const LINK_LABEL_ALIASES = {
|
|
@@ -17,6 +17,14 @@ export function selectedPackageId(packages, ...candidates) {
|
|
|
17
17
|
}
|
|
18
18
|
return packages[0]?.packageId;
|
|
19
19
|
}
|
|
20
|
+
export function toggleSelection(block, packages, selectedId) {
|
|
21
|
+
const on = packages[block.onPackageIndex];
|
|
22
|
+
const off = packages[block.offPackageIndex];
|
|
23
|
+
if (on === undefined || off === undefined)
|
|
24
|
+
return null;
|
|
25
|
+
const isOn = on.packageId === selectedId;
|
|
26
|
+
return { on: isOn, target: (isOn ? off : on).packageId };
|
|
27
|
+
}
|
|
20
28
|
/** The context a package-bearing card opens for itself and its subtree. */
|
|
21
29
|
export function packageContext(pkg, selectedId) {
|
|
22
30
|
if (pkg === undefined)
|
package/dist/esm/blocks/tags.js
CHANGED
|
@@ -115,3 +115,34 @@ export function resolveTags(text, pkg, all = []) {
|
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
|
+
export function formatCountdown(format, remaining) {
|
|
119
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
120
|
+
let span = Infinity;
|
|
121
|
+
const part = (token, unit) => {
|
|
122
|
+
const value = Math.floor((remaining % span) / unit);
|
|
123
|
+
if (format.includes(token))
|
|
124
|
+
span = unit;
|
|
125
|
+
return value;
|
|
126
|
+
};
|
|
127
|
+
const days = part("{d}", 86400);
|
|
128
|
+
const hours = part("{hh}", 3600);
|
|
129
|
+
const minutes = part("{mm}", 60);
|
|
130
|
+
const seconds = part("{ss}", 1);
|
|
131
|
+
return format
|
|
132
|
+
.replace(/\{d\}/g, String(days))
|
|
133
|
+
.replace(/\{hh\}/g, pad(hours))
|
|
134
|
+
.replace(/\{mm\}/g, pad(minutes))
|
|
135
|
+
.replace(/\{ss\}/g, pad(seconds));
|
|
136
|
+
}
|
|
137
|
+
export function timerText(block, shownAt, now) {
|
|
138
|
+
const deadline = block.endsAt !== undefined
|
|
139
|
+
? Date.parse(block.endsAt)
|
|
140
|
+
: shownAt + (block.duration ?? 0) * 1000;
|
|
141
|
+
const remaining = Number.isNaN(deadline)
|
|
142
|
+
? 0
|
|
143
|
+
: Math.max(0, Math.floor((deadline - now) / 1000));
|
|
144
|
+
if (remaining > 0) {
|
|
145
|
+
return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
|
|
146
|
+
}
|
|
147
|
+
return block.expiredText ?? null;
|
|
148
|
+
}
|