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.
Files changed (41) 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 +7 -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 +45 -3
  11. package/dist/blocks/types.js +1 -0
  12. package/dist/client.d.ts +4 -0
  13. package/dist/client.js +73 -5
  14. package/dist/esm/blocks/locale.js +7 -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 +1 -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 +103 -22
  21. package/dist/esm/version.js +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/store/index.d.ts +8 -0
  24. package/dist/store/index.js +10 -1
  25. package/dist/types.d.ts +32 -0
  26. package/dist/ui/RevnixBlockPaywall.js +100 -19
  27. package/dist/version.d.ts +1 -1
  28. package/dist/version.js +1 -1
  29. package/ios/RevnixStore.m +4 -0
  30. package/ios/RevnixStore.swift +29 -0
  31. package/package.json +1 -1
  32. package/src/blocks/locale.ts +7 -0
  33. package/src/blocks/selection.ts +13 -0
  34. package/src/blocks/tags.ts +38 -0
  35. package/src/blocks/types.ts +51 -2
  36. package/src/client.ts +90 -8
  37. package/src/index.ts +1 -0
  38. package/src/store/index.ts +15 -0
  39. package/src/types.ts +30 -0
  40. package/src/ui/RevnixBlockPaywall.tsx +186 -20
  41. package/src/version.ts +1 -1
package/src/types.ts CHANGED
@@ -139,8 +139,38 @@ export interface RevnixConfig {
139
139
  * Never throws into your app: an exception from this handler is reported
140
140
  * through `onDiagnostic` and swallowed. */
141
141
  onAttribution?: (attribution: RevnixAttribution) => void;
142
+ /** Opt-in device integrity evidence (Apple App Attest / Google Play
143
+ * Integrity) attached to every install report and `setAttribution` call.
144
+ * Pair it with the dashboard's "Require device integrity" setting, which
145
+ * strips attribution from installs that arrive without evidence. In React
146
+ * Native pass `nativeDeviceIntegrity` from "react-native-revnix/store";
147
+ * it needs iOS 14+ with App Attest, or Google Play services on Android.
148
+ * Simulators and emulators return nothing.
149
+ *
150
+ * Called at most once per customer id and bounded to 10s. A rejection,
151
+ * timeout, or missing/malformed result sends the request without
152
+ * evidence and reports `op: "deviceIntegrity"` through `onDiagnostic`. */
153
+ deviceIntegrity?: (
154
+ customerId: string
155
+ ) => Promise<IntegrityEvidence | null | undefined>;
156
+ /** Google Advertising ID source, stored on the customer as `gps_adid` at
157
+ * launch and resent only when it changes. In React Native pass
158
+ * `nativeAdvertisingId` from "react-native-revnix/store"; it needs
159
+ * `play-services-ads-identifier` and the `AD_ID` permission in the app and
160
+ * answers null on iOS and on opt-out, and rejects when the id cannot be
161
+ * read. A null answer deletes a previously stored `gps_adid`; a
162
+ * rejection changes nothing and reports `op: "advertisingId"`. */
163
+ advertisingId?: () => Promise<string | null | undefined>;
142
164
  }
143
165
 
