react-native-revnix 1.7.0 → 1.9.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.
@@ -24,7 +24,7 @@ import { backgroundLayers, coverPlacement, } from "../blocks/background.js";
24
24
  import { detectLocale, linkLabels, localizeBlockDoc } from "../blocks/locale.js";
25
25
  import { resolveTags, timerText } from "../blocks/tags.js";
26
26
  import { canvasMetrics, effectiveStyle, isVisible, packageContext, selectedPackageId, toggleSelection, } from "../blocks/selection.js";
27
- import { DESIGN_CANVAS, hasCloseAction, } from "../blocks/types.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,7 +255,7 @@ 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
  }
@@ -294,27 +294,34 @@ function Carousel({ pages, autoAdvance, doc, }) {
294
294
  setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
295
295
  }
296
296
  } }, pages.map((node, i) => (React.createElement(View, { key: i, style: size }, node)))),
297
- pages.length > 1 ? (React.createElement(View, { pointerEvents: "none", style: {
297
+ pages.length > 1 ? (React.createElement(Dots, { count: pages.length, active: page, doc: doc, style: { bottom: 8 } })) : null));
298
+ }
299
+ function Dots({ count, active, doc, style, }) {
300
+ return (React.createElement(View, { pointerEvents: "none", style: [
301
+ {
298
302
  position: "absolute",
299
- bottom: 8,
300
303
  left: 0,
301
304
  right: 0,
302
305
  flexDirection: "row",
303
306
  justifyContent: "center",
304
307
  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));
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
+ } })))));
311
316
  }
312
- /** The press handler for a block the design marks as the close, or undefined
313
- * when it is not one (or the host wired no dismissal). One helper so text,
314
- * image and button blocks cannot drift on what "action: close" means. */
315
- function closePress(block, ctx) {
316
- return block.action === "close" && ctx.onClose ? ctx.onClose : undefined;
317
+ function actionPress(block, ctx) {
318
+ return block.action === "close" ? ctx.onClose : ctx.navigate(block.action);
317
319
  }
320
+ const ACTION_LABEL = {
321
+ close: "Close",
322
+ next: "Next",
323
+ back: "Back",
324
+ };
318
325
  /** Typography that is NOT inherited (decoration) — everything else already
319
326
  * arrives through the inherited set. */
320
327
  function textOnly(s, ctx) {
@@ -439,7 +446,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
439
446
  const size = text.fontSize;
440
447
  switch (block.type) {
441
448
  case "text":
442
- return (React.createElement(BlockText, { text: resolveTags(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: closePress(block, ctx) }));
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 }));
443
450
  case "timer":
444
451
  return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
445
452
  case "toggle": {
@@ -470,12 +477,10 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
470
477
  s.aspectRatio !== undefined ||
471
478
  (s.top !== undefined && s.bottom !== undefined) ||
472
479
  s.flex !== undefined);
473
- // An icon the design marks as the close (REV-252). Same rule as text:
474
- // the whole box is the target, and the style lands on the Pressable so
475
- // a pinned icon keeps its offsets.
476
- const closeImage = image ? closePress(block, ctx) : undefined;
477
- const ImageBox = closeImage ? Pressable : View;
478
- 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: [
479
484
  sized ? null : { height: 160 },
480
485
  { borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
481
486
  view,
@@ -516,12 +521,13 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
516
521
  // A button the design marks as the close dismisses instead of buying,
517
522
  // and takes no accent fill: the CTA must stay the one accented thing on
518
523
  // the screen, or a "Not now" competes with "Subscribe" for the eye.
519
- const close = closePress(block, ctx);
524
+ const close = block.action === "close" ? ctx.onClose : undefined;
525
+ const navigates = block.action === "next" || block.action === "back";
520
526
  // A purchase in flight: the button keeps its box and fill, loses its
521
527
  // label to a spinner in the ink colour, and ignores taps. A close
522
528
  // button is never blocked — the user can always leave.
523
- const busy = !close && ctx.loading;
524
- 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 }) => [
525
531
  {
526
532
  backgroundColor: close ? "transparent" : doc.accent,
527
533
  borderRadius: 12,
@@ -678,6 +684,13 @@ export function RevnixBlockPaywall(props) {
678
684
  const doc = React.useMemo(() => localizeBlockDoc(props.doc, props.locale ?? detectLocale()), [props.doc, props.locale]);
679
685
  const [internalSelected, setInternalSelected] = React.useState(undefined);
680
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);
681
694
  // Contract §1: host → own tap → design highlight → first package, each
682
695
  // only when the offering carries it.
683
696
  const selectedId = selectedPackageId(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
@@ -713,6 +726,10 @@ export function RevnixBlockPaywall(props) {
713
726
  onTerms: props.onTerms,
714
727
  onPrivacy: props.onPrivacy,
715
728
  onClose: props.onClose,
729
+ navigate: (action) => {
730
+ const to = stepScreen(doc, screenIndex, action);
731
+ return to === undefined ? undefined : () => setScreenIndex(to);
732
+ },
716
733
  footer: props.footer,
717
734
  heroImageUrl: props.heroImageUrl,
718
735
  gradient: props.gradient,
@@ -721,14 +738,14 @@ export function RevnixBlockPaywall(props) {
721
738
  loading: props.loading === true,
722
739
  shownAt,
723
740
  };
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 })));
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 })));
725
742
  // REV-252: the renderer's own close, drawn only for a design that authors
