react-native-revnix 1.6.0 → 1.7.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 +7 -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 +45 -3
- package/dist/blocks/types.js +1 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +73 -5
- package/dist/esm/blocks/locale.js +7 -0
- package/dist/esm/blocks/selection.js +8 -0
- package/dist/esm/blocks/tags.js +31 -0
- package/dist/esm/blocks/types.js +1 -0
- package/dist/esm/client.js +73 -5
- package/dist/esm/store/index.js +7 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +103 -22
- 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 +100 -19
- 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 +7 -0
- package/src/blocks/selection.ts +13 -0
- package/src/blocks/tags.ts +38 -0
- package/src/blocks/types.ts +51 -2
- 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 +186 -20
- package/src/version.ts +1 -1
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,13 +17,13 @@
|
|
|
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";
|
|
25
|
+
import { resolveTags, timerText } from "../blocks/tags.js";
|
|
26
|
+
import { canvasMetrics, effectiveStyle, isVisible, packageContext, selectedPackageId, toggleSelection, } from "../blocks/selection.js";
|
|
27
27
|
import { DESIGN_CANVAS, hasCloseAction, } from "../blocks/types.js";
|
|
28
28
|
/** CSS angle (0 = up, 90 = right) → RN gradient start/end points. */
|
|
29
29
|
function angleToPoints(angle) {
|
|
@@ -259,6 +259,56 @@ function BlockText({ text, style, ctx, inherited, onPress, }) {
|
|
|
259
259
|
}
|
|
260
260
|
return React.createElement(View, { style: toViewStyle(style, styleCtx) }, body);
|
|
261
261
|
}
|
|
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(View, { pointerEvents: "none", style: {
|
|
298
|
+
position: "absolute",
|
|
299
|
+
bottom: 8,
|
|
300
|
+
left: 0,
|
|
301
|
+
right: 0,
|
|
302
|
+
flexDirection: "row",
|
|
303
|
+
justifyContent: "center",
|
|
304
|
+
gap: 6,
|
|
305
|
+
} }, pages.map((_, i) => (React.createElement(View, { key: i, style: {
|
|
306
|
+
width: 6,
|
|
307
|
+
height: 6,
|
|
308
|
+
borderRadius: 3,
|
|
309
|
+
backgroundColor: i === page ? doc.accent : withAlpha(doc.textColor, 0.3),
|
|
310
|
+
} }))))) : null));
|
|
311
|
+
}
|
|
262
312
|
/** The press handler for a block the design marks as the close, or undefined
|
|
263
313
|
* when it is not one (or the host wired no dismissal). One helper so text,
|
|
264
314
|
* image and button blocks cannot drift on what "action: close" means. */
|
|
@@ -363,6 +413,11 @@ function gridTracks(block) {
|
|
|
363
413
|
}
|
|
364
414
|
function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
365
415
|
const { doc } = ctx;
|
|
416
|
+
React.useEffect(() => {
|
|
417
|
+
if (block.type === "video") {
|
|
418
|
+
ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
|
|
419
|
+
}
|
|
420
|
+
}, [block.type]);
|
|
366
421
|
const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
|
|
367
422
|
// A pinned card judges its own context; every other block inherits the
|
|
368
423
|
// nearest plan card's. (A repeat card is judged per instance below.)
|
|
@@ -385,9 +440,27 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
385
440
|
switch (block.type) {
|
|
386
441
|
case "text":
|
|
387
442
|
return (React.createElement(BlockText, { text: resolveTags(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: closePress(block, ctx) }));
|
|
388
|
-
case "
|
|
389
|
-
|
|
390
|
-
|
|
443
|
+
case "timer":
|
|
444
|
+
return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
|
|
445
|
+
case "toggle": {
|
|
446
|
+
const toggle = toggleSelection(block, ctx.packages, ctx.selectedId);
|
|
447
|
+
if (!toggle)
|
|
448
|
+
return null;
|
|
449
|
+
const flip = () => ctx.onSelect(toggle.target);
|
|
450
|
+
return (React.createElement(Pressable, { accessibilityRole: "switch", accessibilityState: { checked: toggle.on }, onPress: flip, style: [{ flexDirection: "row", alignItems: "center", gap: 12 }, view] },
|
|
451
|
+
React.createElement(BlockFill, { block: block, ctx: ctx }),
|
|
452
|
+
React.createElement(Text, { style: [
|
|
453
|
+
{ flex: 1 },
|
|
454
|
+
inheritedTextStyle(text, ctx.style),
|
|
455
|
+
textOnly(style, styleCtx),
|
|
456
|
+
] }, resolveTags(block.label, ctx.packages.find((p) => p.packageId === ctx.selectedId), ctx.packages)),
|
|
457
|
+
React.createElement(Switch, { value: toggle.on, onValueChange: flip, trackColor: { true: doc.accent } })));
|
|
458
|
+
}
|
|
459
|
+
case "image":
|
|
460
|
+
case "video": {
|
|
461
|
+
const image = block.type === "image";
|
|
462
|
+
const url = image ? block.url || ctx.heroImageUrl : block.poster;
|
|
463
|
+
const round = image && block.shape === "circle";
|
|
391
464
|
const s = style;
|
|
392
465
|
// A converted design sizes its own slot; the 160pt default is only for
|
|
393
466
|
// a slot dropped into a flow column, and must not fight it.
|
|
@@ -400,7 +473,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
400
473
|
// An icon the design marks as the close (REV-252). Same rule as text:
|
|
401
474
|
// the whole box is the target, and the style lands on the Pressable so
|
|
402
475
|
// a pinned icon keeps its offsets.
|
|
403
|
-
const closeImage = closePress(block, ctx);
|
|
476
|
+
const closeImage = image ? closePress(block, ctx) : undefined;
|
|
404
477
|
const ImageBox = closeImage ? Pressable : View;
|
|
405
478
|
return (React.createElement(ImageBox, { accessibilityRole: closeImage ? "button" : undefined, accessibilityLabel: closeImage ? "Close" : undefined, hitSlop: closeImage ? 8 : undefined, onPress: closeImage, style: [
|
|
406
479
|
sized ? null : { height: 160 },
|
|
@@ -408,11 +481,11 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
408
481
|
view,
|
|
409
482
|
] },
|
|
410
483
|
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: {
|
|
484
|
+
url ? (React.createElement(Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : image ? (React.createElement(View, { style: {
|
|
412
485
|
width: "100%",
|
|
413
486
|
height: "100%",
|
|
414
487
|
backgroundColor: withAlpha("#7d879b", 0.2),
|
|
415
|
-
} }))));
|
|
488
|
+
} })) : null));
|
|
416
489
|
}
|
|
417
490
|
case "list":
|
|
418
491
|
return (React.createElement(View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
|
|
@@ -521,20 +594,23 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
521
594
|
return (React.createElement(View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
|
|
522
595
|
case "card": {
|
|
523
596
|
const layout = block.layout ?? "column";
|
|
597
|
+
const carousel = layout === "carousel";
|
|
524
598
|
const base = layout === "stack"
|
|
525
599
|
? { position: "relative" }
|
|
526
|
-
:
|
|
527
|
-
? {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
600
|
+
: carousel
|
|
601
|
+
? { position: "relative", overflow: "hidden" }
|
|
602
|
+
: layout === "grid"
|
|
603
|
+
? {
|
|
604
|
+
position: "relative",
|
|
605
|
+
flexDirection: "row",
|
|
606
|
+
flexWrap: "wrap",
|
|
607
|
+
gap: style?.gap ?? 10,
|
|
608
|
+
}
|
|
609
|
+
: {
|
|
610
|
+
position: "relative",
|
|
611
|
+
flexDirection: layout === "row" ? "row" : "column",
|
|
612
|
+
gap: style?.gap ?? 10,
|
|
613
|
+
};
|
|
538
614
|
const tracks = layout === "grid" ? gridTracks(block) : null;
|
|
539
615
|
const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
|
|
540
616
|
const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
|
|
@@ -578,6 +654,9 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
578
654
|
base,
|
|
579
655
|
layout === "stack" ? null : { borderRadius: 16 },
|
|
580
656
|
view,
|
|
657
|
+
carousel
|
|
658
|
+
? { height: typeof style?.height === "number" ? style.height : 220 }
|
|
659
|
+
: null,
|
|
581
660
|
];
|
|
582
661
|
const cardPackage = block.packageIndex !== undefined
|
|
583
662
|
? (ctx.packages[block.packageIndex] ?? pkg)
|
|
@@ -586,7 +665,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
586
665
|
// how hand-styled plan rows (a highlighted annual beside a plain
|
|
587
666
|
// monthly) become tappable without a products block.
|
|
588
667
|
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)));
|
|
668
|
+
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
669
|
}
|
|
591
670
|
}
|
|
592
671
|
}
|
|
@@ -598,6 +677,7 @@ export function RevnixBlockPaywall(props) {
|
|
|
598
677
|
// re-runs on every selection tap.
|
|
599
678
|
const doc = React.useMemo(() => localizeBlockDoc(props.doc, props.locale ?? detectLocale()), [props.doc, props.locale]);
|
|
600
679
|
const [internalSelected, setInternalSelected] = React.useState(undefined);
|
|
680
|
+
const [shownAt] = React.useState(Date.now);
|
|
601
681
|
// Contract §1: host → own tap → design highlight → first package, each
|
|
602
682
|
// only when the offering carries it.
|
|
603
683
|
const selectedId = selectedPackageId(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
|
|
@@ -639,6 +719,7 @@ export function RevnixBlockPaywall(props) {
|
|
|
639
719
|
onDiagnostic: props.onDiagnostic,
|
|
640
720
|
style: styleCtx,
|
|
641
721
|
loading: props.loading === true,
|
|
722
|
+
shownAt,
|
|
642
723
|
};
|
|
643
724
|
const children = doc.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
725
|
// REV-252: the renderer's own close, drawn only for a design that authors
|
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.7.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);
|
package/dist/types.d.ts
CHANGED
|
@@ -129,7 +129,39 @@ export interface RevnixConfig {
|
|
|
129
129
|
* Never throws into your app: an exception from this handler is reported
|
|
130
130
|
* through `onDiagnostic` and swallowed. */
|
|
131
131
|
onAttribution?: (attribution: RevnixAttribution) => void;
|
|
132
|
+
/** Opt-in device integrity evidence (Apple App Attest / Google Play
|
|
133
|
+
* Integrity) attached to every install report and `setAttribution` call.
|
|
134
|
+
* Pair it with the dashboard's "Require device integrity" setting, which
|
|
135
|
+
* strips attribution from installs that arrive without evidence. In React
|
|
136
|
+
* Native pass `nativeDeviceIntegrity` from "react-native-revnix/store";
|
|
137
|
+
* it needs iOS 14+ with App Attest, or Google Play services on Android.
|
|
138
|
+
* Simulators and emulators return nothing.
|
|
139
|
+
*
|
|
140
|
+
* Called at most once per customer id and bounded to 10s. A rejection,
|
|
141
|
+
* timeout, or missing/malformed result sends the request without
|
|
142
|
+
* evidence and reports `op: "deviceIntegrity"` through `onDiagnostic`. */
|
|
143
|
+
deviceIntegrity?: (customerId: string) => Promise<IntegrityEvidence | null | undefined>;
|
|
144
|
+
/** Google Advertising ID source, stored on the customer as `gps_adid` at
|
|
145
|
+
* launch and resent only when it changes. In React Native pass
|
|
146
|
+
* `nativeAdvertisingId` from "react-native-revnix/store"; it needs
|
|
147
|
+
* `play-services-ads-identifier` and the `AD_ID` permission in the app and
|
|
148
|
+
* answers null on iOS and on opt-out, and rejects when the id cannot be
|
|
149
|
+
* read. A null answer deletes a previously stored `gps_adid`; a
|
|
150
|
+
* rejection changes nothing and reports `op: "advertisingId"`. */
|
|
151
|
+
advertisingId?: () => Promise<string | null | undefined>;
|
|
132
152
|
}
|
|
153
|
+
/** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
|
|
154
|
+
* iOS: an App Attest key id and its attestation (standard base64) over
|
|
155
|
+
* SHA-256 of the customer id. Android: a classic Play Integrity token whose
|
|
156
|
+
* nonce is base64url (no padding) of SHA-256 of the customer id. */
|
|
157
|
+
export type IntegrityEvidence = {
|
|
158
|
+
platform: "ios";
|
|
159
|
+
keyId: string;
|
|
160
|
+
attestation: string;
|
|
161
|
+
} | {
|
|
162
|
+
platform: "android";
|
|
163
|
+
token: string;
|
|
164
|
+
};
|
|
133
165
|
/** AT11: the install-attribution verdict — GET
|
|
134
166
|
* /v1/customers/{id}/attribution. `installMatch: "unknown"` (no install
|
|
135
167
|
* recorded yet, a normal cold-start race) is never sent to hosts as such;
|