166
+ /** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
167
+ * iOS: an App Attest key id and its attestation (standard base64) over
168
+ * SHA-256 of the customer id. Android: a classic Play Integrity token whose
169
+ * nonce is base64url (no padding) of SHA-256 of the customer id. */
170
+ export type IntegrityEvidence =
171
+ | { platform: "ios"; keyId: string; attestation: string }
172
+ | { platform: "android"; token: string };
173
+
144
174
  /** AT11: the install-attribution verdict — GET
145
175
  * /v1/customers/{id}/attribution. `installMatch: "unknown"` (no install
146
176
  * recorded yet, a normal cold-start race) is never sent to hosts as such;
@@ -26,6 +26,7 @@ import {
26
26
  Pressable,
27
27
  ScrollView,
28
28
  StatusBar,
29
+ Switch,
29
30
  Text,
30
31
  View,
31
32
  type ColorValue,
@@ -60,13 +61,14 @@ import {
60
61
  type ResolvedBackgroundImage,
61
62
  } from "../blocks/background.js";
62
63
  import { detectLocale, linkLabels, localizeBlockDoc } from "../blocks/locale.js";
63
- import { resolveTags } from "../blocks/tags.js";
64
+ import { resolveTags, timerText } from "../blocks/tags.js";
64
65
  import {
65
66
  canvasMetrics,
66
67
  effectiveStyle,
67
68
  isVisible,
68
69
  packageContext,
69
70
  selectedPackageId,
71
+ toggleSelection,
70
72
  } from "../blocks/selection.js";
71
73
  import type { SelectionContext } from "../blocks/selection.js";
72
74
  import {
@@ -76,6 +78,7 @@ import {
76
78
  type PaywallBlock,
77
79
  type PaywallBlockDoc,
78
80
  type ProductsBlock,
81
+ type TimerBlock,
79
82
  } from "../blocks/types.js";
80
83
  import type { RevnixPaywallPackage } from "./RevnixPaywall.js";
81
84
 
@@ -607,6 +610,7 @@ interface RenderContext {
607
610
  style: StyleContext;
608
611
  /** Host purchase in flight: purchase buttons disable and spin. */
609
612
  loading: boolean;
613
+ shownAt: number;
610
614
  }
611
615
 
612
616
  /** Text blocks always render inside a View: a style may carry both typography
@@ -669,6 +673,108 @@ function BlockText({
669
673
  return <View style={toViewStyle(style, styleCtx)}>{body}</View>;
670
674
  }
671
675
 
676
+ function Timer({
677
+ block,
678
+ style,
679
+ ctx,
680
+ inherited,
681
+ }: {
682
+ block: TimerBlock;
683
+ style: BlockStyle | undefined;
684
+ ctx: RenderContext;
685
+ inherited: InheritedText;
686
+ }) {
687
+ const [now, setNow] = React.useState(Date.now);
688
+ React.useEffect(() => {
689
+ const id = setInterval(() => setNow(Date.now()), 1000);
690
+ return () => clearInterval(id);
691
+ }, []);
692
+ const text = timerText(block, ctx.shownAt, now);
693
+ if (text === null) return null;
694
+ return <BlockText text={text} style={style} ctx={ctx} inherited={inherited} />;
695
+ }
696
+
697
+ function Carousel({
698
+ pages,
699
+ autoAdvance,
700
+ doc,
701
+ }: {
702
+ pages: React.ReactNode[];
703
+ autoAdvance?: number;
704
+ doc: PaywallBlockDoc;
705
+ }) {
706
+ const scroller = React.useRef<ScrollView>(null);
707
+ const [size, setSize] = React.useState({ width: 0, height: 0 });
708
+ const [page, setPage] = React.useState(0);
709
+ React.useEffect(() => {
710
+ if (!autoAdvance || size.width === 0 || pages.length < 2) return;
711
+ const id = setInterval(() => {
712
+ scroller.current?.scrollTo({
713
+ x: ((page + 1) % pages.length) * size.width,
714
+ animated: true,
715
+ });
716
+ }, autoAdvance * 1000);
717
+ return () => clearInterval(id);
718
+ }, [autoAdvance, size.width, page, pages.length]);
719
+ return (
720
+ <View
721
+ style={{ flex: 1 }}
722
+ onLayout={(e) =>
723
+ setSize({
724
+ width: e.nativeEvent.layout.width,
725
+ height: e.nativeEvent.layout.height,
726
+ })
727
+ }
728
+ >
729
+ <ScrollView
730
+ ref={scroller}
731
+ horizontal
732
+ pagingEnabled
733
+ showsHorizontalScrollIndicator={false}
734
+ scrollEventThrottle={16}
735
+ onScroll={(e) => {
736
+ if (size.width > 0) {
737
+ setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
738
+ }
739
+ }}
740
+ >
741
+ {pages.map((node, i) => (
742
+ <View key={i} style={size}>
743
+ {node}
744
+ </View>
745
+ ))}
746
+ </ScrollView>
747
+ {pages.length > 1 ? (
748
+ <View
749
+ pointerEvents="none"
750
+ style={{
751
+ position: "absolute",
752
+ bottom: 8,
753
+ left: 0,
754
+ right: 0,
755
+ flexDirection: "row",
756
+ justifyContent: "center",
757
+ gap: 6,
758
+ }}
759
+ >
760
+ {pages.map((_, i) => (
761
+ <View
762
+ key={i}
763
+ style={{
764
+ width: 6,
765
+ height: 6,
766
+ borderRadius: 3,
767
+ backgroundColor:
768
+ i === page ? doc.accent : withAlpha(doc.textColor, 0.3),
769
+ }}
770
+ />
771
+ ))}
772
+ </View>
773
+ ) : null}
774
+ </View>
775
+ );
776
+ }
777
+
672
778
  /** The press handler for a block the design marks as the close, or undefined
673
779
  * when it is not one (or the host wired no dismissal). One helper so text,
674
780
  * image and button blocks cannot drift on what "action: close" means. */
