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.
- package/dist/blocks/locale.js +5 -0
- package/dist/blocks/types.d.ts +20 -5
- package/dist/blocks/types.js +20 -0
- package/dist/esm/blocks/locale.js +5 -0
- package/dist/esm/blocks/types.js +18 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +51 -31
- package/dist/esm/ui/RevnixPaywall.js +11 -2
- package/dist/esm/version.js +1 -1
- package/dist/ui/RevnixBlockPaywall.js +50 -30
- package/dist/ui/RevnixPaywall.js +11 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/blocks/locale.ts +7 -0
- package/src/blocks/types.ts +35 -5
- package/src/ui/RevnixBlockPaywall.tsx +103 -46
- package/src/ui/RevnixPaywall.tsx +11 -2
- package/src/version.ts +1 -1
package/dist/blocks/locale.js
CHANGED
|
@@ -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 = {
|
package/dist/blocks/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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")
|
|
186
|
-
* purchase CTA, which is what a
|
|
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;
|
package/dist/blocks/types.js
CHANGED
|
@@ -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 = {
|
package/dist/esm/blocks/types.js
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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:
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
} },
|
|
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
|
-
?
|
|
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
|
package/dist/esm/version.js
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.
|
|
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:
|
|
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(
|
|
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
|
-
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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:
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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 =
|
|
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 =
|
|
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)(
|
|
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
|
-
} },
|
|
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
|
}
|
package/dist/ui/RevnixPaywall.js
CHANGED
|
@@ -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
|
-
?
|
|
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.
|
|
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
|
+
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.
|
|
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": {
|
package/src/blocks/locale.ts
CHANGED
|
@@ -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
|
|
package/src/blocks/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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")
|
|
210
|
-
* purchase CTA, which is what a
|
|
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=
|
|
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
|
-
|
|
787
|
+
key={i}
|
|
750
788
|
style={{
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
779
|
-
|
|
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"
|
|
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={
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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={
|
|
1050
|
-
accessibilityLabel={
|
|
1051
|
-
|
|
1052
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
);
|
package/src/ui/RevnixPaywall.tsx
CHANGED
|
@@ -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
|
-
?
|
|
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.
|
|
4
|
+
export const SDK_VERSION = "1.8.0";
|