726
743
  // none. Rendered as a sibling of the tree — outside the canvas scale — so
727
744
  // it keeps its tap size and its distance from the screen edge whatever the
728
745
  // device width does to the design.
729
746
  const safeAreaTop = props.safeAreaTop ??
730
747
  (Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0);
731
- 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;
732
749
  const [width, setWidth] = React.useState(null);
733
750
  const [viewportHeight, setViewportHeight] = React.useState(null);
734
751
  if (doc.layout === "canvas") {
@@ -749,7 +766,7 @@ export function RevnixBlockPaywall(props) {
749
766
  const canvasHeight = metrics?.scaledHeight ?? DESIGN_CANVAS.height;
750
767
  const left = metrics?.left ?? 0;
751
768
  return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
752
- React.createElement(ScrollView, { contentContainerStyle: { height: canvasHeight },
769
+ React.createElement(ScrollView, { key: screenIndex, contentContainerStyle: { height: canvasHeight },
753
770
  // A designed screen never shows a scrollbar — the web preview has
754
771
  // none, and in an RTL app the indicator lands on the left edge of
755
772
  // the artwork. When nothing overflows, scrolling is off entirely so
@@ -773,15 +790,18 @@ export function RevnixBlockPaywall(props) {
773
790
  { translateY: -((canvasHeight / scale) * (1 - scale)) / 2 },
774
791
  { scale },
775
792
  ],
776
- } }, 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))),
777
796
  fallbackClose));
778
797
  }
779
798
  return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