@@ -844,6 +950,11 @@ function Block({
844
950
  selection?: SelectionContext;
845
951
  }): React.ReactElement | null {
846
952
  const { doc } = ctx;
953
+ React.useEffect(() => {
954
+ if (block.type === "video") {
955
+ ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
956
+ }
957
+ }, [block.type]);
847
958
  const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
848
959
  // A pinned card judges its own context; every other block inherits the
849
960
  // nearest plan card's. (A repeat card is judged per instance below.)
@@ -876,9 +987,48 @@ function Block({
876
987
  />
877
988
  );
878
989
 
879
- case "image": {
880
- const url = block.url || ctx.heroImageUrl;
881
- const round = block.shape === "circle";
990
+ case "timer":
991
+ return <Timer block={block} style={style} ctx={ctx} inherited={inherited} />;
992
+
993
+ case "toggle": {
994
+ const toggle = toggleSelection(block, ctx.packages, ctx.selectedId);
995
+ if (!toggle) return null;
996
+ const flip = () => ctx.onSelect(toggle.target);
997
+ return (
998
+ <Pressable
999
+ accessibilityRole="switch"
1000
+ accessibilityState={{ checked: toggle.on }}
1001
+ onPress={flip}
1002
+ style={[{ flexDirection: "row", alignItems: "center", gap: 12 }, view]}
1003
+ >
1004
+ <BlockFill block={block} ctx={ctx} />
1005
+ <Text
1006
+ style={[
1007
+ { flex: 1 },
1008
+ inheritedTextStyle(text, ctx.style),
1009
+ textOnly(style, styleCtx),
1010
+ ]}
1011
+ >
1012
+ {resolveTags(
1013
+ block.label,
1014
+ ctx.packages.find((p) => p.packageId === ctx.selectedId),
1015
+ ctx.packages
1016
+ )}
1017
+ </Text>
1018
+ <Switch
1019
+ value={toggle.on}
1020
+ onValueChange={flip}
1021
+ trackColor={{ true: doc.accent }}
1022
+ />
1023
+ </Pressable>
1024
+ );
1025
+ }
1026
+
1027
+ case "image":
1028
+ case "video": {
1029
+ const image = block.type === "image";
1030
+ const url = image ? block.url || ctx.heroImageUrl : block.poster;
1031
+ const round = image && block.shape === "circle";
882
1032
  const s = style;
883
1033
  // A converted design sizes its own slot; the 160pt default is only for
884
1034
  // a slot dropped into a flow column, and must not fight it.
@@ -892,7 +1042,7 @@ function Block({
892
1042
  // An icon the design marks as the close (REV-252). Same rule as text:
893
1043
  // the whole box is the target, and the style lands on the Pressable so
894
1044
  // a pinned icon keeps its offsets.
895
- const closeImage = closePress(block, ctx);
1045
+ const closeImage = image ? closePress(block, ctx) : undefined;
896
1046
  const ImageBox = closeImage ? Pressable : View;
897
1047
  return (
898
1048
  <ImageBox
@@ -913,7 +1063,7 @@ function Block({
913
1063
  resizeMode={block.fit ?? "cover"}
914
1064
  style={{ width: "100%", height: "100%" }}
915
1065
  />
916
- ) : (
1066
+ ) : image ? (
917
1067
  <View
918
1068
  style={{
919
1069
  width: "100%",
@@ -921,7 +1071,7 @@ function Block({
921
1071
  backgroundColor: withAlpha("#7d879b", 0.2),
922
1072
  }}
923
1073
  />
924
- )}
1074
+ ) : null}
925
1075
  </ImageBox>
926
1076
  );
927
1077
  }
@@ -1101,21 +1251,24 @@ function Block({
1101
1251
 
1102
1252
  case "card": {
1103
1253
  const layout = block.layout ?? "column";
1254
+ const carousel = layout === "carousel";
1104
1255
  const base: ViewStyle =
1105
1256
  layout === "stack"
1106
1257
  ? { position: "relative" }
1107
- : layout === "grid"
1108
- ? {
1109
- position: "relative",
1110
- flexDirection: "row",
1111
- flexWrap: "wrap",
1112
- gap: style?.gap ?? 10,
1113
- }
1114
- : {
1115
- position: "relative",
1116
- flexDirection: layout === "row" ? "row" : "column",
1117
- gap: style?.gap ?? 10,
1118
- };
1258
+ : carousel
1259
+ ? { position: "relative", overflow: "hidden" }
1260
+ : layout === "grid"
1261
+ ? {
1262
+ position: "relative",
1263
+ flexDirection: "row",
1264
+ flexWrap: "wrap",
1265
+ gap: style?.gap ?? 10,
1266
+ }
1267
+ : {
1268
+ position: "relative",
1269
+ flexDirection: layout === "row" ? "row" : "column",
1270
+ gap: style?.gap ?? 10,
1271
+ };
1119
1272
 
1120
1273
  const tracks = layout === "grid" ? gridTracks(block) : null;
1121
1274
  const renderChildren = (
@@ -1202,6 +1355,9 @@ function Block({
1202
1355
  base,
1203
1356
  layout === "stack" ? null : { borderRadius: 16 },
1204
1357
  view,
1358
+ carousel
1359
+ ? { height: typeof style?.height === "number" ? style.height : 220 }
1360
+ : null,
1205
1361
  ];
1206
1362
 
1207
1363
  const cardPackage =
@@ -1222,7 +1378,15 @@ function Block({
1222
1378
  radius={style?.radius ?? (layout === "stack" ? undefined : 16)}
1223
1379
  onPress={pinned ? () => ctx.onSelect(cardPackage!.packageId) : undefined}
1224
1380
  >
1225
- {renderChildren(cardPackage, context)}
1381
+ {carousel ? (
1382
+ <Carousel
1383
+ pages={renderChildren(cardPackage, context)}
1384
+ autoAdvance={block.autoAdvance}
1385
+ doc={doc}
1386
+ />
1387
+ ) : (
1388
+ renderChildren(cardPackage, context)
1389
+ )}
1226
1390
  </CardBox>
1227
1391
  );
1228
1392
  }
@@ -1243,6 +1407,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1243
1407
  const [internalSelected, setInternalSelected] = React.useState<
1244
1408
  string | undefined
1245
1409
  >(undefined);
1410
+ const [shownAt] = React.useState(Date.now);
1246
1411
  // Contract §1: host → own tap → design highlight → first package, each
1247
1412
  // only when the offering carries it.
1248
1413
  const selectedId = selectedPackageId(
@@ -1292,6 +1457,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1292
1457
  onDiagnostic: props.onDiagnostic,
1293
1458
  style: styleCtx,
1294
1459
  loading: props.loading === true,
1460
+ shownAt,
1295
1461
  };
1296
1462
 
1297
1463
  const children = doc.blocks.map((block) => (
package/src/version.ts 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.6.0";
4
+ export const SDK_VERSION = "1.7.0";