react-native-revnix 1.5.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 (49) 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 +50 -11
  13. package/dist/client.js +408 -119
  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 +407 -119
  19. package/dist/esm/store/index.js +7 -0
  20. package/dist/esm/ui/RevnixBlockPaywall.js +103 -22
  21. package/dist/esm/ui/RevnixPaywall.js +7 -0
  22. package/dist/esm/ui/index.js +1 -1
  23. package/dist/esm/version.js +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/store/index.d.ts +8 -0
  26. package/dist/store/index.js +10 -1
  27. package/dist/types.d.ts +32 -0
  28. package/dist/ui/RevnixBlockPaywall.js +100 -19
  29. package/dist/ui/RevnixPaywall.d.ts +6 -2
  30. package/dist/ui/RevnixPaywall.js +8 -0
  31. package/dist/ui/index.d.ts +1 -1
  32. package/dist/ui/index.js +2 -1
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/ios/RevnixStore.m +4 -0
  36. package/ios/RevnixStore.swift +29 -0
  37. package/package.json +1 -1
  38. package/src/blocks/locale.ts +7 -0
  39. package/src/blocks/selection.ts +13 -0
  40. package/src/blocks/tags.ts +38 -0
  41. package/src/blocks/types.ts +51 -2
  42. package/src/client.ts +478 -154
  43. package/src/index.ts +1 -0
  44. package/src/store/index.ts +15 -0
  45. package/src/types.ts +30 -0
  46. package/src/ui/RevnixBlockPaywall.tsx +186 -20
  47. package/src/ui/RevnixPaywall.tsx +15 -1
  48. package/src/ui/index.ts +1 -0
  49. package/src/version.ts +1 -1
@@ -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 "image": {
389
- const url = block.url || ctx.heroImageUrl;
390
- const round = block.shape === "circle";
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
- : 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
- };
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
@@ -18,6 +18,7 @@ import * as React from "react";
18
18
  import { ActivityIndicator, Alert, Image, Linking, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native";
19
19
  import { PREVIEW_PLACEMENT_KEY } from "../implicit.js";
20
20
  import { getStoreAdapter } from "../store.js";
21
+ import { paywallImageUrls } from "../client.js";
21
22
  import { withAlpha } from "../blocks/color.js";
22
23
  import { RevnixBlockPaywall, } from "./RevnixBlockPaywall.js";
23
24
  /** `config.blocks` is opaque on the core config (the non-UI entrypoint must
@@ -66,6 +67,12 @@ const CURRENCY_SYMBOL = /[$€£¥₹₩₽₺₫₪฿₴₦₱]/;
66
67
  // part of the public theme — derived from the config's mode, in lockstep
67
68
  // with the dashboard preview's SCREEN_PALETTES.card.
68
69
  const CARD_BG = { dark: "#181b22", light: "#f4f5f7" };
70
+ /** Warms React Native's image cache with every image `resolution`'s paywall
71
+ * loads, so it paints without waiting on the network. Pair it with
72
+ * `client.preload` / `resolvePlacement`. Never throws. */
73
+ export async function prefetchPaywallImages(resolution) {
74
+ await Promise.all(paywallImageUrls(resolution?.paywall).map((url) => Image.prefetch(url).catch(() => false)));
75
+ }
69
76
  export function RevnixPaywall(props) {
70
77
  // One view per mount: remounting (a re-shown paywall) is a genuine new
71
78
  // display; re-renders are not.
@@ -1,4 +1,4 @@
1
- export { RevnixPaywall, DEFAULT_PAYWALL_THEME, LIGHT_PAYWALL_THEME, } from "./RevnixPaywall.js";
1
+ export { RevnixPaywall, DEFAULT_PAYWALL_THEME, LIGHT_PAYWALL_THEME, prefetchPaywallImages, } from "./RevnixPaywall.js";
2
2
  export { RevnixBlockPaywall, } from "./RevnixBlockPaywall.js";
3
3
  export { DESIGN_CANVAS, } from "../blocks/types.js";
4
4
  export { backgroundBaseColor, backgroundLayers, coverPlacement, } from "../blocks/background.js";
@@ -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.5.0";
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: {
@@ -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);
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;
@@ -295,6 +295,56 @@ function BlockText({ text, style, ctx, inherited, onPress, }) {
295
295
  }
296
296
  return React.createElement(react_native_1.View, { style: (0, style_js_1.toViewStyle)(style, styleCtx) }, body);
297
297
  }
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(react_native_1.View, { pointerEvents: "none", style: {
334
+ position: "absolute",
335
+ bottom: 8,
336
+ left: 0,
337
+ right: 0,
338
+ flexDirection: "row",
339
+ justifyContent: "center",
340
+ gap: 6,
341
+ } }, pages.map((_, i) => (React.createElement(react_native_1.View, { key: i, style: {
342
+ width: 6,
343
+ height: 6,
344
+ borderRadius: 3,
345
+ backgroundColor: i === page ? doc.accent : (0, color_js_1.withAlpha)(doc.textColor, 0.3),
346
+ } }))))) : null));
347
+ }
298
348
  /** The press handler for a block the design marks as the close, or undefined
299
349
  * when it is not one (or the host wired no dismissal). One helper so text,
300
350
  * image and button blocks cannot drift on what "action: close" means. */