780
- React.createElement(ScrollView, { contentContainerStyle: {
799
+ React.createElement(ScrollView, { key: screenIndex, contentContainerStyle: {
781
800
  gap: 12,
782
801
  paddingHorizontal: 20,
783
802
  paddingTop: 18,
784
803
  paddingBottom: 30,
785
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,
786
806
  fallbackClose));
787
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.7.0";
4
+ export const SDK_VERSION = "1.9.0";
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { RevnixClient } from "./client.js";
2
2
  import { setLocale } from "./blocks/locale.js";
3
3
  import type { RevnixConfig } from "./types.js";
4
4
  export { RevnixClient } from "./client.js";
5
- export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
5
+ export { PurchaseAlreadyOwnedError, PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
6
6
  export { purchaseInputFromIap, type IapPurchase } from "./iap.js";
7
7
  export { setStoreAdapter, type StoreAdapter, type StoreTransaction, } from "./store.js";
8
8
  export { memoryStorage, generateAnonymousId } from "./storage.js";
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Revnix = exports.PREVIEW_PLACEMENT_KEY = exports.IMPLICIT_PLACEMENTS = exports.HOST_CALL_PLACEMENT = exports.DEFAULT_SESSION_TIMEOUT_MS = exports.browserLifecycle = exports.encodeDeviceHeader = exports.DEVICE_HEADER = exports.SDK_VERSION = exports.generateAnonymousId = exports.memoryStorage = exports.setStoreAdapter = exports.purchaseInputFromIap = exports.RevnixTimeoutError = exports.RevnixRateLimitError = exports.RevnixNetworkError = exports.RevnixError = exports.RevnixAuthError = exports.PurchaseBlockedError = exports.RevnixClient = void 0;
3
+ exports.Revnix = exports.PREVIEW_PLACEMENT_KEY = exports.IMPLICIT_PLACEMENTS = exports.HOST_CALL_PLACEMENT = exports.DEFAULT_SESSION_TIMEOUT_MS = exports.browserLifecycle = exports.encodeDeviceHeader = exports.DEVICE_HEADER = exports.SDK_VERSION = exports.generateAnonymousId = exports.memoryStorage = exports.setStoreAdapter = exports.purchaseInputFromIap = exports.RevnixTimeoutError = exports.RevnixRateLimitError = exports.RevnixNetworkError = exports.RevnixError = exports.RevnixAuthError = exports.PurchaseBlockedError = exports.PurchaseAlreadyOwnedError = exports.RevnixClient = void 0;
4
4
  const client_js_1 = require("./client.js");
5
5
  const locale_js_1 = require("./blocks/locale.js");
6
6
  var client_js_2 = require("./client.js");
7
7
  Object.defineProperty(exports, "RevnixClient", { enumerable: true, get: function () { return client_js_2.RevnixClient; } });
8
8
  var errors_js_1 = require("./errors.js");
9
+ Object.defineProperty(exports, "PurchaseAlreadyOwnedError", { enumerable: true, get: function () { return errors_js_1.PurchaseAlreadyOwnedError; } });
9
10
  Object.defineProperty(exports, "PurchaseBlockedError", { enumerable: true, get: function () { return errors_js_1.PurchaseBlockedError; } });
10
11
  Object.defineProperty(exports, "RevnixAuthError", { enumerable: true, get: function () { return errors_js_1.RevnixAuthError; } });
11
12
  Object.defineProperty(exports, "RevnixError", { enumerable: true, get: function () { return errors_js_1.RevnixError; } });
@@ -9,7 +9,7 @@ exports.RevnixStore = native
9
9
  ? {
10
10
  purchase: (productId, { customerId }) => native.purchase(productId, customerId),
11
11
  restore: () => native.restore(),
12
- finish: (transactionId) => native.finish(transactionId),
12
+ finish: (transactionId, consume) => native.finish(transactionId, consume === true),
13
13
  observe(onTransaction) {
14
14
  const sub = new react_native_1.NativeEventEmitter(react_native_1.NativeModules.RevnixStore).addListener("revnixStoreTransaction", (tx) => onTransaction(tx));
15
15
  return () => sub.remove();
package/dist/store.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface StoreTransaction {
12
12
  expiresAt?: number;
13
13
  /** Apple only: the StoreKit 2 JWS. */
14
14
  signedTransactionInfo?: string;
15
+ /** Google only: already acknowledged in Play. */
16
+ acknowledged?: boolean;
17
+ /** Google only: purchase() answered with an earlier purchase Play reported
18
+ * as already owned, not a new charge. */
19
+ alreadyOwned?: boolean;
15
20
  }
16
21
  export interface StoreAdapter {
17
22
  /** Resolves null when the customer cancelled or the purchase is pending. */
@@ -19,7 +24,8 @@ export interface StoreAdapter {
19
24
  customerId: string;
20
25
  }): Promise<StoreTransaction | null>;
21
26
  restore(): Promise<StoreTransaction[]>;
22
- finish(transactionId: string): Promise<void>;
27
+ /** `consume`: Google only, consume instead of acknowledge. */
28
+ finish(transactionId: string, consume?: boolean): Promise<void>;
23
29
  /** Transactions arriving outside purchase(): renewals, other devices, Ask
24
30
  * to Buy, refunds. Returns an unsubscribe. */
25
31
  observe(onTransaction: (tx: StoreTransaction) => void): () => void;
package/dist/types.d.ts CHANGED
@@ -149,6 +149,9 @@ export interface RevnixConfig {
149
149
  * read. A null answer deletes a previously stored `gps_adid`; a
150
150
  * rejection changes nothing and reports `op: "advertisingId"`. */
151
151
  advertisingId?: () => Promise<string | null | undefined>;
152
+ /** Google Play product ids sold as consumables. A registered purchase of
153
+ * one is consumed instead of acknowledged so it can be bought again. */
154
+ consumableProductIds?: string[];
152
155
  }
153
156
  /** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
154
157
  * iOS: an App Attest key id and its attestation (standard base64) over
@@ -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,7 +291,7 @@ 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
  }
@@ -330,27 +330,34 @@ function Carousel({ pages, autoAdvance, doc, }) {
330
330
  setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
331
331
  }
332
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: {
333
+ pages.length > 1 ? (React.createElement(Dots, { count: pages.length, active: page, doc: doc, style: { bottom: 8 } })) : null));
334
+ }
335
+ function Dots({ count, active, doc, style, }) {
336
+ return (React.createElement(react_native_1.View, { pointerEvents: "none", style: [
337
+ {
334
338
  position: "absolute",
335
- bottom: 8,
336
339
  left: 0,
337
340
  right: 0,
338
341
  flexDirection: "row",
339
342
  justifyContent: "center",
340
343
  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));
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
+ } })))));
347
352
  }
348
- /** The press handler for a block the design marks as the close, or undefined
349
- * when it is not one (or the host wired no dismissal). One helper so text,
350
- * image and button blocks cannot drift on what "action: close" means. */
351
- function closePress(block, ctx) {
352
- return block.action === "close" && ctx.onClose ? ctx.onClose : undefined;
353
+ function actionPress(block, ctx) {
354
+ return block.action === "close" ? ctx.onClose : ctx.navigate(block.action);
353
355
  }
356
+ const ACTION_LABEL = {
357
+ close: "Close",
358
+ next: "Next",
359
+ back: "Back",
360
+ };
354
361
  /** Typography that is NOT inherited (decoration) — everything else already
355
362
  * arrives through the inherited set. */
356
363
  function textOnly(s, ctx) {
@@ -475,7 +482,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
475
482
  const size = text.fontSize;
476
483
  switch (block.type) {
477
484
  case "text":
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) }));
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 }));
479
486
  case "timer":
480
487
  return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
481
488
  case "toggle": {
@@ -506,12 +513,10 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
506
513
  s.aspectRatio !== undefined ||
507
514
  (s.top !== undefined && s.bottom !== undefined) ||
508
515
  s.flex !== undefined);
509
- // An icon the design marks as the close (REV-252). Same rule as text:
510
- // the whole box is the target, and the style lands on the Pressable so
511
- // a pinned icon keeps its offsets.
512
- const closeImage = image ? closePress(block, ctx) : undefined;
513
- const ImageBox = closeImage ? react_native_1.Pressable : react_native_1.View;
514
- 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: [
515
520
  sized ? null : { height: 160 },
516
521
  { borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
517
522
  view,
@@ -552,12 +557,13 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
552
557
  // A button the design marks as the close dismisses instead of buying,
553
558
  // and takes no accent fill: the CTA must stay the one accented thing on
554
559
  // the screen, or a "Not now" competes with "Subscribe" for the eye.
555
- const close = closePress(block, ctx);
560
+ const close = block.action === "close" ? ctx.onClose : undefined;
561
+ const navigates = block.action === "next" || block.action === "back";
556
562
  // A purchase in flight: the button keeps its box and fill, loses its
557
563
  // label to a spinner in the ink colour, and ignores taps. A close
558
564
  // button is never blocked — the user can always leave.
559
- const busy = !close && ctx.loading;
560
- 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 }) => [
561
567
  {
562
568
  backgroundColor: close ? "transparent" : doc.accent,
563
569
  borderRadius: 12,
@@ -714,6 +720,13 @@ function RevnixBlockPaywall(props) {
714
720
  const doc = React.useMemo(() => (0, locale_js_1.localizeBlockDoc)(props.doc, props.locale ?? (0, locale_js_1.detectLocale)()), [props.doc, props.locale]);
715
721
  const [internalSelected, setInternalSelected] = React.useState(undefined);
716
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);
717
730
  // Contract §1: host → own tap → design highlight → first package, each
718
731
  // only when the offering carries it.
719
732
  const selectedId = (0, selection_js_1.selectedPackageId)(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
@@ -749,6 +762,10 @@ function RevnixBlockPaywall(props) {
749
762
  onTerms: props.onTerms,
750
763
  onPrivacy: props.onPrivacy,
751
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
+ },
752
769
  footer: props.footer,
753
770
  heroImageUrl: props.heroImageUrl,
754
771
  gradient: props.gradient,
@@ -757,14 +774,14 @@ function RevnixBlockPaywall(props) {
757
774
  loading: props.loading === true,
758
775
  shownAt,
759
776
  };
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 })));
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 })));
761
778
  // REV-252: the renderer's own close, drawn only for a design that authors
