react-native-revnix 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -191,6 +191,11 @@ function localizeBlockDoc(doc, locale) {
191
191
  ...doc,
192
192
  defaultLocale: chain[0],
193
193
  blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
194
+ ...(doc.screens
195
+ ? {
196
+ screens: doc.screens.map((screen) => screen.map((b) => localizeBlock(b, lookup))),
197
+ }
198
+ : {}),
194
199
  };
195
200
  }
196
201
  const LINK_LABEL_ALIASES = {
@@ -112,12 +112,14 @@ export interface BlockStyle {
112
112
  /** REV-262: draw a block only while its plan card is (or is not) the selected
113
113
  * one. Ignored outside a package card — a root block is always drawn. */
114
114
  export type BlockVisibility = "selected" | "unselected";
115
- export type BlockAction = "close";
115
+ /** `close` dismisses the paywall; `next`/`back` move between onboarding
116
+ * screens and only appear inside `screens`. */
117
+ export type BlockAction = "close" | "next" | "back";
116
118
  export interface TextBlock {
117
119
  id: string;
118
120
  type: "text";
119
121
  text: string;
120
- /** Tapping this block dismisses the paywall. See BlockAction. */
122
+ /** What tapping this block does. See BlockAction. */
121
123
  action?: BlockAction;
122
124
  style?: BlockStyle;
123
125
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -134,7 +136,7 @@ export interface ImageBlock {
134
136
  shape?: "rect" | "circle";
135
137
  fit?: "cover" | "contain";
136
138
  placeholder?: string;
137
- /** Tapping this block dismisses the paywall. See BlockAction. */
139
+ /** What tapping this block does. See BlockAction. */
138
140
  action?: BlockAction;
139
141
  style?: BlockStyle;
140
142
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -182,8 +184,9 @@ export interface ButtonBlock {
182
184
  id: string;
183
185
  type: "button";
184
186
  label: string;
185
- /** `close` turns this button into a dismiss ("Not now") instead of the
186
- * purchase CTA, which is what a button means by default. */
187
+ /** `close` turns this button into a dismiss ("Not now"), and `next`/`back`
188
+ * into onboarding navigation, instead of the purchase CTA, which is what a
189
+ * button means by default. */
187
190
  action?: BlockAction;
188
191
  style?: BlockStyle;
189
192
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -311,6 +314,10 @@ export interface PaywallBlockDoc {
311
314
  accentInk: string;
312
315
  fontFamily?: string;
313
316
  blocks: PaywallBlock[];
317
+ /** Onboarding: intro screens shown in order before `blocks`, which stays
318
+ * the last (purchase) screen. They share the doc's palette, layout and
319
+ * locale table. Absent or empty = `blocks` alone. */
320
+ screens?: PaywallBlock[][];
314
321
  /** REV-271: the language the blocks' own copy is written in. Absent = "en".
315
322
  * Never a key in a stored doc's `locales` — the tree IS that language's
316
323
  * version. A localized copy names the language it was localized to. */
@@ -341,3 +348,11 @@ export declare const DESIGN_CANVAS: {
341
348
  * bug this feature exists to fix. Two close buttons is merely ugly, so the
342
349
  * tie breaks toward always having one. */
343
350
  export declare function hasCloseAction(blocks: PaywallBlock[]): boolean;
351
+ export declare function onboardingScreen(doc: PaywallBlockDoc, index: number): {
352
+ blocks: PaywallBlock[];
353
+ dots?: {
354
+ count: number;
355
+ active: number;
356
+ };
357
+ };
358
+ export declare function stepScreen(doc: PaywallBlockDoc, index: number, action: BlockAction | undefined): number | undefined;
@@ -13,6 +13,8 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.DESIGN_CANVAS = void 0;
15
15
  exports.hasCloseAction = hasCloseAction;
16
+ exports.onboardingScreen = onboardingScreen;
17
+ exports.stepScreen = stepScreen;
16
18
  /** The device screen `canvas` designs are authored against. */
17
19
  exports.DESIGN_CANVAS = { width: 393, height: 852 };
18
20
  /** Does this tree author a dismiss affordance that is CERTAIN to render?
@@ -43,3 +45,21 @@ function hasCloseAction(blocks) {
43
45
  : (b.type === "text" || b.type === "image" || b.type === "button") &&
44
46
  b.action === "close");
45
47
  }
48
+ function onboardingScreen(doc, index) {
49
+ const intro = doc.screens?.[index];
50
+ if (intro === undefined)
51
+ return { blocks: doc.blocks };
52
+ return {
53
+ blocks: intro,
54
+ dots: { count: doc.screens.length + 1, active: index },
55
+ };
56
+ }
57
+ function stepScreen(doc, index, action) {
58
+ if (index >= (doc.screens?.length ?? 0))
59
+ return undefined;
60
+ if (action === "next")
61
+ return index + 1;
62
+ if (action === "back" && index > 0)
63
+ return index - 1;
64
+ return undefined;
65
+ }
@@ -183,6 +183,11 @@ export function localizeBlockDoc(doc, locale) {
183
183
  ...doc,
184
184
  defaultLocale: chain[0],
185
185
  blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
186
+ ...(doc.screens
187
+ ? {
188
+ screens: doc.screens.map((screen) => screen.map((b) => localizeBlock(b, lookup))),
189
+ }
190
+ : {}),
186
191
  };
187
192
  }
188
193
  const LINK_LABEL_ALIASES = {
@@ -39,3 +39,21 @@ export function hasCloseAction(blocks) {
39
39
  : (b.type === "text" || b.type === "image" || b.type === "button") &&
40
40
  b.action === "close");
41
41
  }
42
+ export function onboardingScreen(doc, index) {
43
+ const intro = doc.screens?.[index];
44
+ if (intro === undefined)
45
+ return { blocks: doc.blocks };
46
+ return {
47
+ blocks: intro,
48
+ dots: { count: doc.screens.length + 1, active: index },
49
+ };
50
+ }
51
+ export function stepScreen(doc, index, action) {
52
+ if (index >= (doc.screens?.length ?? 0))
53
+ return undefined;
54
+ if (action === "next")
55
+ return index + 1;
56
+ if (action === "back" && index > 0)
57
+ return index - 1;
58
+ return undefined;
59
+ }
@@ -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.8.0";
@@ -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.8.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@ exports.SDK_VERSION = void 0;
4
4
  // L3: the single source of the SDK version inside src/. Must match
5
5
  // package.json "version" — version.test.ts fails the build when they drift
6
6
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
7
- exports.SDK_VERSION = "1.7.0";
7
+ exports.SDK_VERSION = "1.8.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-revnix",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Revnix SDK for React, React Native & Next.js: purchase registration, entitlements, identity, and placements over the Revnix /v1 API.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
 
@@ -135,13 +135,15 @@ export interface BlockStyle {
135
135
  * one. Ignored outside a package card — a root block is always drawn. */
136
136
  export type BlockVisibility = "selected" | "unselected";
137
137
 
138
- export type BlockAction = "close";
138
+ /** `close` dismisses the paywall; `next`/`back` move between onboarding
139
+ * screens and only appear inside `screens`. */
140
+ export type BlockAction = "close" | "next" | "back";
139
141
 
140
142
  export interface TextBlock {
141
143
  id: string;
142
144
  type: "text";
143
145
  text: string;
144
- /** Tapping this block dismisses the paywall. See BlockAction. */
146
+ /** What tapping this block does. See BlockAction. */
145
147
  action?: BlockAction;
146
148
  style?: BlockStyle;
147
149
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -159,7 +161,7 @@ export interface ImageBlock {
159
161
  shape?: "rect" | "circle";
160
162
  fit?: "cover" | "contain";
161
163
  placeholder?: string;
162
- /** Tapping this block dismisses the paywall. See BlockAction. */
164
+ /** What tapping this block does. See BlockAction. */
163
165
  action?: BlockAction;
164
166
  style?: BlockStyle;
165
167
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -206,8 +208,9 @@ export interface ButtonBlock {
206
208
  id: string;
207
209
  type: "button";
208
210
  label: string;
209
- /** `close` turns this button into a dismiss ("Not now") instead of the
210
- * purchase CTA, which is what a button means by default. */
211
+ /** `close` turns this button into a dismiss ("Not now"), and `next`/`back`
212
+ * into onboarding navigation, instead of the purchase CTA, which is what a
213
+ * button means by default. */
211
214
  action?: BlockAction;
212
215
  style?: BlockStyle;
213
216
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -356,6 +359,10 @@ export interface PaywallBlockDoc {
356
359
  accentInk: string;
357
360
  fontFamily?: string;
358
361
  blocks: PaywallBlock[];
362
+ /** Onboarding: intro screens shown in order before `blocks`, which stays
363
+ * the last (purchase) screen. They share the doc's palette, layout and
364
+ * locale table. Absent or empty = `blocks` alone. */
365
+ screens?: PaywallBlock[][];
359
366
  /** REV-271: the language the blocks' own copy is written in. Absent = "en".
360
367
  * Never a key in a stored doc's `locales` — the tree IS that language's
361
368
  * version. A localized copy names the language it was localized to. */
@@ -399,3 +406,26 @@ export function hasCloseAction(blocks: PaywallBlock[]): boolean {
399
406
  b.action === "close"
400
407
  );
401
408
  }
409
+
410
+ export function onboardingScreen(
411
+ doc: PaywallBlockDoc,
412
+ index: number
413
+ ): { blocks: PaywallBlock[]; dots?: { count: number; active: number } } {
414
+ const intro = doc.screens?.[index];
415
+ if (intro === undefined) return { blocks: doc.blocks };
416
+ return {
417
+ blocks: intro,
418
+ dots: { count: doc.screens!.length + 1, active: index },
419
+ };
420
+ }
421
+
422
+ export function stepScreen(
423
+ doc: PaywallBlockDoc,
424
+ index: number,
425
+ action: BlockAction | undefined
426
+ ): number | undefined {
427
+ if (index >= (doc.screens?.length ?? 0)) return undefined;
428
+ if (action === "next") return index + 1;
429
+ if (action === "back" && index > 0) return index - 1;
430
+ return undefined;
431
+ }
@@ -74,6 +74,9 @@ import type { SelectionContext } from "../blocks/selection.js";
74
74
  import {
75
75
  DESIGN_CANVAS,
76
76
  hasCloseAction,
77
+ onboardingScreen,
78
+ stepScreen,
79
+ type BlockAction,
77
80
  type BlockStyle,
78
81
  type PaywallBlock,
79
82
  type PaywallBlockDoc,
@@ -600,6 +603,7 @@ interface RenderContext {
600
603
  onTerms?: () => void;
601
604
  onPrivacy?: () => void;
602
605
  onClose?: () => void;
606
+ navigate: (action: BlockAction | undefined) => (() => void) | undefined;
603
607
  footer?: { termsUrl?: string; privacyUrl?: string };
604
608
  heroImageUrl?: string;
605
609
  gradient?: RevnixGradientComponent;
@@ -622,13 +626,14 @@ function BlockText({
622
626
  ctx,
623
627
  inherited,
624
628
  onPress,
629
+ pressLabel,
625
630
  }: {
626
631
  text: string;
627
632
  style: BlockStyle | undefined;
628
633
  ctx: RenderContext;
629
634
  inherited: InheritedText;
630
- /** Set when the design marks this element as the close (REV-252). */
631
635
  onPress?: () => void;
636
+ pressLabel?: string;
632
637
  }) {
633
638
  const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
634
639
  // The block's own typography is folded into the inherited set first, so
@@ -661,7 +666,7 @@ function BlockText({
661
666
  return (
662
667
  <Pressable
663
668
  accessibilityRole="button"
664
- accessibilityLabel="Close"
669
+ accessibilityLabel={pressLabel}
665
670
  hitSlop={8}
666
671
  onPress={onPress}
667
672
  style={toViewStyle(style, styleCtx)}
@@ -745,46 +750,67 @@ function Carousel({
745
750
  ))}
746
751
  </ScrollView>
747
752
  {pages.length > 1 ? (
753
+ <Dots count={pages.length} active={page} doc={doc} style={{ bottom: 8 }} />
754
+ ) : null}
755
+ </View>
756
+ );
757
+ }
758
+
759
+ function Dots({
760
+ count,
761
+ active,
762
+ doc,
763
+ style,
764
+ }: {
765
+ count: number;
766
+ active: number;
767
+ doc: PaywallBlockDoc;
768
+ style: ViewStyle;
769
+ }) {
770
+ return (
771
+ <View
772
+ pointerEvents="none"
773
+ style={[
774
+ {
775
+ position: "absolute",
776
+ left: 0,
777
+ right: 0,
778
+ flexDirection: "row",
779
+ justifyContent: "center",
780
+ gap: 6,
781
+ },
782
+ style,
783
+ ]}
784
+ >
785
+ {Array.from({ length: count }, (_, i) => (
748
786
  <View
749
- pointerEvents="none"
787
+ key={i}
750
788
  style={{
751
- position: "absolute",
752
- bottom: 8,
753
- left: 0,
754
- right: 0,
755
- flexDirection: "row",
756
- justifyContent: "center",
757
- gap: 6,
789
+ width: 6,
790
+ height: 6,
791
+ borderRadius: 3,
792
+ backgroundColor:
793
+ i === active ? doc.accent : withAlpha(doc.textColor, 0.3),
758
794
  }}
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}
795
+ />
796
+ ))}
774
797
  </View>
775
798
  );
776
799
  }
777
800
 
778
- /** The press handler for a block the design marks as the close, or undefined
779
- * when it is not one (or the host wired no dismissal). One helper so text,
780
- * image and button blocks cannot drift on what "action: close" means. */
781
- function closePress(
782
- block: { action?: "close" },
801
+ function actionPress(
802
+ block: { action?: BlockAction },
783
803
  ctx: RenderContext
784
804
  ): (() => void) | undefined {
785
- return block.action === "close" && ctx.onClose ? ctx.onClose : undefined;
805
+ return block.action === "close" ? ctx.onClose : ctx.navigate(block.action);
786
806
  }
787
807
 
808
+ const ACTION_LABEL: Record<BlockAction, string> = {
809
+ close: "Close",
810
+ next: "Next",
811
+ back: "Back",
812
+ };
813
+
788
814
  /** Typography that is NOT inherited (decoration) — everything else already
789
815
  * arrives through the inherited set. */
790
816
  function textOnly(s: BlockStyle | undefined, ctx: StyleContext) {
@@ -983,7 +1009,8 @@ function Block({
983
1009
  style={style}
984
1010
  ctx={ctx}
985
1011
  inherited={inherited}
986
- onPress={closePress(block, ctx)}
1012
+ onPress={actionPress(block, ctx)}
1013
+ pressLabel={block.action === "close" ? "Close" : undefined}
987
1014
  />
988
1015
  );
989
1016
 
@@ -1039,17 +1066,17 @@ function Block({
1039
1066
  s.aspectRatio !== undefined ||
1040
1067
  (s.top !== undefined && s.bottom !== undefined) ||
1041
1068
  s.flex !== undefined);
1042
- // An icon the design marks as the close (REV-252). Same rule as text:
1043
- // the whole box is the target, and the style lands on the Pressable so
1044
- // a pinned icon keeps its offsets.
1045
- const closeImage = image ? closePress(block, ctx) : undefined;
1046
- const ImageBox = closeImage ? Pressable : View;
1069
+ const action = block.type === "image" ? block.action : undefined;
1070
+ const imagePress = actionPress({ action }, ctx);
1071
+ const ImageBox = imagePress ? Pressable : View;
1047
1072
  return (
1048
1073
  <ImageBox
1049
- accessibilityRole={closeImage ? "button" : undefined}
1050
- accessibilityLabel={closeImage ? "Close" : undefined}
1051
- hitSlop={closeImage ? 8 : undefined}
1052
- onPress={closeImage}
1074
+ accessibilityRole={imagePress ? "button" : undefined}
1075
+ accessibilityLabel={
1076
+ imagePress && action ? ACTION_LABEL[action] : undefined
1077
+ }
1078
+ hitSlop={imagePress ? 8 : undefined}
1079
+ onPress={imagePress}
1053
1080
  style={[
1054
1081
  sized ? null : { height: 160 },
1055
1082
  { borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
@@ -1130,17 +1157,18 @@ function Block({
1130
1157
  // A button the design marks as the close dismisses instead of buying,
1131
1158
  // and takes no accent fill: the CTA must stay the one accented thing on
1132
1159
  // the screen, or a "Not now" competes with "Subscribe" for the eye.
1133
- const close = closePress(block, ctx);
1160
+ const close = block.action === "close" ? ctx.onClose : undefined;
1161
+ const navigates = block.action === "next" || block.action === "back";
1134
1162
  // A purchase in flight: the button keeps its box and fill, loses its
1135
1163
  // label to a spinner in the ink colour, and ignores taps. A close
1136
1164
  // button is never blocked — the user can always leave.
1137
- const busy = !close && ctx.loading;
1165
+ const busy = !close && !navigates && ctx.loading;
1138
1166
  return (
1139
1167
  <Pressable
1140
1168
  accessibilityRole="button"
1141
1169
  accessibilityState={{ disabled: busy, busy }}
1142
1170
  disabled={busy}
1143
- onPress={close ?? ctx.onPurchase}
1171
+ onPress={navigates ? actionPress(block, ctx) : (close ?? ctx.onPurchase)}
1144
1172
  style={({ pressed }) => [
1145
1173
  {
1146
1174
  backgroundColor: close ? "transparent" : doc.accent,
@@ -1408,6 +1436,13 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1408
1436
  string | undefined
1409
1437
  >(undefined);
1410
1438
  const [shownAt] = React.useState(Date.now);
1439
+ const [screenIndex, setScreenIndex] = React.useState(0);
1440
+ const [screenDoc, setScreenDoc] = React.useState(props.doc);
1441
+ if (screenDoc !== props.doc) {
1442
+ setScreenDoc(props.doc);
1443
+ setScreenIndex(0);
1444
+ }
1445
+ const screen = onboardingScreen(doc, screenIndex);
1411
1446
  // Contract §1: host → own tap → design highlight → first package, each
1412
1447
  // only when the offering carries it.
1413
1448
  const selectedId = selectedPackageId(
@@ -1451,6 +1486,10 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1451
1486
  onTerms: props.onTerms,
1452
1487
  onPrivacy: props.onPrivacy,
1453
1488
  onClose: props.onClose,
1489
+ navigate: (action) => {
1490
+ const to = stepScreen(doc, screenIndex, action);
1491
+ return to === undefined ? undefined : () => setScreenIndex(to);
1492
+ },
1454
1493
  footer: props.footer,
1455
1494
  heroImageUrl: props.heroImageUrl,
1456
1495
  gradient: props.gradient,
@@ -1460,7 +1499,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1460
1499
  shownAt,
1461
1500
  };
1462
1501
 
1463
- const children = doc.blocks.map((block) => (
1502
+ const children = screen.blocks.map((block) => (
1464
1503
  <Block
1465
1504
  key={block.id}
1466
1505
  block={block}
@@ -1478,7 +1517,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1478
1517
  props.safeAreaTop ??
1479
1518
  (Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0);
1480
1519
  const fallbackClose =
1481
- props.onClose && !hasCloseAction(doc.blocks) ? (
1520
+ props.onClose && !hasCloseAction(screen.blocks) ? (
1482
1521
  <FallbackClose
1483
1522
  doc={doc}
1484
1523
  onPress={props.onClose}
@@ -1518,6 +1557,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1518
1557
  style={[{ flex: 1 }, props.style]}
1519
1558
  >
1520
1559
  <ScrollView
1560
+ key={screenIndex}
1521
1561
  contentContainerStyle={{ height: canvasHeight }}
1522
1562
  // A designed screen never shows a scrollbar — the web preview has
1523
1563
  // none, and in an RTL app the indicator lands on the left edge of
@@ -1554,6 +1594,14 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1554
1594
  }}
1555
1595
  >
1556
1596
  {children}
1597
+ {screen.dots ? (
1598
+ <Dots
1599
+ count={screen.dots.count}
1600
+ active={screen.dots.active}
1601
+ doc={doc}
1602
+ style={{ top: 20, zIndex: 50 }}
1603
+ />
1604
+ ) : null}
1557
1605
  </View>
1558
1606
  </View>
1559
1607
  </ScrollView>
@@ -1571,6 +1619,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1571
1619
  style={[{ flex: 1 }, props.style]}
1572
1620
  >
1573
1621
  <ScrollView
1622
+ key={screenIndex}
1574
1623
  contentContainerStyle={{
1575
1624
  gap: 12,
1576
1625
  paddingHorizontal: 20,
@@ -1583,6 +1632,14 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1583
1632
  >
1584
1633
  {children}
1585
1634
  </ScrollView>
1635
+ {screen.dots ? (
1636
+ <Dots
1637
+ count={screen.dots.count}
1638
+ active={screen.dots.active}
1639
+ doc={doc}
1640
+ style={{ top: 20 + safeAreaTop, zIndex: 50 }}
1641
+ />
1642
+ ) : null}
1586
1643
  {fallbackClose}
1587
1644
  </ScreenBackground>
1588
1645
  );
@@ -65,7 +65,16 @@ function asBlockDoc(value: unknown): PaywallBlockDoc | null {
65
65
  typeof doc.textColor === "string" &&
66
66
  typeof doc.accent === "string" &&
67
67
  typeof doc.accentInk === "string"
68
- ? (doc as PaywallBlockDoc)
68
+ ? {
69
+ ...(doc as PaywallBlockDoc),
70
+ ...(doc.screens !== undefined
71
+ ? {
72
+ screens: Array.isArray(doc.screens)
73
+ ? doc.screens.filter((s) => Array.isArray(s) && s.length > 0)
74
+ : [],
75
+ }
76
+ : {}),
77
+ }
69
78
  : null;
70
79
  }
71
80
 
@@ -480,7 +489,7 @@ export function RevnixPaywall(props: RevnixPaywallProps) {
480
489
  // arrives after a placeholder (or is swapped by an A/B assignment) would
481
490
  // otherwise crash on "rendered more hooks than during the previous render".
482
491
  const { config } = props;
483
- const blocks = asBlockDoc(config.blocks);
492
+ const blocks = React.useMemo(() => asBlockDoc(config.blocks), [config.blocks]);
484
493
  // Both sinks fire when both are wired; nil when neither is, which is what
485
494
  // stops the renderer building a message nobody will read.
486
495
  const renderDiagnostic =
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.7.0";
4
+ export const SDK_VERSION = "1.8.0";