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.
Files changed (44) hide show
  1. package/README.md +16 -0
  2. package/android/build.gradle +3 -0
  3. package/android/consumer-rules.pro +1 -0
  4. package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
  5. package/dist/blocks/locale.js +12 -0
  6. package/dist/blocks/selection.d.ts +7 -1
  7. package/dist/blocks/selection.js +9 -0
  8. package/dist/blocks/tags.d.ts +3 -0
  9. package/dist/blocks/tags.js +33 -0
  10. package/dist/blocks/types.d.ts +65 -8
  11. package/dist/blocks/types.js +21 -0
  12. package/dist/client.d.ts +4 -0
  13. package/dist/client.js +73 -5
  14. package/dist/esm/blocks/locale.js +12 -0
  15. package/dist/esm/blocks/selection.js +8 -0
  16. package/dist/esm/blocks/tags.js +31 -0
  17. package/dist/esm/blocks/types.js +19 -0
  18. package/dist/esm/client.js +73 -5
  19. package/dist/esm/store/index.js +7 -0
  20. package/dist/esm/ui/RevnixBlockPaywall.js +145 -44
  21. package/dist/esm/ui/RevnixPaywall.js +11 -2
  22. package/dist/esm/version.js +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/store/index.d.ts +8 -0
  25. package/dist/store/index.js +10 -1
  26. package/dist/types.d.ts +32 -0
  27. package/dist/ui/RevnixBlockPaywall.js +141 -40
  28. package/dist/ui/RevnixPaywall.js +11 -2
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/ios/RevnixStore.m +4 -0
  32. package/ios/RevnixStore.swift +29 -0
  33. package/package.json +1 -1
  34. package/src/blocks/locale.ts +14 -0
  35. package/src/blocks/selection.ts +13 -0
  36. package/src/blocks/tags.ts +38 -0
  37. package/src/blocks/types.ts +86 -7
  38. package/src/client.ts +90 -8
  39. package/src/index.ts +1 -0
  40. package/src/store/index.ts +15 -0
  41. package/src/types.ts +30 -0
  42. package/src/ui/RevnixBlockPaywall.tsx +265 -42
  43. package/src/ui/RevnixPaywall.tsx +11 -2
  44. package/src/version.ts +1 -1
@@ -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
+ }
@@ -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
- void this.request("POST", "/v1/installs", {
1640
+ const occurredAt = Date.now();
1641
+ void this.integrityFor(customerId)
1642
+ .then((integrity) => this.request("POST", "/v1/installs", {
1604
1643
  customerId,
1605
- occurredAt: Date.now(),
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
@@ -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: "Close", hitSlop: 8, onPress: onPress, style: toViewStyle(style, styleCtx) }, body));
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
- /** The press handler for a block the design marks as the close, or undefined
263
- * when it is not one (or the host wired no dismissal). One helper so text,
264
- * image and button blocks cannot drift on what "action: close" means. */
265
- function closePress(block, ctx) {
266
- return block.action === "close" && ctx.onClose ? ctx.onClose : undefined;
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: closePress(block, ctx) }));
388
- case "image": {
389
- const url = block.url || ctx.heroImageUrl;
390
- const round = block.shape === "circle";
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
- // An icon the design marks as the close (REV-252). Same rule as text:
401
- // the whole box is the target, and the style lands on the Pressable so
402
- // a pinned icon keeps its offsets.
403
- const closeImage = closePress(block, ctx);
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 = closePress(block, ctx);
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
- : layout === "grid"
527
- ? {
528
- position: "relative",
529
- flexDirection: "row",
530
- flexWrap: "wrap",
531
- gap: style?.gap ?? 10,
532
- }
533
- : {
534
- position: "relative",
535
- flexDirection: layout === "row" ? "row" : "column",
536
- gap: style?.gap ?? 10,
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 = 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 })));
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(doc.blocks) ? (React.createElement(FallbackClose, { doc: doc, onPress: props.onClose, safeAreaTop: safeAreaTop })) : null;
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
- } }, children))),
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
- ? doc
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
@@ -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.6.0";
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: {
@@ -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"]>;
@@ -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);