762
779
  // none. Rendered as a sibling of the tree — outside the canvas scale — so
763
780
  // it keeps its tap size and its distance from the screen edge whatever the
764
781
  // device width does to the design.
765
782
  const safeAreaTop = props.safeAreaTop ??
766
783
  (react_native_1.Platform.OS === "android" ? (react_native_1.StatusBar.currentHeight ?? 0) : 0);
767
- 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;
768
785
  const [width, setWidth] = React.useState(null);
769
786
  const [viewportHeight, setViewportHeight] = React.useState(null);
770
787
  if (doc.layout === "canvas") {
@@ -785,7 +802,7 @@ function RevnixBlockPaywall(props) {
785
802
  const canvasHeight = metrics?.scaledHeight ?? types_js_1.DESIGN_CANVAS.height;
786
803
  const left = metrics?.left ?? 0;
787
804
  return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
788
- React.createElement(react_native_1.ScrollView, { contentContainerStyle: { height: canvasHeight },
805
+ React.createElement(react_native_1.ScrollView, { key: screenIndex, contentContainerStyle: { height: canvasHeight },
789
806
  // A designed screen never shows a scrollbar — the web preview has
790
807
  // none, and in an RTL app the indicator lands on the left edge of
791
808
  // the artwork. When nothing overflows, scrolling is off entirely so
@@ -809,15 +826,18 @@ function RevnixBlockPaywall(props) {
809
826
  { translateY: -((canvasHeight / scale) * (1 - scale)) / 2 },
810
827
  { scale },
811
828
  ],
812
- } }, 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))),
813
832
  fallbackClose));