@@ -399,6 +449,11 @@ function gridTracks(block) {
399
449
  }
400
450
  function Block({ block, ctx, pkg, inherited, selection, }) {
401
451
  const { doc } = ctx;
452
+ React.useEffect(() => {
453
+ if (block.type === "video") {
454
+ ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
455
+ }
456
+ }, [block.type]);
402
457
  const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
403
458
  // A pinned card judges its own context; every other block inherits the
404
459
  // nearest plan card's. (A repeat card is judged per instance below.)
@@ -421,9 +476,27 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
421
476
  switch (block.type) {
422
477
  case "text":
423
478
  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";
479
+ case "timer":
480
+ return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
481
+ case "toggle": {
482
+ const toggle = (0, selection_js_1.toggleSelection)(block, ctx.packages, ctx.selectedId);
483
+ if (!toggle)
484
+ return null;
485
+ const flip = () => ctx.onSelect(toggle.target);
486
+ return (React.createElement(react_native_1.Pressable, { accessibilityRole: "switch", accessibilityState: { checked: toggle.on }, onPress: flip, style: [{ flexDirection: "row", alignItems: "center", gap: 12 }, view] },
487
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
488
+ React.createElement(react_native_1.Text, { style: [
489
+ { flex: 1 },
490
+ (0, style_js_1.inheritedTextStyle)(text, ctx.style),
491
+ textOnly(style, styleCtx),
492
+ ] }, (0, tags_js_1.resolveTags)(block.label, ctx.packages.find((p) => p.packageId === ctx.selectedId), ctx.packages)),
493
+ React.createElement(react_native_1.Switch, { value: toggle.on, onValueChange: flip, trackColor: { true: doc.accent } })));
494
+ }
495
+ case "image":
496
+ case "video": {
497
+ const image = block.type === "image";
498
+ const url = image ? block.url || ctx.heroImageUrl : block.poster;
499
+ const round = image && block.shape === "circle";
427
500
  const s = style;
428
501
  // A converted design sizes its own slot; the 160pt default is only for
429
502
  // a slot dropped into a flow column, and must not fight it.
@@ -436,7 +509,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
436
509
  // An icon the design marks as the close (REV-252). Same rule as text:
437
510
  // the whole box is the target, and the style lands on the Pressable so
438
511
  // a pinned icon keeps its offsets.
439
- const closeImage = closePress(block, ctx);
512
+ const closeImage = image ? closePress(block, ctx) : undefined;
440
513
  const ImageBox = closeImage ? react_native_1.Pressable : react_native_1.View;
441
514
  return (React.createElement(ImageBox, { accessibilityRole: closeImage ? "button" : undefined, accessibilityLabel: closeImage ? "Close" : undefined, hitSlop: closeImage ? 8 : undefined, onPress: closeImage, style: [
442
515
  sized ? null : { height: 160 },
@@ -444,11 +517,11 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
444
517
  view,
445
518
  ] },
446
519
  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: {
520
+ 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
521
  width: "100%",
449
522
  height: "100%",
450
523
  backgroundColor: (0, color_js_1.withAlpha)("#7d879b", 0.2),
451
- } }))));
524
+ } })) : null));
452
525
  }
453
526
  case "list":
