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
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;
@@ -276,7 +276,7 @@ function FallbackClose({ doc, onPress, safeAreaTop, }) {
276
276
  /** Text blocks always render inside a View: a style may carry both typography
277
277
  * and box properties (a round chip is a 34pt circle with a centered glyph),
278
278
  * and RN cannot centre a glyph inside <Text> the way flexbox does. */
279
- function BlockText({ text, style, ctx, inherited, onPress, }) {
279
+ function BlockText({ text, style, ctx, inherited, onPress, pressLabel, }) {
280
280
  const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
281
281
  // The block's own typography is folded into the inherited set first, so
282
282
  // em letter-spacing and unitless line-height resolve against whichever font
@@ -291,16 +291,73 @@ function BlockText({ text, style, ctx, inherited, onPress, }) {
291
291
  // wrapping a styled View would leave a pinned chip resolving its offsets
292
292
  // against a zero-sized parent.
293
293
  if (onPress) {
294
- return (React.createElement(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: "Close", hitSlop: 8, onPress: onPress, style: (0, style_js_1.toViewStyle)(style, styleCtx) }, body));
294
+ return (React.createElement(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: pressLabel, hitSlop: 8, onPress: onPress, style: (0, style_js_1.toViewStyle)(style, styleCtx) }, body));
295
295
  }
296
296
  return React.createElement(react_native_1.View, { style: (0, style_js_1.toViewStyle)(style, styleCtx) }, body);
297
297
  }
298
- /** The press handler for a block the design marks as the close, or undefined
299
- * when it is not one (or the host wired no dismissal). One helper so text,
300
- * image and button blocks cannot drift on what "action: close" means. */
301
- function closePress(block, ctx) {
302
- return block.action === "close" && ctx.onClose ? ctx.onClose : undefined;
298
+ function Timer({ block, style, ctx, inherited, }) {
299
+ const [now, setNow] = React.useState(Date.now);
300
+ React.useEffect(() => {
301
+ const id = setInterval(() => setNow(Date.now()), 1000);
302
+ return () => clearInterval(id);
303
+ }, []);
304
+ const text = (0, tags_js_1.timerText)(block, ctx.shownAt, now);
305
+ if (text === null)
306
+ return null;
307
+ return React.createElement(BlockText, { text: text, style: style, ctx: ctx, inherited: inherited });
308
+ }
309
+ function Carousel({ pages, autoAdvance, doc, }) {
310
+ const scroller = React.useRef(null);
311
+ const [size, setSize] = React.useState({ width: 0, height: 0 });
312
+ const [page, setPage] = React.useState(0);
313
+ React.useEffect(() => {
314
+ if (!autoAdvance || size.width === 0 || pages.length < 2)
315
+ return;
316
+ const id = setInterval(() => {
317
+ scroller.current?.scrollTo({
318
+ x: ((page + 1) % pages.length) * size.width,
319
+ animated: true,
320
+ });
321
+ }, autoAdvance * 1000);
322
+ return () => clearInterval(id);
323
+ }, [autoAdvance, size.width, page, pages.length]);
324
+ return (React.createElement(react_native_1.View, { style: { flex: 1 }, onLayout: (e) => setSize({
325
+ width: e.nativeEvent.layout.width,
326
+ height: e.nativeEvent.layout.height,
327
+ }) },
328
+ React.createElement(react_native_1.ScrollView, { ref: scroller, horizontal: true, pagingEnabled: true, showsHorizontalScrollIndicator: false, scrollEventThrottle: 16, onScroll: (e) => {
329
+ if (size.width > 0) {
330
+ setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
331
+ }
332
+ } }, pages.map((node, i) => (React.createElement(react_native_1.View, { key: i, style: size }, node)))),
333
+ pages.length > 1 ? (React.createElement(Dots, { count: pages.length, active: page, doc: doc, style: { bottom: 8 } })) : null));
303
334
  }
335
+ function Dots({ count, active, doc, style, }) {
336
+ return (React.createElement(react_native_1.View, { pointerEvents: "none", style: [
337
+ {
338
+ position: "absolute",
339
+ left: 0,
340
+ right: 0,
341
+ flexDirection: "row",
342
+ justifyContent: "center",
343
+ gap: 6,
344
+ },
345
+ style,
346
+ ] }, Array.from({ length: count }, (_, i) => (React.createElement(react_native_1.View, { key: i, style: {
347
+ width: 6,
348
+ height: 6,
349
+ borderRadius: 3,
350
+ backgroundColor: i === active ? doc.accent : (0, color_js_1.withAlpha)(doc.textColor, 0.3),
351
+ } })))));
352
+ }
353
+ function actionPress(block, ctx) {
354
+ return block.action === "close" ? ctx.onClose : ctx.navigate(block.action);
355
+ }
356
+ const ACTION_LABEL = {
357
+ close: "Close",
358
+ next: "Next",
359
+ back: "Back",
360
+ };
304
361
  /** Typography that is NOT inherited (decoration) — everything else already
305
362
  * arrives through the inherited set. */
306
363
  function textOnly(s, ctx) {
@@ -399,6 +456,11 @@ function gridTracks(block) {
399
456
  }
400
457
  function Block({ block, ctx, pkg, inherited, selection, }) {
401
458
  const { doc } = ctx;
459
+ React.useEffect(() => {
460
+ if (block.type === "video") {
461
+ ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
462
+ }
463
+ }, [block.type]);
402
464
  const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
403
465
  // A pinned card judges its own context; every other block inherits the
404
466
  // nearest plan card's. (A repeat card is judged per instance below.)
@@ -420,10 +482,28 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
420
482
  const size = text.fontSize;
421
483
  switch (block.type) {
422
484
  case "text":
423
- return (React.createElement(BlockText, { text: (0, tags_js_1.resolveTags)(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: closePress(block, ctx) }));
424
- case "image": {
425
- const url = block.url || ctx.heroImageUrl;
426
- const round = block.shape === "circle";
485
+ return (React.createElement(BlockText, { text: (0, tags_js_1.resolveTags)(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: actionPress(block, ctx), pressLabel: block.action === "close" ? "Close" : undefined }));
486
+ case "timer":
487
+ return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
488
+ case "toggle": {
489
+ const toggle = (0, selection_js_1.toggleSelection)(block, ctx.packages, ctx.selectedId);
490
+ if (!toggle)
491
+ return null;
492
+ const flip = () => ctx.onSelect(toggle.target);
493
+ return (React.createElement(react_native_1.Pressable, { accessibilityRole: "switch", accessibilityState: { checked: toggle.on }, onPress: flip, style: [{ flexDirection: "row", alignItems: "center", gap: 12 }, view] },
494
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
495
+ React.createElement(react_native_1.Text, { style: [
496
+ { flex: 1 },
497
+ (0, style_js_1.inheritedTextStyle)(text, ctx.style),
498
+ textOnly(style, styleCtx),
499
+ ] }, (0, tags_js_1.resolveTags)(block.label, ctx.packages.find((p) => p.packageId === ctx.selectedId), ctx.packages)),
500
+ React.createElement(react_native_1.Switch, { value: toggle.on, onValueChange: flip, trackColor: { true: doc.accent } })));
501
+ }
502
+ case "image":
503
+ case "video": {
504
+ const image = block.type === "image";
505
+ const url = image ? block.url || ctx.heroImageUrl : block.poster;
506
+ const round = image && block.shape === "circle";
427
507
  const s = style;
428
508
  // A converted design sizes its own slot; the 160pt default is only for
429
509
  // a slot dropped into a flow column, and must not fight it.
@@ -433,22 +513,20 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
433
513
  s.aspectRatio !== undefined ||
434
514
  (s.top !== undefined && s.bottom !== undefined) ||
435
515
  s.flex !== undefined);
436
- // An icon the design marks as the close (REV-252). Same rule as text:
437
- // the whole box is the target, and the style lands on the Pressable so
438
- // a pinned icon keeps its offsets.
439
- const closeImage = closePress(block, ctx);
440
- const ImageBox = closeImage ? react_native_1.Pressable : react_native_1.View;
441
- return (React.createElement(ImageBox, { accessibilityRole: closeImage ? "button" : undefined, accessibilityLabel: closeImage ? "Close" : undefined, hitSlop: closeImage ? 8 : undefined, onPress: closeImage, style: [
516
+ const action = block.type === "image" ? block.action : undefined;
517
+ const imagePress = actionPress({ action }, ctx);
518
+ const ImageBox = imagePress ? react_native_1.Pressable : react_native_1.View;
519
+ return (React.createElement(ImageBox, { accessibilityRole: imagePress ? "button" : undefined, accessibilityLabel: imagePress && action ? ACTION_LABEL[action] : undefined, hitSlop: imagePress ? 8 : undefined, onPress: imagePress, style: [
442
520
  sized ? null : { height: 160 },
443
521
  { borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
444
522
  view,
445
523
  ] },
446
524
  React.createElement(BlockFill, { block: block, ctx: ctx }),
447
- url ? (React.createElement(react_native_1.Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : (React.createElement(react_native_1.View, { style: {
525
+ url ? (React.createElement(react_native_1.Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : image ? (React.createElement(react_native_1.View, { style: {
448
526
  width: "100%",
449
527
  height: "100%",
450
528
  backgroundColor: (0, color_js_1.withAlpha)("#7d879b", 0.2),
451
- } }))));
529
+ } })) : null));
452
530
  }
453
531
  case "list":
454
532
  return (React.createElement(react_native_1.View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
@@ -479,12 +557,13 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
479
557
  // A button the design marks as the close dismisses instead of buying,
480
558
  // and takes no accent fill: the CTA must stay the one accented thing on
481
559
  // the screen, or a "Not now" competes with "Subscribe" for the eye.
482
- const close = closePress(block, ctx);
560
+ const close = block.action === "close" ? ctx.onClose : undefined;
561
+ const navigates = block.action === "next" || block.action === "back";
483
562
  // A purchase in flight: the button keeps its box and fill, loses its
484
563
  // label to a spinner in the ink colour, and ignores taps. A close
485
564
  // button is never blocked — the user can always leave.
486
- const busy = !close && ctx.loading;
487
- return (React.createElement(react_native_1.Pressable, { accessibilityRole: "button", accessibilityState: { disabled: busy, busy }, disabled: busy, onPress: close ?? ctx.onPurchase, style: ({ pressed }) => [
565
+ const busy = !close && !navigates && ctx.loading;
566
+ return (React.createElement(react_native_1.Pressable, { accessibilityRole: "button", accessibilityState: { disabled: busy, busy }, disabled: busy, onPress: navigates ? actionPress(block, ctx) : (close ?? ctx.onPurchase), style: ({ pressed }) => [
488
567
  {
489
568
  backgroundColor: close ? "transparent" : doc.accent,
490
569
  borderRadius: 12,
@@ -557,20 +636,23 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
557
636
  return (React.createElement(react_native_1.View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
558
637
  case "card": {
559
638
  const layout = block.layout ?? "column";
639
+ const carousel = layout === "carousel";
560
640
  const base = layout === "stack"
561
641
  ? { position: "relative" }
562
- : layout === "grid"
563
- ? {
564
- position: "relative",
565
- flexDirection: "row",
566
- flexWrap: "wrap",
567
- gap: style?.gap ?? 10,
568
- }
569
- : {
570
- position: "relative",
571
- flexDirection: layout === "row" ? "row" : "column",
572
- gap: style?.gap ?? 10,
573
- };
642
+ : carousel
643
+ ? { position: "relative", overflow: "hidden" }
644
+ : layout === "grid"
645
+ ? {
646
+ position: "relative",
647
+ flexDirection: "row",
648
+ flexWrap: "wrap",
649
+ gap: style?.gap ?? 10,
650
+ }
651
+ : {
652
+ position: "relative",
653
+ flexDirection: layout === "row" ? "row" : "column",
654
+ gap: style?.gap ?? 10,
655
+ };
574
656
  const tracks = layout === "grid" ? gridTracks(block) : null;
575
657
  const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
576
658
  const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
@@ -614,6 +696,9 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
614
696
  base,
615
697
  layout === "stack" ? null : { borderRadius: 16 },
616
698
  view,
699
+ carousel
700
+ ? { height: typeof style?.height === "number" ? style.height : 220 }
701
+ : null,
617
702
  ];
618
703
  const cardPackage = block.packageIndex !== undefined
619
704
  ? (ctx.packages[block.packageIndex] ?? pkg)
@@ -622,7 +707,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
622
707
  // how hand-styled plan rows (a highlighted annual beside a plain
623
708
  // monthly) become tappable without a products block.
624
709
  const pinned = block.packageIndex !== undefined && cardPackage !== undefined;
625
- 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)));
710
+ 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))));
626
711
  }
627
712
  }
628
713
  }
@@ -634,6 +719,14 @@ function RevnixBlockPaywall(props) {
634
719
  // re-runs on every selection tap.
635
720
  const doc = React.useMemo(() => (0, locale_js_1.localizeBlockDoc)(props.doc, props.locale ?? (0, locale_js_1.detectLocale)()), [props.doc, props.locale]);
636
721
  const [internalSelected, setInternalSelected] = React.useState(undefined);
722
+ const [shownAt] = React.useState(Date.now);
723
+ const [screenIndex, setScreenIndex] = React.useState(0);
724
+ const [screenDoc, setScreenDoc] = React.useState(props.doc);
725
+ if (screenDoc !== props.doc) {
726
+ setScreenDoc(props.doc);
727
+ setScreenIndex(0);
728
+ }
729
+ const screen = (0, types_js_1.onboardingScreen)(doc, screenIndex);
637
730
  // Contract §1: host → own tap → design highlight → first package, each
638
731
  // only when the offering carries it.
639
732
  const selectedId = (0, selection_js_1.selectedPackageId)(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
@@ -669,21 +762,26 @@ function RevnixBlockPaywall(props) {
669
762
  onTerms: props.onTerms,
670
763
  onPrivacy: props.onPrivacy,
671
764
  onClose: props.onClose,
765
+ navigate: (action) => {
766
+ const to = (0, types_js_1.stepScreen)(doc, screenIndex, action);
767
+ return to === undefined ? undefined : () => setScreenIndex(to);
768
+ },
672
769
  footer: props.footer,
673
770
  heroImageUrl: props.heroImageUrl,
674
771
  gradient: props.gradient,
675
772
  onDiagnostic: props.onDiagnostic,
676
773
  style: styleCtx,
677
774
  loading: props.loading === true,
775
+ shownAt,
678
776
  };
679
- 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 })));
777
+ 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 })));
680
778
  // REV-252: the renderer's own close, drawn only for a design that authors
681
779
  // none. Rendered as a sibling of the tree — outside the canvas scale — so
682
780
  // it keeps its tap size and its distance from the screen edge whatever the
683
781
  // device width does to the design.
684
782
  const safeAreaTop = props.safeAreaTop ??
685
783
  (react_native_1.Platform.OS === "android" ? (react_native_1.StatusBar.currentHeight ?? 0) : 0);
686
- const fallbackClose = props.onClose && !(0, types_js_1.hasCloseAction)(doc.blocks) ? (React.createElement(FallbackClose, { doc: doc, onPress: props.onClose, safeAreaTop: safeAreaTop })) : null;
784
+ const fallbackClose = props.onClose && !(0, types_js_1.hasCloseAction)(screen.blocks) ? (React.createElement(FallbackClose, { doc: doc, onPress: props.onClose, safeAreaTop: safeAreaTop })) : null;
687
785
  const [width, setWidth] = React.useState(null);
688
786
  const [viewportHeight, setViewportHeight] = React.useState(null);
689
787
  if (doc.layout === "canvas") {
@@ -704,7 +802,7 @@ function RevnixBlockPaywall(props) {
704
802
  const canvasHeight = metrics?.scaledHeight ?? types_js_1.DESIGN_CANVAS.height;
705
803
  const left = metrics?.left ?? 0;
706
804
  return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
707
- React.createElement(react_native_1.ScrollView, { contentContainerStyle: { height: canvasHeight },
805
+ React.createElement(react_native_1.ScrollView, { key: screenIndex, contentContainerStyle: { height: canvasHeight },
708
806
  // A designed screen never shows a scrollbar — the web preview has
709
807
  // none, and in an RTL app the indicator lands on the left edge of
710
808
  // the artwork. When nothing overflows, scrolling is off entirely so
@@ -728,15 +826,18 @@ function RevnixBlockPaywall(props) {
728
826
  { translateY: -((canvasHeight / scale) * (1 - scale)) / 2 },
729
827
  { scale },
730
828
  ],
731
- } }, children))),
829
+ } },
830
+ children,
831
+ screen.dots ? (React.createElement(Dots, { count: screen.dots.count, active: screen.dots.active, doc: doc, style: { top: 20, zIndex: 50 } })) : null))),
732
832
  fallbackClose));
733
833
  }
734
834
  return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
735
- React.createElement(react_native_1.ScrollView, { contentContainerStyle: {
835
+ React.createElement(react_native_1.ScrollView, { key: screenIndex, contentContainerStyle: {
736
836
  gap: 12,
737
837
  paddingHorizontal: 20,
738
838
  paddingTop: 18,
739
839
  paddingBottom: 30,
740
840
  }, showsVerticalScrollIndicator: false, alwaysBounceVertical: false, overScrollMode: "never" }, children),
841
+ screen.dots ? (React.createElement(Dots, { count: screen.dots.count, active: screen.dots.active, doc: doc, style: { top: 20 + safeAreaTop, zIndex: 50 } })) : null,
741
842
  fallbackClose));
742
843
  }
@@ -76,7 +76,16 @@ function asBlockDoc(value) {
76
76
  typeof doc.textColor === "string" &&
77
77
  typeof doc.accent === "string" &&
78
78
  typeof doc.accentInk === "string"
79
- ? doc
79
+ ? {
80
+ ...doc,
81
+ ...(doc.screens !== undefined
82
+ ? {
83
+ screens: Array.isArray(doc.screens)
84
+ ? doc.screens.filter((s) => Array.isArray(s) && s.length > 0)
85
+ : [],
86
+ }
87
+ : {}),
88
+ }
80
89
  : null;
81
90
  }
82
91
  exports.DEFAULT_PAYWALL_THEME = {
@@ -289,7 +298,7 @@ function RevnixPaywall(props) {
289
298
  // arrives after a placeholder (or is swapped by an A/B assignment) would
290
299
  // otherwise crash on "rendered more hooks than during the previous render".
291
300
  const { config } = props;
292
- const blocks = asBlockDoc(config.blocks);
301
+ const blocks = React.useMemo(() => asBlockDoc(config.blocks), [config.blocks]);
293
302
  // Both sinks fire when both are wired; nil when neither is, which is what
294
303
  // stops the renderer building a message nobody will read.
295
304
  const renderDiagnostic = client?.reportRenderDiagnostic || props.onDiagnostic
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.6.0";
1
+ export declare const SDK_VERSION = "1.8.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@ exports.SDK_VERSION = void 0;
4
4
  // L3: the single source of the SDK version inside src/. Must match
5
5
  // package.json "version" — version.test.ts fails the build when they drift
6
6
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
7
- exports.SDK_VERSION = "1.6.0";
7
+ exports.SDK_VERSION = "1.8.0";
package/ios/RevnixStore.m CHANGED
@@ -15,4 +15,8 @@ RCT_EXTERN_METHOD(finish:(NSString *)transactionId
15
15
  resolver:(RCTPromiseResolveBlock)resolve
16
16
  rejecter:(RCTPromiseRejectBlock)reject)
17
17
 
18
+ RCT_EXTERN_METHOD(deviceIntegrity:(NSString *)customerId
19
+ resolver:(RCTPromiseResolveBlock)resolve
20
+ rejecter:(RCTPromiseRejectBlock)reject)
21
+
18
22
  @end
@@ -1,3 +1,5 @@
1
+ import CryptoKit
2
+ import DeviceCheck
1
3
  import Foundation
2
4
  import React
3
5
  import StoreKit
@@ -88,6 +90,33 @@ class RevnixStore: RCTEventEmitter {
88
90
  }
89
91
  }
90
92
 
93
+ @objc(deviceIntegrity:resolver:rejecter:)
94
+ func deviceIntegrity(
95
+ _ customerId: String,
96
+ resolver resolve: @escaping RCTPromiseResolveBlock,
97
+ rejecter reject: @escaping RCTPromiseRejectBlock
98
+ ) {
99
+ let service = DCAppAttestService.shared
100
+ guard service.isSupported else {
101
+ resolve(nil)
102
+ return
103
+ }
104
+ Task {
105
+ do {
106
+ let keyId = try await service.generateKey()
107
+ let clientDataHash = Data(SHA256.hash(data: Data(customerId.utf8)))
108
+ let attestation = try await service.attestKey(keyId, clientDataHash: clientDataHash)
109
+ resolve([
110
+ "platform": "ios",
111
+ "keyId": keyId,
112
+ "attestation": attestation.base64EncodedString(),
113
+ ])
114
+ } catch {
115
+ resolve(nil)
116
+ }
117
+ }
118
+ }
119
+
91
120
  private static func payload(_ verification: VerificationResult<Transaction>) -> [String: Any] {
92
121
  let transaction = verification.unsafePayloadValue
93
122
  var payload: [String: Any] = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-revnix",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Revnix SDK for React, React Native & Next.js: purchase registration, entitlements, identity, and placements over the Revnix /v1 API.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -125,7 +125,14 @@ function localizeBlock(
125
125
  case "text":
126
126
  return { ...block, text: at("text", block.text) };
127
127
  case "button":
128
+ case "toggle":
128
129
  return { ...block, label: at("label", block.label) };
130
+ case "timer":
131
+ return {
132
+ ...block,
133
+ format: opt("format", block.format),
134
+ expiredText: opt("expiredText", block.expiredText),
135
+ };
129
136
  case "list":
130
137
  return {
131
138
  ...block,
@@ -186,6 +193,13 @@ export function localizeBlockDoc(
186
193
  ...doc,
187
194
  defaultLocale: chain[0],
188
195
  blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
196
+ ...(doc.screens
197
+ ? {
198
+ screens: doc.screens.map((screen) =>
199
+ screen.map((b) => localizeBlock(b, lookup))
200
+ ),
201
+ }
202
+ : {}),
189
203
  };
190
204
  }
191
205
 
@@ -11,6 +11,7 @@ import type {
11
11
  CardBlock,
12
12
  PaywallBlock,
13
13
  PaywallBlockDoc,
14
+ ToggleBlock,
14
15
  } from "./types.js";
15
16
  import { DESIGN_CANVAS } from "./types.js";
16
17
  import { resolveTags } from "./tags.js";
@@ -33,6 +34,18 @@ export function selectedPackageId(
33
34
  return packages[0]?.packageId;
34
35
  }
35
36
 
37
+ export function toggleSelection(
38
+ block: ToggleBlock,
39
+ packages: ReadonlyArray<{ packageId: string }>,
40
+ selectedId: string | undefined
41
+ ): { on: boolean; target: string } | null {
42
+ const on = packages[block.onPackageIndex];
43
+ const off = packages[block.offPackageIndex];
44
+ if (on === undefined || off === undefined) return null;
45
+ const isOn = on.packageId === selectedId;
46
+ return { on: isOn, target: (isOn ? off : on).packageId };
47
+ }
48
+
36
49
  /** The context a package-bearing card opens for itself and its subtree. */
37
50
  export function packageContext(
38
51
  pkg: { packageId: string } | undefined,
@@ -11,6 +11,7 @@
11
11
  // approximated — a wrong price must never reach a customer silently.
12
12
 
13
13
  import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
14
+ import type { TimerBlock } from "./types.js";
14
15
 
15
16
  /** Renewal cycles, in months. Lifetime and one-time products have no cycle. */
16
17
  const MONTHS: Record<string, number | undefined> = {
@@ -123,3 +124,40 @@ export function resolveTags(
123
124
  }
124
125
  });
125
126
  }
127
+
128
+ export function formatCountdown(format: string, remaining: number): string {
129
+ const pad = (n: number) => String(n).padStart(2, "0");
130
+ let span = Infinity;
131
+ const part = (token: string, unit: number) => {
132
+ const value = Math.floor((remaining % span) / unit);
133
+ if (format.includes(token)) span = unit;
134
+ return value;
135
+ };
136
+ const days = part("{d}", 86400);
137
+ const hours = part("{hh}", 3600);
138
+ const minutes = part("{mm}", 60);
139
+ const seconds = part("{ss}", 1);
140
+ return format
141
+ .replace(/\{d\}/g, String(days))
142
+ .replace(/\{hh\}/g, pad(hours))
143
+ .replace(/\{mm\}/g, pad(minutes))
144
+ .replace(/\{ss\}/g, pad(seconds));
145
+ }
146
+
147
+ export function timerText(
148
+ block: TimerBlock,
149
+ shownAt: number,
150
+ now: number,
151
+ ): string | null {
152
+ const deadline =
153
+ block.endsAt !== undefined
154
+ ? Date.parse(block.endsAt)
155
+ : shownAt + (block.duration ?? 0) * 1000;
156
+ const remaining = Number.isNaN(deadline)
157
+ ? 0
158
+ : Math.max(0, Math.floor((deadline - now) / 1000));
159
+ if (remaining > 0) {
160
+ return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
161
+ }
162
+ return block.expiredText ?? null;
163
+ }