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/dist/esm/blocks/types.js
CHANGED
|
@@ -34,7 +34,26 @@ export function hasCloseAction(blocks) {
|
|
|
34
34
|
: b.type === "card"
|
|
35
35
|
? b.repeat === undefined &&
|
|
36
36
|
b.packageIndex === undefined &&
|
|
37
|
+
b.layout !== "carousel" &&
|
|
37
38
|
hasCloseAction(b.children)
|
|
38
39
|
: (b.type === "text" || b.type === "image" || b.type === "button") &&
|
|
39
40
|
b.action === "close");
|
|
40
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
|
@@ -15,9 +15,11 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
|
|
|
15
15
|
const PLACEMENTS_CACHE_KEY = "revnix.placements";
|
|
16
16
|
const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
|
|
17
17
|
const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
|
|
18
|
+
const LAST_AD_ID_KEY = "revnix.lastAdId";
|
|
18
19
|
const EVENT_QUEUE_KEY = "revnix.eventQueue";
|
|
19
20
|
const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
|
|
20
21
|
const DEFAULT_TIMEOUT_MS = 10000;
|
|
22
|
+
const INTEGRITY_TIMEOUT_MS = 10000;
|
|
21
23
|
const EVENT_QUEUE_CAP = 1000;
|
|
22
24
|
const EVENT_BATCH_SIZE = 100;
|
|
23
25
|
const EVENT_FLUSH_THRESHOLD = 20;
|
|
@@ -98,6 +100,19 @@ const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
|
|
|
98
100
|
// from memory. Short enough that a webhook-driven change is picked up within
|
|
99
101
|
// half a minute; long enough that gating five screens costs one request.
|
|
100
102
|
const DEFAULT_ENTITLEMENTS_TTL_MS = 30000;
|
|
103
|
+
const fitsWithin = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
|
|
104
|
+
function wellFormedIntegrity(evidence) {
|
|
105
|
+
if (typeof evidence !== "object" || evidence === null)
|
|
106
|
+
return undefined;
|
|
107
|
+
const { platform, keyId, attestation, token } = evidence;
|
|
108
|
+
if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16384)) {
|
|
109
|
+
return { platform, keyId, attestation };
|
|
110
|
+
}
|
|
111
|
+
if (platform === "android" && fitsWithin(token, 16384)) {
|
|
112
|
+
return { platform, token };
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
101
116
|
export class RevnixClient {
|
|
102
117
|
constructor(config) {
|
|
103
118
|
/** True while the current id is a generated anon id living on
|
|
@@ -110,6 +125,7 @@ export class RevnixClient {
|
|
|
110
125
|
// client (dev fast-refresh re-running configure()) does not keep retrying
|
|
111
126
|
// and prefetching alongside its successor.
|
|
112
127
|
this.disposed = false;
|
|
128
|
+
this.integrityRequest = null;
|
|
113
129
|
this.deferredDeepLinkClaimed = false;
|
|
114
130
|
this.landingUrl = null;
|
|
115
131
|
this.lastDeepLinkWrite = Promise.resolve();
|
|
@@ -186,12 +202,15 @@ export class RevnixClient {
|
|
|
186
202
|
this.onDiagnostic = config.onDiagnostic;
|
|
187
203
|
this.onDeferredDeepLink = config.onDeferredDeepLink;
|
|
188
204
|
this.onAttribution = config.onAttribution;
|
|
205
|
+
this.deviceIntegrity = config.deviceIntegrity;
|
|
189
206
|
this.readYourWritesDelaysMs =
|
|
190
207
|
config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
|
|
191
208
|
this.customerIdPromise = this.loadCustomerId(config.customerId);
|
|
192
209
|
// C7: retry any purchases that failed to register on a previous launch, so
|
|
193
210
|
// a paying user never stays locked out because a POST didn't land.
|
|
194
211
|
void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
|
|
212
|
+
if (config.advertisingId)
|
|
213
|
+
this.reportAdvertisingId(config.advertisingId);
|
|
195
214
|
const store = getStoreAdapter();
|
|
196
215
|
if (store) {
|
|
197
216
|
this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
|
|
@@ -967,6 +986,7 @@ export class RevnixClient {
|
|
|
967
986
|
...(attribution.campaign ? { campaign: attribution.campaign } : {}),
|
|
968
987
|
...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
|
|
969
988
|
...(attribution.creative ? { creative: attribution.creative } : {}),
|
|
989
|
+
...(await this.integrityFor(customerId)),
|
|
970
990
|
});
|
|
971
991
|
await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
|
|
972
992
|
})
|
|
@@ -1000,6 +1020,21 @@ export class RevnixClient {
|
|
|
1000
1020
|
})
|
|
1001
1021
|
.catch((err) => this.diag("setPushToken", err));
|
|
1002
1022
|
}
|
|
1023
|
+
reportAdvertisingId(source) {
|
|
1024
|
+
void Promise.resolve()
|
|
1025
|
+
.then(source)
|
|
1026
|
+
.then(async (value) => {
|
|
1027
|
+
const id = value || null;
|
|
1028
|
+
const customerId = await this.customerIdPromise;
|
|
1029
|
+
const payload = JSON.stringify({ customerId, id });
|
|
1030
|
+
const last = await this.storage.getItem(LAST_AD_ID_KEY);
|
|
1031
|
+
if (last === payload || (id === null && last === null))
|
|
1032
|
+
return;
|
|
1033
|
+
await this.setAttributes({ gps_adid: id });
|
|
1034
|
+
await this.storage.setItem(LAST_AD_ID_KEY, payload);
|
|
1035
|
+
})
|
|
1036
|
+
.catch((err) => this.diag("advertisingId", err));
|
|
1037
|
+
}
|
|
1003
1038
|
/** REV-272: the two moments that happen ON a paywall, with the loop guard
|
|
1004
1039
|
* applied. `fromPaywallId` travels so the server can refuse to hand back
|
|
1005
1040
|
* the very paywall being dismissed — the backstop for a host that reports
|
|
@@ -1222,12 +1257,13 @@ export class RevnixClient {
|
|
|
1222
1257
|
if (this.ephemeralAnonId)
|
|
1223
1258
|
return;
|
|
1224
1259
|
void this.customerIdPromise
|
|
1225
|
-
.then((customerId) => this.request("POST", "/v1/installs", {
|
|
1260
|
+
.then(async (customerId) => this.request("POST", "/v1/installs", {
|
|
1226
1261
|
customerId,
|
|
1227
1262
|
occurredAt: Date.now(),
|
|
1228
1263
|
sdkVersion: SDK_VERSION,
|
|
1229
1264
|
installReferrer: referrer.slice(0, 1024),
|
|
1230
1265
|
...this.installMetadata,
|
|
1266
|
+
...(await this.integrityFor(customerId)),
|
|
1231
1267
|
}))
|
|
1232
1268
|
.then((body) => {
|
|
1233
1269
|
this.refreshAttribution();
|
|
@@ -1262,12 +1298,13 @@ export class RevnixClient {
|
|
|
1262
1298
|
if (this.ephemeralAnonId)
|
|
1263
1299
|
return Promise.resolve(undefined);
|
|
1264
1300
|
return this.customerIdPromise
|
|
1265
|
-
.then((customerId) => this.request("POST", "/v1/installs", {
|
|
1301
|
+
.then(async (customerId) => this.request("POST", "/v1/installs", {
|
|
1266
1302
|
customerId,
|
|
1267
1303
|
occurredAt: Date.now(),
|
|
1268
1304
|
sdkVersion: SDK_VERSION,
|
|
1269
1305
|
attributionToken: token.slice(0, 2048),
|
|
1270
1306
|
...this.installMetadata,
|
|
1307
|
+
...(await this.integrityFor(customerId)),
|
|
1271
1308
|
}))
|
|
1272
1309
|
.then((body) => {
|
|
1273
1310
|
this.refreshAttribution();
|
|
@@ -1600,12 +1637,15 @@ export class RevnixClient {
|
|
|
1600
1637
|
/** Fire-and-forget install report (REV-093). Never blocks startup and
|
|
1601
1638
|
* never throws — the backend absorbs retries idempotently per id. */
|
|
1602
1639
|
reportInstall(customerId) {
|
|
1603
|
-
|
|
1640
|
+
const occurredAt = Date.now();
|
|
1641
|
+
void this.integrityFor(customerId)
|
|
1642
|
+
.then((integrity) => this.request("POST", "/v1/installs", {
|
|
1604
1643
|
customerId,
|
|
1605
|
-
occurredAt
|
|
1644
|
+
occurredAt,
|
|
1606
1645
|
sdkVersion: SDK_VERSION,
|
|
1607
1646
|
...this.installMetadata,
|
|
1608
|
-
|
|
1647
|
+
...integrity,
|
|
1648
|
+
}))
|
|
1609
1649
|
.then((body) => {
|
|
1610
1650
|
if (!this.ephemeralAnonId)
|
|
1611
1651
|
this.refreshAttribution();
|
|
@@ -1613,6 +1653,34 @@ export class RevnixClient {
|
|
|
1613
1653
|
})
|
|
1614
1654
|
.catch((err) => this.diag("reportInstall", err));
|
|
1615
1655
|
}
|
|
1656
|
+
integrityFor(customerId) {
|
|
1657
|
+
const source = this.deviceIntegrity;
|
|
1658
|
+
if (!source)
|
|
1659
|
+
return Promise.resolve({});
|
|
1660
|
+
if (this.integrityRequest?.customerId !== customerId) {
|
|
1661
|
+
let timer;
|
|
1662
|
+
const timeout = new Promise((_, reject) => {
|
|
1663
|
+
timer = setTimeout(() => reject(new Error("device integrity timed out")), INTEGRITY_TIMEOUT_MS);
|
|
1664
|
+
});
|
|
1665
|
+
const body = Promise.race([
|
|
1666
|
+
Promise.resolve().then(() => source(customerId)),
|
|
1667
|
+
timeout,
|
|
1668
|
+
])
|
|
1669
|
+
.then((evidence) => {
|
|
1670
|
+
const integrity = wellFormedIntegrity(evidence);
|
|
1671
|
+
if (!integrity)
|
|
1672
|
+
throw new Error("device integrity unavailable");
|
|
1673
|
+
return { integrity };
|
|
1674
|
+
})
|
|
1675
|
+
.catch((err) => {
|
|
1676
|
+
this.diag("deviceIntegrity", err);
|
|
1677
|
+
return {};
|
|
1678
|
+
})
|
|
1679
|
+
.finally(() => clearTimeout(timer));
|
|
1680
|
+
this.integrityRequest = { customerId, body };
|
|
1681
|
+
}
|
|
1682
|
+
return this.integrityRequest.body;
|
|
1683
|
+
}
|
|
1616
1684
|
/** REV-268: assemble and encode the device facts once. Runtime-detected
|
|
1617
1685
|
* facts first, the app's `device` over them, then the three the SDK owns.
|
|
1618
1686
|
* `installedAt` is the first launch this storage ever saw — written on
|
package/dist/esm/store/index.js
CHANGED
|
@@ -18,5 +18,12 @@ export const RevnixStore = native
|
|
|
18
18
|
finish: missing,
|
|
19
19
|
observe: () => () => { },
|
|
20
20
|
};
|
|
21
|
+
/** Pass as `deviceIntegrity` in the Revnix config: App Attest on iOS, Play
|
|
22
|
+
* Integrity on Android, via the RevnixStore native module. Resolves null
|
|
23
|
+
* where the device or build cannot produce evidence. */
|
|
24
|
+
export const nativeDeviceIntegrity = (customerId) => native?.deviceIntegrity?.(customerId) ?? Promise.resolve(null);
|
|
25
|
+
/** Pass as `advertisingId` in the Revnix config: the Google Advertising ID
|
|
26
|
+
* on Android, via the RevnixStore native module. Resolves null on iOS. */
|
|
27
|
+
export const nativeAdvertisingId = () => native?.advertisingId?.() ?? Promise.resolve(null);
|
|
21
28
|
if (native)
|
|
22
29
|
setStoreAdapter(RevnixStore);
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
// RevnixPaywall delegates here whenever a config carries `blocks`, so apps
|
|
18
18
|
// get designed paywalls without changing a line of their integration.
|
|
19
19
|
import * as React from "react";
|
|
20
|
-
import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, StatusBar, Text, View, } from "react-native";
|
|
20
|
+
import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, StatusBar, Switch, Text, View, } from "react-native";
|
|
21
21
|
import { gradientFallbackColor, isGradient, parseGradient, resolveColor, surfaceSolid, withAlpha, } from "../blocks/color.js";
|
|
22
22
|
import { DEFAULT_FONT_SIZE, hairlineColor, inherit, inheritedTextStyle, toTextStyle, toViewStyle, } from "../blocks/style.js";
|
|
23
23
|
import { backgroundLayers, coverPlacement, } from "../blocks/background.js";
|
|
24
24
|
import { detectLocale, linkLabels, localizeBlockDoc } from "../blocks/locale.js";
|
|
25
|
-
import { resolveTags } from "../blocks/tags.js";
|
|
26
|
-
import { canvasMetrics, effectiveStyle, isVisible, packageContext, selectedPackageId, } from "../blocks/selection.js";
|
|
27
|
-
import { DESIGN_CANVAS, hasCloseAction, } from "../blocks/types.js";
|
|
25
|
+
import { resolveTags, timerText } from "../blocks/tags.js";
|
|
26
|
+
import { canvasMetrics, effectiveStyle, isVisible, packageContext, selectedPackageId, toggleSelection, } from "../blocks/selection.js";
|
|
27
|
+
import { DESIGN_CANVAS, hasCloseAction, onboardingScreen, stepScreen, } from "../blocks/types.js";
|
|
28
28
|
/** CSS angle (0 = up, 90 = right) → RN gradient start/end points. */
|
|
29
29
|
function angleToPoints(angle) {
|
|
30
30
|
const rad = (angle * Math.PI) / 180;
|
|
@@ -240,7 +240,7 @@ function FallbackClose({ doc, onPress, safeAreaTop, }) {
|
|
|
240
240
|
/** Text blocks always render inside a View: a style may carry both typography
|
|
241
241
|
* and box properties (a round chip is a 34pt circle with a centered glyph),
|
|
242
242
|
* and RN cannot centre a glyph inside <Text> the way flexbox does. */
|
|
243
|
-
function BlockText({ text, style, ctx, inherited, onPress, }) {
|
|
243
|
+
function BlockText({ text, style, ctx, inherited, onPress, pressLabel, }) {
|
|
244
244
|
const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
|
|
245
245
|
// The block's own typography is folded into the inherited set first, so
|
|
246
246
|
// em letter-spacing and unitless line-height resolve against whichever font
|
|
@@ -255,16 +255,73 @@ function BlockText({ text, style, ctx, inherited, onPress, }) {
|
|
|
255
255
|
// wrapping a styled View would leave a pinned chip resolving its offsets
|
|
256
256
|
// against a zero-sized parent.
|
|
257
257
|
if (onPress) {
|
|
258
|
-
return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityLabel:
|
|
258
|
+
return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityLabel: pressLabel, hitSlop: 8, onPress: onPress, style: toViewStyle(style, styleCtx) }, body));
|
|
259
259
|
}
|
|
260
260
|
return React.createElement(View, { style: toViewStyle(style, styleCtx) }, body);
|
|
261
261
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
262
|
+
function Timer({ block, style, ctx, inherited, }) {
|
|
263
|
+
const [now, setNow] = React.useState(Date.now);
|
|
264
|
+
React.useEffect(() => {
|
|
265
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
266
|
+
return () => clearInterval(id);
|
|
267
|
+
}, []);
|
|
268
|
+
const text = timerText(block, ctx.shownAt, now);
|
|
269
|
+
if (text === null)
|
|
270
|
+
return null;
|
|
271
|
+
return React.createElement(BlockText, { text: text, style: style, ctx: ctx, inherited: inherited });
|
|
272
|
+
}
|
|
273
|
+
function Carousel({ pages, autoAdvance, doc, }) {
|
|
274
|
+
const scroller = React.useRef(null);
|
|
275
|
+
const [size, setSize] = React.useState({ width: 0, height: 0 });
|
|
276
|
+
const [page, setPage] = React.useState(0);
|
|
277
|
+
React.useEffect(() => {
|
|
278
|
+
if (!autoAdvance || size.width === 0 || pages.length < 2)
|
|
279
|
+
return;
|
|
280
|
+
const id = setInterval(() => {
|
|
281
|
+
scroller.current?.scrollTo({
|
|
282
|
+
x: ((page + 1) % pages.length) * size.width,
|
|
283
|
+
animated: true,
|
|
284
|
+
});
|
|
285
|
+
}, autoAdvance * 1000);
|
|
286
|
+
return () => clearInterval(id);
|
|
287
|
+
}, [autoAdvance, size.width, page, pages.length]);
|
|
288
|
+
return (React.createElement(View, { style: { flex: 1 }, onLayout: (e) => setSize({
|
|
289
|
+
width: e.nativeEvent.layout.width,
|
|
290
|
+
height: e.nativeEvent.layout.height,
|
|
291
|
+
}) },
|
|
292
|
+
React.createElement(ScrollView, { ref: scroller, horizontal: true, pagingEnabled: true, showsHorizontalScrollIndicator: false, scrollEventThrottle: 16, onScroll: (e) => {
|
|
293
|
+
if (size.width > 0) {
|
|
294
|
+
setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
|
|
295
|
+
}
|
|
296
|
+
} }, pages.map((node, i) => (React.createElement(View, { key: i, style: size }, node)))),
|
|
297
|
+
pages.length > 1 ? (React.createElement(Dots, { count: pages.length, active: page, doc: doc, style: { bottom: 8 } })) : null));
|
|
267
298
|
}
|
|
299
|
+
function Dots({ count, active, doc, style, }) {
|
|
300
|
+
return (React.createElement(View, { pointerEvents: "none", style: [
|
|
301
|
+
{
|
|
302
|
+
position: "absolute",
|
|
303
|
+
left: 0,
|
|
304
|
+
right: 0,
|
|
305
|
+
flexDirection: "row",
|
|
306
|
+
justifyContent: "center",
|
|
307
|
+
gap: 6,
|
|
308
|
+
},
|
|
309
|
+
style,
|
|
310
|
+
] }, Array.from({ length: count }, (_, i) => (React.createElement(View, { key: i, style: {
|
|
311
|
+
width: 6,
|
|
312
|
+
height: 6,
|
|
313
|
+
borderRadius: 3,
|
|
314
|
+
backgroundColor: i === active ? doc.accent : withAlpha(doc.textColor, 0.3),
|
|
315
|
+
} })))));
|
|
316
|
+
}
|
|
317
|
+
function actionPress(block, ctx) {
|
|
318
|
+
return block.action === "close" ? ctx.onClose : ctx.navigate(block.action);
|
|
319
|
+
}
|
|
320
|
+
const ACTION_LABEL = {
|
|
321
|
+
close: "Close",
|
|
322
|
+
next: "Next",
|
|
323
|
+
back: "Back",
|
|
324
|
+
};
|
|
268
325
|
/** Typography that is NOT inherited (decoration) — everything else already
|
|
269
326
|
* arrives through the inherited set. */
|
|
270
327
|
function textOnly(s, ctx) {
|
|
@@ -363,6 +420,11 @@ function gridTracks(block) {
|
|
|
363
420
|
}
|
|
364
421
|
function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
365
422
|
const { doc } = ctx;
|
|
423
|
+
React.useEffect(() => {
|
|
424
|
+
if (block.type === "video") {
|
|
425
|
+
ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
|
|
426
|
+
}
|
|
427
|
+
}, [block.type]);
|
|
366
428
|
const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
|
|
367
429
|
// A pinned card judges its own context; every other block inherits the
|
|
368
430
|
// nearest plan card's. (A repeat card is judged per instance below.)
|
|
@@ -384,10 +446,28 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
384
446
|
const size = text.fontSize;
|
|
385
447
|
switch (block.type) {
|
|
386
448
|
case "text":
|
|
387
|
-
return (React.createElement(BlockText, { text: resolveTags(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress:
|
|
388
|
-
case "
|
|
389
|
-
|
|
390
|
-
|
|
449
|
+
return (React.createElement(BlockText, { text: resolveTags(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: actionPress(block, ctx), pressLabel: block.action === "close" ? "Close" : undefined }));
|
|
450
|
+
case "timer":
|
|
451
|
+
return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
|
|
452
|
+
case "toggle": {
|
|
453
|
+
const toggle = toggleSelection(block, ctx.packages, ctx.selectedId);
|
|
454
|
+
if (!toggle)
|
|
455
|
+
return null;
|
|
456
|
+
const flip = () => ctx.onSelect(toggle.target);
|
|
457
|
+
return (React.createElement(Pressable, { accessibilityRole: "switch", accessibilityState: { checked: toggle.on }, onPress: flip, style: [{ flexDirection: "row", alignItems: "center", gap: 12 }, view] },
|
|
458
|
+
React.createElement(BlockFill, { block: block, ctx: ctx }),
|
|
459
|
+
React.createElement(Text, { style: [
|
|
460
|
+
{ flex: 1 },
|
|
461
|
+
inheritedTextStyle(text, ctx.style),
|
|
462
|
+
textOnly(style, styleCtx),
|
|
463
|
+
] }, resolveTags(block.label, ctx.packages.find((p) => p.packageId === ctx.selectedId), ctx.packages)),
|
|
464
|
+
React.createElement(Switch, { value: toggle.on, onValueChange: flip, trackColor: { true: doc.accent } })));
|
|
465
|
+
}
|
|
466
|
+
case "image":
|
|
467
|
+
case "video": {
|
|
468
|
+
const image = block.type === "image";
|
|
469
|
+
const url = image ? block.url || ctx.heroImageUrl : block.poster;
|
|
470
|
+
const round = image && block.shape === "circle";
|
|
391
471
|
const s = style;
|
|
392
472
|
// A converted design sizes its own slot; the 160pt default is only for
|
|
393
473
|
// a slot dropped into a flow column, and must not fight it.
|
|
@@ -397,22 +477,20 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
397
477
|
s.aspectRatio !== undefined ||
|
|
398
478
|
(s.top !== undefined && s.bottom !== undefined) ||
|
|
399
479
|
s.flex !== undefined);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const ImageBox = closeImage ? Pressable : View;
|
|
405
|
-
return (React.createElement(ImageBox, { accessibilityRole: closeImage ? "button" : undefined, accessibilityLabel: closeImage ? "Close" : undefined, hitSlop: closeImage ? 8 : undefined, onPress: closeImage, style: [
|
|
480
|
+
const action = block.type === "image" ? block.action : undefined;
|
|
481
|
+
const imagePress = actionPress({ action }, ctx);
|
|
482
|
+
const ImageBox = imagePress ? Pressable : View;
|
|
483
|
+
return (React.createElement(ImageBox, { accessibilityRole: imagePress ? "button" : undefined, accessibilityLabel: imagePress && action ? ACTION_LABEL[action] : undefined, hitSlop: imagePress ? 8 : undefined, onPress: imagePress, style: [
|
|
406
484
|
sized ? null : { height: 160 },
|
|
407
485
|
{ borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
|
|
408
486
|
view,
|
|
409
487
|
] },
|
|
410
488
|
React.createElement(BlockFill, { block: block, ctx: ctx }),
|
|
411
|
-
url ? (React.createElement(Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : (React.createElement(View, { style: {
|
|
489
|
+
url ? (React.createElement(Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : image ? (React.createElement(View, { style: {
|
|
412
490
|
width: "100%",
|
|
413
491
|
height: "100%",
|
|
414
492
|
backgroundColor: withAlpha("#7d879b", 0.2),
|
|
415
|
-
} }))));
|
|
493
|
+
} })) : null));
|
|
416
494
|
}
|
|
417
495
|
case "list":
|
|
418
496
|
return (React.createElement(View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
|
|
@@ -443,12 +521,13 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
443
521
|
// A button the design marks as the close dismisses instead of buying,
|
|
444
522
|
// and takes no accent fill: the CTA must stay the one accented thing on
|
|
445
523
|
// the screen, or a "Not now" competes with "Subscribe" for the eye.
|
|
446
|
-
const close =
|
|
524
|
+
const close = block.action === "close" ? ctx.onClose : undefined;
|
|
525
|
+
const navigates = block.action === "next" || block.action === "back";
|
|
447
526
|
// A purchase in flight: the button keeps its box and fill, loses its
|
|
448
527
|
// label to a spinner in the ink colour, and ignores taps. A close
|
|
449
528
|
// button is never blocked — the user can always leave.
|
|
450
|
-
const busy = !close && ctx.loading;
|
|
451
|
-
return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: busy, busy }, disabled: busy, onPress: close ?? ctx.onPurchase, style: ({ pressed }) => [
|
|
529
|
+
const busy = !close && !navigates && ctx.loading;
|
|
530
|
+
return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: busy, busy }, disabled: busy, onPress: navigates ? actionPress(block, ctx) : (close ?? ctx.onPurchase), style: ({ pressed }) => [
|
|
452
531
|
{
|
|
453
532
|
backgroundColor: close ? "transparent" : doc.accent,
|
|
454
533
|
borderRadius: 12,
|
|
@@ -521,20 +600,23 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
521
600
|
return (React.createElement(View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
|
|
522
601
|
case "card": {
|
|
523
602
|
const layout = block.layout ?? "column";
|
|
603
|
+
const carousel = layout === "carousel";
|
|
524
604
|
const base = layout === "stack"
|
|
525
605
|
? { position: "relative" }
|
|
526
|
-
:
|
|
527
|
-
? {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
606
|
+
: carousel
|
|
607
|
+
? { position: "relative", overflow: "hidden" }
|
|
608
|
+
: layout === "grid"
|
|
609
|
+
? {
|
|
610
|
+
position: "relative",
|
|
611
|
+
flexDirection: "row",
|
|
612
|
+
flexWrap: "wrap",
|
|
613
|
+
gap: style?.gap ?? 10,
|
|
614
|
+
}
|
|
615
|
+
: {
|
|
616
|
+
position: "relative",
|
|
617
|
+
flexDirection: layout === "row" ? "row" : "column",
|
|
618
|
+
gap: style?.gap ?? 10,
|
|
619
|
+
};
|
|
538
620
|
const tracks = layout === "grid" ? gridTracks(block) : null;
|
|
539
621
|
const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
|
|
540
622
|
const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
|
|
@@ -578,6 +660,9 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
578
660
|
base,
|
|
579
661
|
layout === "stack" ? null : { borderRadius: 16 },
|
|
580
662
|
view,
|
|
663
|
+
carousel
|
|
664
|
+
? { height: typeof style?.height === "number" ? style.height : 220 }
|
|
665
|
+
: null,
|
|
581
666
|
];
|
|
582
667
|
const cardPackage = block.packageIndex !== undefined
|
|
583
668
|
? (ctx.packages[block.packageIndex] ?? pkg)
|
|
@@ -586,7 +671,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
586
671
|
// how hand-styled plan rows (a highlighted annual beside a plain
|
|
587
672
|
// monthly) become tappable without a products block.
|
|
588
673
|
const pinned = block.packageIndex !== undefined && cardPackage !== undefined;
|
|
589
|
-
return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, renderChildren(cardPackage, context)));
|
|
674
|
+
return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, carousel ? (React.createElement(Carousel, { pages: renderChildren(cardPackage, context), autoAdvance: block.autoAdvance, doc: doc })) : (renderChildren(cardPackage, context))));
|
|
590
675
|
}
|
|
591
676
|
}
|
|
592
677
|
}
|
|
@@ -598,6 +683,14 @@ export function RevnixBlockPaywall(props) {
|
|
|
598
683
|
// re-runs on every selection tap.
|
|
599
684
|
const doc = React.useMemo(() => localizeBlockDoc(props.doc, props.locale ?? detectLocale()), [props.doc, props.locale]);
|
|
600
685
|
const [internalSelected, setInternalSelected] = React.useState(undefined);
|
|
686
|
+
const [shownAt] = React.useState(Date.now);
|
|
687
|
+
const [screenIndex, setScreenIndex] = React.useState(0);
|
|
688
|
+
const [screenDoc, setScreenDoc] = React.useState(props.doc);
|
|
689
|
+
if (screenDoc !== props.doc) {
|
|
690
|
+
setScreenDoc(props.doc);
|
|
691
|
+
setScreenIndex(0);
|
|
692
|
+
}
|
|
693
|
+
const screen = onboardingScreen(doc, screenIndex);
|
|
601
694
|
// Contract §1: host → own tap → design highlight → first package, each
|
|
602
695
|
// only when the offering carries it.
|
|
603
696
|
const selectedId = selectedPackageId(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
|
|
@@ -633,21 +726,26 @@ export function RevnixBlockPaywall(props) {
|
|
|
633
726
|
onTerms: props.onTerms,
|
|
634
727
|
onPrivacy: props.onPrivacy,
|
|
635
728
|
onClose: props.onClose,
|
|
729
|
+
navigate: (action) => {
|
|
730
|
+
const to = stepScreen(doc, screenIndex, action);
|
|
731
|
+
return to === undefined ? undefined : () => setScreenIndex(to);
|
|
732
|
+
},
|
|
636
733
|
footer: props.footer,
|
|
637
734
|
heroImageUrl: props.heroImageUrl,
|
|
638
735
|
gradient: props.gradient,
|
|
639
736
|
onDiagnostic: props.onDiagnostic,
|
|
640
737
|
style: styleCtx,
|
|
641
738
|
loading: props.loading === true,
|
|
739
|
+
shownAt,
|
|
642
740
|
};
|
|
643
|
-
const children =
|
|
741
|
+
const children = screen.blocks.map((block) => (React.createElement(Block, { key: block.id, block: block, ctx: ctx, pkg: packages.find((p) => p.packageId === selectedId) ?? packages[0], inherited: rootText })));
|
|
644
742
|
// REV-252: the renderer's own close, drawn only for a design that authors
|
|
645
743
|
// none. Rendered as a sibling of the tree — outside the canvas scale — so
|
|
646
744
|
// it keeps its tap size and its distance from the screen edge whatever the
|
|
647
745
|
// device width does to the design.
|
|
648
746
|
const safeAreaTop = props.safeAreaTop ??
|
|
649
747
|
(Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0);
|
|
650
|
-
const fallbackClose = props.onClose && !hasCloseAction(
|
|
748
|
+
const fallbackClose = props.onClose && !hasCloseAction(screen.blocks) ? (React.createElement(FallbackClose, { doc: doc, onPress: props.onClose, safeAreaTop: safeAreaTop })) : null;
|
|
651
749
|
const [width, setWidth] = React.useState(null);
|
|
652
750
|
const [viewportHeight, setViewportHeight] = React.useState(null);
|
|
653
751
|
if (doc.layout === "canvas") {
|
|
@@ -668,7 +766,7 @@ export function RevnixBlockPaywall(props) {
|
|
|
668
766
|
const canvasHeight = metrics?.scaledHeight ?? DESIGN_CANVAS.height;
|
|
669
767
|
const left = metrics?.left ?? 0;
|
|
670
768
|
return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
|
|
671
|
-
React.createElement(ScrollView, { contentContainerStyle: { height: canvasHeight },
|
|
769
|
+
React.createElement(ScrollView, { key: screenIndex, contentContainerStyle: { height: canvasHeight },
|
|
672
770
|
// A designed screen never shows a scrollbar — the web preview has
|
|
673
771
|
// none, and in an RTL app the indicator lands on the left edge of
|
|
674
772
|
// the artwork. When nothing overflows, scrolling is off entirely so
|
|
@@ -692,15 +790,18 @@ export function RevnixBlockPaywall(props) {
|
|
|
692
790
|
{ translateY: -((canvasHeight / scale) * (1 - scale)) / 2 },
|
|
693
791
|
{ scale },
|
|
694
792
|
],
|
|
695
|
-
} },
|
|
793
|
+
} },
|
|
794
|
+
children,
|
|
795
|
+
screen.dots ? (React.createElement(Dots, { count: screen.dots.count, active: screen.dots.active, doc: doc, style: { top: 20, zIndex: 50 } })) : null))),
|
|
696
796
|
fallbackClose));
|
|
697
797
|
}
|
|
698
798
|
return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
|
|
699
|
-
React.createElement(ScrollView, { contentContainerStyle: {
|
|
799
|
+
React.createElement(ScrollView, { key: screenIndex, contentContainerStyle: {
|
|
700
800
|
gap: 12,
|
|
701
801
|
paddingHorizontal: 20,
|
|
702
802
|
paddingTop: 18,
|
|
703
803
|
paddingBottom: 30,
|
|
704
804
|
}, showsVerticalScrollIndicator: false, alwaysBounceVertical: false, overScrollMode: "never" }, children),
|
|
805
|
+
screen.dots ? (React.createElement(Dots, { count: screen.dots.count, active: screen.dots.active, doc: doc, style: { top: 20 + safeAreaTop, zIndex: 50 } })) : null,
|
|
705
806
|
fallbackClose));
|
|
706
807
|
}
|
|
@@ -38,7 +38,16 @@ function asBlockDoc(value) {
|
|
|
38
38
|
typeof doc.textColor === "string" &&
|
|
39
39
|
typeof doc.accent === "string" &&
|
|
40
40
|
typeof doc.accentInk === "string"
|
|
41
|
-
?
|
|
41
|
+
? {
|
|
42
|
+
...doc,
|
|
43
|
+
...(doc.screens !== undefined
|
|
44
|
+
? {
|
|
45
|
+
screens: Array.isArray(doc.screens)
|
|
46
|
+
? doc.screens.filter((s) => Array.isArray(s) && s.length > 0)
|
|
47
|
+
: [],
|
|
48
|
+
}
|
|
49
|
+
: {}),
|
|
50
|
+
}
|
|
42
51
|
: null;
|
|
43
52
|
}
|
|
44
53
|
export const DEFAULT_PAYWALL_THEME = {
|
|
@@ -251,7 +260,7 @@ export function RevnixPaywall(props) {
|
|
|
251
260
|
// arrives after a placeholder (or is swapped by an A/B assignment) would
|
|
252
261
|
// otherwise crash on "rendered more hooks than during the previous render".
|
|
253
262
|
const { config } = props;
|
|
254
|
-
const blocks = asBlockDoc(config.blocks);
|
|
263
|
+
const blocks = React.useMemo(() => asBlockDoc(config.blocks), [config.blocks]);
|
|
255
264
|
// Both sinks fire when both are wired; nil when neither is, which is what
|
|
256
265
|
// stops the renderer building a message nobody will read.
|
|
257
266
|
const renderDiagnostic = client?.reportRenderDiagnostic || props.onDiagnostic
|
package/dist/esm/version.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// L3: the single source of the SDK version inside src/. Must match
|
|
2
2
|
// package.json "version" — version.test.ts fails the build when they drift
|
|
3
3
|
// (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
|
|
4
|
-
export const SDK_VERSION = "1.
|
|
4
|
+
export const SDK_VERSION = "1.8.0";
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { memoryStorage, generateAnonymousId } from "./storage.js";
|
|
|
9
9
|
export { SDK_VERSION } from "./version.js";
|
|
10
10
|
export { DEVICE_HEADER, encodeDeviceHeader } from "./device.js";
|
|
11
11
|
export { browserLifecycle, DEFAULT_SESSION_TIMEOUT_MS, HOST_CALL_PLACEMENT, IMPLICIT_PLACEMENTS, PREVIEW_PLACEMENT_KEY, type ImplicitPlacement, type ImplicitSkipReason, type ImplicitTrigger, type ImplicitTriggerResponse, type RevnixLifecycle, } from "./implicit.js";
|
|
12
|
-
export type { AdRevenue, AliasResult, CustomerEntitlements, DeviceFacts, Entitlement, EntitlementSource, Environment, IdentifyResult, IdentifyStrategy, IdentityInfo, ImplicitResolution, LastDeepLink, MmpAttribution, MmpProvider, PaywallConfig, PaywallInteraction, PaywallPurchaseOutcome, PlacementOffering, PlacementPackage, PlacementPaywall, PlacementProduct, PlacementResolution, RegisterPurchaseInput, RegisterPurchaseResult, RevnixAttribution, RevnixConfig, RevnixDiagnostic, RevnixStorage, Store, } from "./types.js";
|
|
12
|
+
export type { AdRevenue, AliasResult, CustomerEntitlements, DeviceFacts, Entitlement, EntitlementSource, Environment, IdentifyResult, IdentifyStrategy, IdentityInfo, ImplicitResolution, IntegrityEvidence, LastDeepLink, MmpAttribution, MmpProvider, PaywallConfig, PaywallInteraction, PaywallPurchaseOutcome, PlacementOffering, PlacementPackage, PlacementPaywall, PlacementProduct, PlacementResolution, RegisterPurchaseInput, RegisterPurchaseResult, RevnixAttribution, RevnixConfig, RevnixDiagnostic, RevnixStorage, Store, } from "./types.js";
|
|
13
13
|
/** Singleton facade — configure once at app start, then import `Revnix`
|
|
14
14
|
* anywhere. Prefer constructing RevnixClient directly in tests. */
|
|
15
15
|
export declare const Revnix: {
|
package/dist/store/index.d.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
import { type StoreAdapter } from "../store.js";
|
|
2
|
+
import type { RevnixConfig } from "../types.js";
|
|
2
3
|
export declare const RevnixStore: StoreAdapter;
|
|
4
|
+
/** Pass as `deviceIntegrity` in the Revnix config: App Attest on iOS, Play
|
|
5
|
+
* Integrity on Android, via the RevnixStore native module. Resolves null
|
|
6
|
+
* where the device or build cannot produce evidence. */
|
|
7
|
+
export declare const nativeDeviceIntegrity: NonNullable<RevnixConfig["deviceIntegrity"]>;
|
|
8
|
+
/** Pass as `advertisingId` in the Revnix config: the Google Advertising ID
|
|
9
|
+
* on Android, via the RevnixStore native module. Resolves null on iOS. */
|
|
10
|
+
export declare const nativeAdvertisingId: NonNullable<RevnixConfig["advertisingId"]>;
|
package/dist/store/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.RevnixStore = void 0;
|
|
3
|
+
exports.nativeAdvertisingId = exports.nativeDeviceIntegrity = exports.RevnixStore = void 0;
|
|
4
4
|
const react_native_1 = require("react-native");
|
|
5
5
|
const store_js_1 = require("../store.js");
|
|
6
6
|
const native = react_native_1.NativeModules.RevnixStore;
|
|
@@ -21,5 +21,14 @@ exports.RevnixStore = native
|
|
|
21
21
|
finish: missing,
|
|
22
22
|
observe: () => () => { },
|
|
23
23
|
};
|
|
24
|
+
/** Pass as `deviceIntegrity` in the Revnix config: App Attest on iOS, Play
|
|
25
|
+
* Integrity on Android, via the RevnixStore native module. Resolves null
|
|
26
|
+
* where the device or build cannot produce evidence. */
|
|
27
|
+
const nativeDeviceIntegrity = (customerId) => native?.deviceIntegrity?.(customerId) ?? Promise.resolve(null);
|
|
28
|
+
exports.nativeDeviceIntegrity = nativeDeviceIntegrity;
|
|
29
|
+
/** Pass as `advertisingId` in the Revnix config: the Google Advertising ID
|
|
30
|
+
* on Android, via the RevnixStore native module. Resolves null on iOS. */
|
|
31
|
+
const nativeAdvertisingId = () => native?.advertisingId?.() ?? Promise.resolve(null);
|
|
32
|
+
exports.nativeAdvertisingId = nativeAdvertisingId;
|
|
24
33
|
if (native)
|
|
25
34
|
(0, store_js_1.setStoreAdapter)(exports.RevnixStore);
|