454
527
  return (React.createElement(react_native_1.View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
@@ -557,20 +630,23 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
557
630
  return (React.createElement(react_native_1.View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
558
631
  case "card": {
559
632
  const layout = block.layout ?? "column";
633
+ const carousel = layout === "carousel";
560
634
  const base = layout === "stack"
561
635
  ? { 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
- };
636
+ : carousel
637
+ ? { position: "relative", overflow: "hidden" }
638
+ : layout === "grid"
639
+ ? {
640
+ position: "relative",
641
+ flexDirection: "row",
642
+ flexWrap: "wrap",
643
+ gap: style?.gap ?? 10,
644
+ }
645
+ : {
646
+ position: "relative",
647
+ flexDirection: layout === "row" ? "row" : "column",
648
+ gap: style?.gap ?? 10,
649
+ };
574
650
  const tracks = layout === "grid" ? gridTracks(block) : null;
575
651
  const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
576
652
  const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
@@ -614,6 +690,9 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
614
690
  base,
615
691
  layout === "stack" ? null : { borderRadius: 16 },
616
692
  view,
693
+ carousel
694
+ ? { height: typeof style?.height === "number" ? style.height : 220 }
695
+ : null,
617
696
  ];
618
697
  const cardPackage = block.packageIndex !== undefined
619
698
  ? (ctx.packages[block.packageIndex] ?? pkg)
@@ -622,7 +701,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
622
701
  // how hand-styled plan rows (a highlighted annual beside a plain
623
702
  // monthly) become tappable without a products block.
624
703
  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)));
704
+ 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
705
  }
627
706
  }
628
707
  }
@@ -634,6 +713,7 @@ function RevnixBlockPaywall(props) {
634
713
  // re-runs on every selection tap.
635
714
  const doc = React.useMemo(() => (0, locale_js_1.localizeBlockDoc)(props.doc, props.locale ?? (0, locale_js_1.detectLocale)()), [props.doc, props.locale]);
636
715
  const [internalSelected, setInternalSelected] = React.useState(undefined);
716
+ const [shownAt] = React.useState(Date.now);
637
717
  // Contract §1: host → own tap → design highlight → first package, each
638
718
  // only when the offering carries it.
639
719
  const selectedId = (0, selection_js_1.selectedPackageId)(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
@@ -675,6 +755,7 @@ function RevnixBlockPaywall(props) {
675
755
  onDiagnostic: props.onDiagnostic,
676
756
  style: styleCtx,
677
757
  loading: props.loading === true,
758
+ shownAt,
678
759
  };
679
760
  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 })));
680
761
  // REV-252: the renderer's own close, drawn only for a design that authors
@@ -1,7 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { type StyleProp, type ViewStyle } from "react-native";
3
- import type { PaywallConfig, PaywallInteraction, PaywallPurchaseOutcome, RegisterPurchaseResult } from "../types.js";
4
- import type { RevnixClient } from "../client.js";
3
+ import type { PaywallConfig, PaywallInteraction, PaywallPurchaseOutcome, PlacementResolution, RegisterPurchaseResult } from "../types.js";
4
+ import { type RevnixClient } from "../client.js";
5
5
  import type { FontFamilyMap } from "../blocks/style.js";
6
6
  import { type RevnixBlurComponent, type RevnixGradientComponent } from "./RevnixBlockPaywall.js";
7
7
  /** One purchasable row. `priceLabel` must come from the store (localized). */
@@ -155,4 +155,8 @@ export interface RevnixPaywallProps {
155
155
  * layouts below render `config`'s own copy whatever this says. */
156
156
  locale?: string;
157
157
  }
158
+ /** Warms React Native's image cache with every image `resolution`'s paywall
159
+ * loads, so it paints without waiting on the network. Pair it with
160
+ * `client.preload` / `resolvePlacement`. Never throws. */
161
+ export declare function prefetchPaywallImages(resolution: PlacementResolution | null): Promise<void>;
158
162
  export declare function RevnixPaywall(props: RevnixPaywallProps): React.JSX.Element;
@@ -50,11 +50,13 @@ var __importStar = (this && this.__importStar) || (function () {
50
50
  })();
51
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
52
  exports.LIGHT_PAYWALL_THEME = exports.DEFAULT_PAYWALL_THEME = void 0;
53
+ exports.prefetchPaywallImages = prefetchPaywallImages;
53
54
  exports.RevnixPaywall = RevnixPaywall;
54
55
  const React = __importStar(require("react"));
55
56
  const react_native_1 = require("react-native");
56
57
  const implicit_js_1 = require("../implicit.js");
57
58
  const store_js_1 = require("../store.js");
59
+ const client_js_1 = require("../client.js");
58
60
  const color_js_1 = require("../blocks/color.js");
59
61
  const RevnixBlockPaywall_js_1 = require("./RevnixBlockPaywall.js");