814
833
  }
815
834
  return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
816
- React.createElement(react_native_1.ScrollView, { contentContainerStyle: {
835
+ React.createElement(react_native_1.ScrollView, { key: screenIndex, contentContainerStyle: {
817
836
  gap: 12,
818
837
  paddingHorizontal: 20,
819
838
  paddingTop: 18,
820
839
  paddingBottom: 30,
821
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,
822
842
  fallbackClose));
823
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.7.0";
1
+ export declare const SDK_VERSION = "1.9.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.7.0";
7
+ exports.SDK_VERSION = "1.9.0";
package/ios/RevnixStore.m CHANGED
@@ -12,6 +12,7 @@ RCT_EXTERN_METHOD(restore:(RCTPromiseResolveBlock)resolve
12
12
  rejecter:(RCTPromiseRejectBlock)reject)
13
13
 
14
14
  RCT_EXTERN_METHOD(finish:(NSString *)transactionId
15
+ consume:(BOOL)consume
15
16
  resolver:(RCTPromiseResolveBlock)resolve
16
17
  rejecter:(RCTPromiseRejectBlock)reject)
17
18
 
@@ -20,7 +20,7 @@ class RevnixStore: RCTEventEmitter {
20
20
  override func startObserving() {
21
21
  updates?.cancel()
22
22
  updates = Task { [weak self] in
23
- for await update in Transaction.updates where update.unsafePayloadValue.revocationDate == nil {
23
+ for await update in Transaction.updates {
24
24
  self?.sendEvent(withName: "revnixStoreTransaction", body: RevnixStore.payload(update))
25
25
  }
26
26
  }
@@ -72,9 +72,10 @@ class RevnixStore: RCTEventEmitter {
72
72
  }
73
73
  }
74
74
 
75
- @objc(finish:resolver:rejecter:)
75
+ @objc(finish:consume:resolver:rejecter:)
76
76
  func finish(
77
77
  _ transactionId: String,
78
+ consume _: Bool,
78
79
  resolver resolve: @escaping RCTPromiseResolveBlock,
79
80
  rejecter reject: @escaping RCTPromiseRejectBlock
80
81
  ) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-revnix",
3
- "version": "1.7.0",
3
+ "version": "1.9.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": {
@@ -193,6 +193,13 @@ export function localizeBlockDoc(
193
193
  ...doc,
194
194
  defaultLocale: chain[0],
195
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
+ : {}),
196
203
  };
197
204
  }
198
205