60
62
  /** `config.blocks` is opaque on the core config (the non-UI entrypoint must
@@ -103,6 +105,12 @@ const CURRENCY_SYMBOL = /[$€£¥₹₩₽₺₫₪฿₴₦₱]/;
103
105
  // part of the public theme — derived from the config's mode, in lockstep
104
106
  // with the dashboard preview's SCREEN_PALETTES.card.
105
107
  const CARD_BG = { dark: "#181b22", light: "#f4f5f7" };
108
+ /** Warms React Native's image cache with every image `resolution`'s paywall
109
+ * loads, so it paints without waiting on the network. Pair it with
110
+ * `client.preload` / `resolvePlacement`. Never throws. */
111
+ async function prefetchPaywallImages(resolution) {
112
+ await Promise.all((0, client_js_1.paywallImageUrls)(resolution?.paywall).map((url) => react_native_1.Image.prefetch(url).catch(() => false)));
113
+ }
106
114
  function RevnixPaywall(props) {
107
115
  // One view per mount: remounting (a re-shown paywall) is a genuine new
108
116
  // display; re-renders are not.
@@ -1,4 +1,4 @@
1
- export { RevnixPaywall, DEFAULT_PAYWALL_THEME, LIGHT_PAYWALL_THEME, type RevnixPaywallPackage, type RevnixPaywallProps, type RevnixPaywallTheme, } from "./RevnixPaywall.js";
1
+ export { RevnixPaywall, DEFAULT_PAYWALL_THEME, LIGHT_PAYWALL_THEME, prefetchPaywallImages, type RevnixPaywallPackage, type RevnixPaywallProps, type RevnixPaywallTheme, } from "./RevnixPaywall.js";
2
2
  export { RevnixBlockPaywall, type RevnixBlockPaywallProps, type RevnixBlurComponent, type RevnixBlurProps, type RevnixGradientComponent, type RevnixGradientProps, } from "./RevnixBlockPaywall.js";
3
3
  export { DESIGN_CANVAS, type BlockStyle, type PaywallBlock, type PaywallBlockDoc, } from "../blocks/types.js";
4
4
  export { backgroundBaseColor, backgroundLayers, coverPlacement, type BackgroundFit, type BackgroundImage, type BackgroundLayers, type BackgroundOverlay, type PaywallBackground, type PaywallBackgroundSpec, } from "../blocks/background.js";
package/dist/ui/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resolveTags = exports.minorUnitsForCurrency = exports.setLocale = exports.normalizeLocale = exports.localizeBlockDoc = exports.localeChain = exports.linkLabels = exports.detectLocale = exports.coverPlacement = exports.backgroundLayers = exports.backgroundBaseColor = exports.DESIGN_CANVAS = exports.RevnixBlockPaywall = exports.LIGHT_PAYWALL_THEME = exports.DEFAULT_PAYWALL_THEME = exports.RevnixPaywall = void 0;
3
+ exports.resolveTags = exports.minorUnitsForCurrency = exports.setLocale = exports.normalizeLocale = exports.localizeBlockDoc = exports.localeChain = exports.linkLabels = exports.detectLocale = exports.coverPlacement = exports.backgroundLayers = exports.backgroundBaseColor = exports.DESIGN_CANVAS = exports.RevnixBlockPaywall = exports.prefetchPaywallImages = exports.LIGHT_PAYWALL_THEME = exports.DEFAULT_PAYWALL_THEME = exports.RevnixPaywall = void 0;
4
4
  var RevnixPaywall_js_1 = require("./RevnixPaywall.js");
5
5
  Object.defineProperty(exports, "RevnixPaywall", { enumerable: true, get: function () { return RevnixPaywall_js_1.RevnixPaywall; } });
6
6
  Object.defineProperty(exports, "DEFAULT_PAYWALL_THEME", { enumerable: true, get: function () { return RevnixPaywall_js_1.DEFAULT_PAYWALL_THEME; } });
7
7
  Object.defineProperty(exports, "LIGHT_PAYWALL_THEME", { enumerable: true, get: function () { return RevnixPaywall_js_1.LIGHT_PAYWALL_THEME; } });
8
+ Object.defineProperty(exports, "prefetchPaywallImages", { enumerable: true, get: function () { return RevnixPaywall_js_1.prefetchPaywallImages; } });
8
9
  var RevnixBlockPaywall_js_1 = require("./RevnixBlockPaywall.js");
9
10
  Object.defineProperty(exports, "RevnixBlockPaywall", { enumerable: true, get: function () { return RevnixBlockPaywall_js_1.RevnixBlockPaywall; } });
10
11
  var types_js_1 = require("../blocks/types.js");
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.5.0";
1
+ export declare const SDK_VERSION = "1.7.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.5.0";
7
+ exports.SDK_VERSION = "1.7.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