react-native-revnix 1.6.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/README.md +16 -0
- package/android/build.gradle +3 -0
- package/android/consumer-rules.pro +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
- package/dist/blocks/locale.js +12 -0
- package/dist/blocks/selection.d.ts +7 -1
- package/dist/blocks/selection.js +9 -0
- package/dist/blocks/tags.d.ts +3 -0
- package/dist/blocks/tags.js +33 -0
- package/dist/blocks/types.d.ts +65 -8
- package/dist/blocks/types.js +21 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +73 -5
- package/dist/esm/blocks/locale.js +12 -0
- package/dist/esm/blocks/selection.js +8 -0
- package/dist/esm/blocks/tags.js +31 -0
- package/dist/esm/blocks/types.js +19 -0
- package/dist/esm/client.js +73 -5
- package/dist/esm/store/index.js +7 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +145 -44
- package/dist/esm/ui/RevnixPaywall.js +11 -2
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/store/index.d.ts +8 -0
- package/dist/store/index.js +10 -1
- package/dist/types.d.ts +32 -0
- package/dist/ui/RevnixBlockPaywall.js +141 -40
- package/dist/ui/RevnixPaywall.js +11 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/RevnixStore.m +4 -0
- package/ios/RevnixStore.swift +29 -0
- package/package.json +1 -1
- package/src/blocks/locale.ts +14 -0
- package/src/blocks/selection.ts +13 -0
- package/src/blocks/tags.ts +38 -0
- package/src/blocks/types.ts +86 -7
- package/src/client.ts +90 -8
- package/src/index.ts +1 -0
- package/src/store/index.ts +15 -0
- package/src/types.ts +30 -0
- package/src/ui/RevnixBlockPaywall.tsx +265 -42
- package/src/ui/RevnixPaywall.tsx +11 -2
- package/src/version.ts +1 -1
package/dist/types.d.ts
CHANGED
|
@@ -129,7 +129,39 @@ export interface RevnixConfig {
|
|
|
129
129
|
* Never throws into your app: an exception from this handler is reported
|
|
130
130
|
* through `onDiagnostic` and swallowed. */
|
|
131
131
|
onAttribution?: (attribution: RevnixAttribution) => void;
|
|
132
|
+
/** Opt-in device integrity evidence (Apple App Attest / Google Play
|
|
133
|
+
* Integrity) attached to every install report and `setAttribution` call.
|
|
134
|
+
* Pair it with the dashboard's "Require device integrity" setting, which
|
|
135
|
+
* strips attribution from installs that arrive without evidence. In React
|
|
136
|
+
* Native pass `nativeDeviceIntegrity` from "react-native-revnix/store";
|
|
137
|
+
* it needs iOS 14+ with App Attest, or Google Play services on Android.
|
|
138
|
+
* Simulators and emulators return nothing.
|
|
139
|
+
*
|
|
140
|
+
* Called at most once per customer id and bounded to 10s. A rejection,
|
|
141
|
+
* timeout, or missing/malformed result sends the request without
|
|
142
|
+
* evidence and reports `op: "deviceIntegrity"` through `onDiagnostic`. */
|
|
143
|
+
deviceIntegrity?: (customerId: string) => Promise<IntegrityEvidence | null | undefined>;
|
|
144
|
+
/** Google Advertising ID source, stored on the customer as `gps_adid` at
|
|
145
|
+
* launch and resent only when it changes. In React Native pass
|
|
146
|
+
* `nativeAdvertisingId` from "react-native-revnix/store"; it needs
|
|
147
|
+
* `play-services-ads-identifier` and the `AD_ID` permission in the app and
|
|
148
|
+
* answers null on iOS and on opt-out, and rejects when the id cannot be
|
|
149
|
+
* read. A null answer deletes a previously stored `gps_adid`; a
|
|
150
|
+
* rejection changes nothing and reports `op: "advertisingId"`. */
|
|
151
|
+
advertisingId?: () => Promise<string | null | undefined>;
|
|
132
152
|
}
|
|
153
|
+
/** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
|
|
154
|
+
* iOS: an App Attest key id and its attestation (standard base64) over
|
|
155
|
+
* SHA-256 of the customer id. Android: a classic Play Integrity token whose
|
|
156
|
+
* nonce is base64url (no padding) of SHA-256 of the customer id. */
|
|
157
|
+
export type IntegrityEvidence = {
|
|
158
|
+
platform: "ios";
|
|
159
|
+
keyId: string;
|
|
160
|
+
attestation: string;
|
|
161
|
+
} | {
|
|
162
|
+
platform: "android";
|
|
163
|
+
token: string;
|
|
164
|
+
};
|
|
133
165
|
/** AT11: the install-attribution verdict — GET
|
|
134
166
|
* /v1/customers/{id}/attribution. `installMatch: "unknown"` (no install
|
|
135
167
|
* recorded yet, a normal cold-start race) is never sent to hosts as such;
|
|
@@ -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,16 +291,73 @@ 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
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
298
|
+
function Timer({ block, style, ctx, inherited, }) {
|
|
299
|
+
const [now, setNow] = React.useState(Date.now);
|
|
300
|
+
React.useEffect(() => {
|
|
301
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
302
|
+
return () => clearInterval(id);
|
|
303
|
+
}, []);
|
|
304
|
+
const text = (0, tags_js_1.timerText)(block, ctx.shownAt, now);
|
|
305
|
+
if (text === null)
|
|
306
|
+
return null;
|
|
307
|
+
return React.createElement(BlockText, { text: text, style: style, ctx: ctx, inherited: inherited });
|
|
308
|
+
}
|
|
309
|
+
function Carousel({ pages, autoAdvance, doc, }) {
|
|
310
|
+
const scroller = React.useRef(null);
|
|
311
|
+
const [size, setSize] = React.useState({ width: 0, height: 0 });
|
|
312
|
+
const [page, setPage] = React.useState(0);
|
|
313
|
+
React.useEffect(() => {
|
|
314
|
+
if (!autoAdvance || size.width === 0 || pages.length < 2)
|
|
315
|
+
return;
|
|
316
|
+
const id = setInterval(() => {
|
|
317
|
+
scroller.current?.scrollTo({
|
|
318
|
+
x: ((page + 1) % pages.length) * size.width,
|
|
319
|
+
animated: true,
|
|
320
|
+
});
|
|
321
|
+
}, autoAdvance * 1000);
|
|
322
|
+
return () => clearInterval(id);
|
|
323
|
+
}, [autoAdvance, size.width, page, pages.length]);
|
|
324
|
+
return (React.createElement(react_native_1.View, { style: { flex: 1 }, onLayout: (e) => setSize({
|
|
325
|
+
width: e.nativeEvent.layout.width,
|
|
326
|
+
height: e.nativeEvent.layout.height,
|
|
327
|
+
}) },
|
|
328
|
+
React.createElement(react_native_1.ScrollView, { ref: scroller, horizontal: true, pagingEnabled: true, showsHorizontalScrollIndicator: false, scrollEventThrottle: 16, onScroll: (e) => {
|
|
329
|
+
if (size.width > 0) {
|
|
330
|
+
setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
|
|
331
|
+
}
|
|
332
|
+
} }, pages.map((node, i) => (React.createElement(react_native_1.View, { key: i, style: size }, node)))),
|
|
333
|
+
pages.length > 1 ? (React.createElement(Dots, { count: pages.length, active: page, doc: doc, style: { bottom: 8 } })) : null));
|
|
303
334
|
}
|
|
335
|
+
function Dots({ count, active, doc, style, }) {
|
|
336
|
+
return (React.createElement(react_native_1.View, { pointerEvents: "none", style: [
|
|
337
|
+
{
|
|
338
|
+
position: "absolute",
|
|
339
|
+
left: 0,
|
|
340
|
+
right: 0,
|
|
341
|
+
flexDirection: "row",
|
|
342
|
+
justifyContent: "center",
|
|
343
|
+
gap: 6,
|
|
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
|
+
} })))));
|
|
352
|
+
}
|
|
353
|
+
function actionPress(block, ctx) {
|
|
354
|
+
return block.action === "close" ? ctx.onClose : ctx.navigate(block.action);
|
|
355
|
+
}
|
|
356
|
+
const ACTION_LABEL = {
|
|
357
|
+
close: "Close",
|
|
358
|
+
next: "Next",
|
|
359
|
+
back: "Back",
|
|
360
|
+
};
|
|
304
361
|
/** Typography that is NOT inherited (decoration) — everything else already
|
|
305
362
|
* arrives through the inherited set. */
|
|
306
363
|
function textOnly(s, ctx) {
|
|
@@ -399,6 +456,11 @@ function gridTracks(block) {
|
|
|
399
456
|
}
|
|
400
457
|
function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
401
458
|
const { doc } = ctx;
|
|
459
|
+
React.useEffect(() => {
|
|
460
|
+
if (block.type === "video") {
|
|
461
|
+
ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
|
|
462
|
+
}
|
|
463
|
+
}, [block.type]);
|
|
402
464
|
const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
|
|
403
465
|
// A pinned card judges its own context; every other block inherits the
|
|
404
466
|
// nearest plan card's. (A repeat card is judged per instance below.)
|
|
@@ -420,10 +482,28 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
420
482
|
const size = text.fontSize;
|
|
421
483
|
switch (block.type) {
|
|
422
484
|
case "text":
|
|
423
|
-
return (React.createElement(BlockText, { text: (0, tags_js_1.resolveTags)(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress:
|
|
424
|
-
case "
|
|
425
|
-
|
|
426
|
-
|
|
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 }));
|
|
486
|
+
case "timer":
|
|
487
|
+
return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
|
|
488
|
+
case "toggle": {
|
|
489
|
+
const toggle = (0, selection_js_1.toggleSelection)(block, ctx.packages, ctx.selectedId);
|
|
490
|
+
if (!toggle)
|
|
491
|
+
return null;
|
|
492
|
+
const flip = () => ctx.onSelect(toggle.target);
|
|
493
|
+
return (React.createElement(react_native_1.Pressable, { accessibilityRole: "switch", accessibilityState: { checked: toggle.on }, onPress: flip, style: [{ flexDirection: "row", alignItems: "center", gap: 12 }, view] },
|
|
494
|
+
React.createElement(BlockFill, { block: block, ctx: ctx }),
|
|
495
|
+
React.createElement(react_native_1.Text, { style: [
|
|
496
|
+
{ flex: 1 },
|
|
497
|
+
(0, style_js_1.inheritedTextStyle)(text, ctx.style),
|
|
498
|
+
textOnly(style, styleCtx),
|
|
499
|
+
] }, (0, tags_js_1.resolveTags)(block.label, ctx.packages.find((p) => p.packageId === ctx.selectedId), ctx.packages)),
|
|
500
|
+
React.createElement(react_native_1.Switch, { value: toggle.on, onValueChange: flip, trackColor: { true: doc.accent } })));
|
|
501
|
+
}
|
|
502
|
+
case "image":
|
|
503
|
+
case "video": {
|
|
504
|
+
const image = block.type === "image";
|
|
505
|
+
const url = image ? block.url || ctx.heroImageUrl : block.poster;
|
|
506
|
+
const round = image && block.shape === "circle";
|
|
427
507
|
const s = style;
|
|
428
508
|
// A converted design sizes its own slot; the 160pt default is only for
|
|
429
509
|
// a slot dropped into a flow column, and must not fight it.
|
|
@@ -433,22 +513,20 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
433
513
|
s.aspectRatio !== undefined ||
|
|
434
514
|
(s.top !== undefined && s.bottom !== undefined) ||
|
|
435
515
|
s.flex !== undefined);
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const ImageBox = closeImage ? react_native_1.Pressable : react_native_1.View;
|
|
441
|
-
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: [
|
|
442
520
|
sized ? null : { height: 160 },
|
|
443
521
|
{ borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
|
|
444
522
|
view,
|
|
445
523
|
] },
|
|
446
524
|
React.createElement(BlockFill, { block: block, ctx: ctx }),
|
|
447
|
-
url ? (React.createElement(react_native_1.Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : (React.createElement(react_native_1.View, { style: {
|
|
525
|
+
url ? (React.createElement(react_native_1.Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : image ? (React.createElement(react_native_1.View, { style: {
|
|
448
526
|
width: "100%",
|
|
449
527
|
height: "100%",
|
|
450
528
|
backgroundColor: (0, color_js_1.withAlpha)("#7d879b", 0.2),
|
|
451
|
-
} }))));
|
|
529
|
+
} })) : null));
|
|
452
530
|
}
|
|
453
531
|
case "list":
|
|
454
532
|
return (React.createElement(react_native_1.View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
|
|
@@ -479,12 +557,13 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
479
557
|
// A button the design marks as the close dismisses instead of buying,
|
|
480
558
|
// and takes no accent fill: the CTA must stay the one accented thing on
|
|
481
559
|
// the screen, or a "Not now" competes with "Subscribe" for the eye.
|
|
482
|
-
const close =
|
|
560
|
+
const close = block.action === "close" ? ctx.onClose : undefined;
|
|
561
|
+
const navigates = block.action === "next" || block.action === "back";
|
|
483
562
|
// A purchase in flight: the button keeps its box and fill, loses its
|
|
484
563
|
// label to a spinner in the ink colour, and ignores taps. A close
|
|
485
564
|
// button is never blocked — the user can always leave.
|
|
486
|
-
const busy = !close && ctx.loading;
|
|
487
|
-
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 }) => [
|
|
488
567
|
{
|
|
489
568
|
backgroundColor: close ? "transparent" : doc.accent,
|
|
490
569
|
borderRadius: 12,
|
|
@@ -557,20 +636,23 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
557
636
|
return (React.createElement(react_native_1.View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
|
|
558
637
|
case "card": {
|
|
559
638
|
const layout = block.layout ?? "column";
|
|
639
|
+
const carousel = layout === "carousel";
|
|
560
640
|
const base = layout === "stack"
|
|
561
641
|
? { position: "relative" }
|
|
562
|
-
:
|
|
563
|
-
? {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
642
|
+
: carousel
|
|
643
|
+
? { position: "relative", overflow: "hidden" }
|
|
644
|
+
: layout === "grid"
|
|
645
|
+
? {
|
|
646
|
+
position: "relative",
|
|
647
|
+
flexDirection: "row",
|
|
648
|
+
flexWrap: "wrap",
|
|
649
|
+
gap: style?.gap ?? 10,
|
|
650
|
+
}
|
|
651
|
+
: {
|
|
652
|
+
position: "relative",
|
|
653
|
+
flexDirection: layout === "row" ? "row" : "column",
|
|
654
|
+
gap: style?.gap ?? 10,
|
|
655
|
+
};
|
|
574
656
|
const tracks = layout === "grid" ? gridTracks(block) : null;
|
|
575
657
|
const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
|
|
576
658
|
const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
|
|
@@ -614,6 +696,9 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
614
696
|
base,
|
|
615
697
|
layout === "stack" ? null : { borderRadius: 16 },
|
|
616
698
|
view,
|
|
699
|
+
carousel
|
|
700
|
+
? { height: typeof style?.height === "number" ? style.height : 220 }
|
|
701
|
+
: null,
|
|
617
702
|
];
|
|
618
703
|
const cardPackage = block.packageIndex !== undefined
|
|
619
704
|
? (ctx.packages[block.packageIndex] ?? pkg)
|
|
@@ -622,7 +707,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
622
707
|
// how hand-styled plan rows (a highlighted annual beside a plain
|
|
623
708
|
// monthly) become tappable without a products block.
|
|
624
709
|
const pinned = block.packageIndex !== undefined && cardPackage !== undefined;
|
|
625
|
-
return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, renderChildren(cardPackage, context)));
|
|
710
|
+
return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, carousel ? (React.createElement(Carousel, { pages: renderChildren(cardPackage, context), autoAdvance: block.autoAdvance, doc: doc })) : (renderChildren(cardPackage, context))));
|
|
626
711
|
}
|
|
627
712
|
}
|
|
628
713
|
}
|
|
@@ -634,6 +719,14 @@ function RevnixBlockPaywall(props) {
|
|
|
634
719
|
// re-runs on every selection tap.
|
|
635
720
|
const doc = React.useMemo(() => (0, locale_js_1.localizeBlockDoc)(props.doc, props.locale ?? (0, locale_js_1.detectLocale)()), [props.doc, props.locale]);
|
|
636
721
|
const [internalSelected, setInternalSelected] = React.useState(undefined);
|
|
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);
|
|
637
730
|
// Contract §1: host → own tap → design highlight → first package, each
|
|
638
731
|
// only when the offering carries it.
|
|
639
732
|
const selectedId = (0, selection_js_1.selectedPackageId)(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
|
|
@@ -669,21 +762,26 @@ function RevnixBlockPaywall(props) {
|
|
|
669
762
|
onTerms: props.onTerms,
|
|
670
763
|
onPrivacy: props.onPrivacy,
|
|
671
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
|
+
},
|
|
672
769
|
footer: props.footer,
|
|
673
770
|
heroImageUrl: props.heroImageUrl,
|
|
674
771
|
gradient: props.gradient,
|
|
675
772
|
onDiagnostic: props.onDiagnostic,
|
|
676
773
|
style: styleCtx,
|
|
677
774
|
loading: props.loading === true,
|
|
775
|
+
shownAt,
|
|
678
776
|
};
|
|
679
|
-
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 })));
|
|
680
778
|
// REV-252: the renderer's own close, drawn only for a design that authors
|
|
681
779
|
// none. Rendered as a sibling of the tree — outside the canvas scale — so
|
|
682
780
|
// it keeps its tap size and its distance from the screen edge whatever the
|
|
683
781
|
// device width does to the design.
|
|
684
782
|
const safeAreaTop = props.safeAreaTop ??
|
|
685
783
|
(react_native_1.Platform.OS === "android" ? (react_native_1.StatusBar.currentHeight ?? 0) : 0);
|
|
686
|
-
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;
|
|
687
785
|
const [width, setWidth] = React.useState(null);
|
|
688
786
|
const [viewportHeight, setViewportHeight] = React.useState(null);
|
|
689
787
|
if (doc.layout === "canvas") {
|
|
@@ -704,7 +802,7 @@ function RevnixBlockPaywall(props) {
|
|
|
704
802
|
const canvasHeight = metrics?.scaledHeight ?? types_js_1.DESIGN_CANVAS.height;
|
|
705
803
|
const left = metrics?.left ?? 0;
|
|
706
804
|
return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
|
|
707
|
-
React.createElement(react_native_1.ScrollView, { contentContainerStyle: { height: canvasHeight },
|
|
805
|
+
React.createElement(react_native_1.ScrollView, { key: screenIndex, contentContainerStyle: { height: canvasHeight },
|
|
708
806
|
// A designed screen never shows a scrollbar — the web preview has
|
|
709
807
|
// none, and in an RTL app the indicator lands on the left edge of
|
|
710
808
|
// the artwork. When nothing overflows, scrolling is off entirely so
|
|
@@ -728,15 +826,18 @@ function RevnixBlockPaywall(props) {
|
|
|
728
826
|
{ translateY: -((canvasHeight / scale) * (1 - scale)) / 2 },
|
|
729
827
|
{ scale },
|
|
730
828
|
],
|
|
731
|
-
} },
|
|
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))),
|
|
732
832
|
fallbackClose));
|
|
733
833
|
}
|
|
734
834
|
return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
|
|
735
|
-
React.createElement(react_native_1.ScrollView, { contentContainerStyle: {
|
|
835
|
+
React.createElement(react_native_1.ScrollView, { key: screenIndex, contentContainerStyle: {
|
|
736
836
|
gap: 12,
|
|
737
837
|
paddingHorizontal: 20,
|
|
738
838
|
paddingTop: 18,
|
|
739
839
|
paddingBottom: 30,
|
|
740
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,
|
|
741
842
|
fallbackClose));
|
|
742
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/ios/RevnixStore.m
CHANGED
|
@@ -15,4 +15,8 @@ RCT_EXTERN_METHOD(finish:(NSString *)transactionId
|
|
|
15
15
|
resolver:(RCTPromiseResolveBlock)resolve
|
|
16
16
|
rejecter:(RCTPromiseRejectBlock)reject)
|
|
17
17
|
|
|
18
|
+
RCT_EXTERN_METHOD(deviceIntegrity:(NSString *)customerId
|
|
19
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
20
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
21
|
+
|
|
18
22
|
@end
|
package/ios/RevnixStore.swift
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import DeviceCheck
|
|
1
3
|
import Foundation
|
|
2
4
|
import React
|
|
3
5
|
import StoreKit
|
|
@@ -88,6 +90,33 @@ class RevnixStore: RCTEventEmitter {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
@objc(deviceIntegrity:resolver:rejecter:)
|
|
94
|
+
func deviceIntegrity(
|
|
95
|
+
_ customerId: String,
|
|
96
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
97
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
98
|
+
) {
|
|
99
|
+
let service = DCAppAttestService.shared
|
|
100
|
+
guard service.isSupported else {
|
|
101
|
+
resolve(nil)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
Task {
|
|
105
|
+
do {
|
|
106
|
+
let keyId = try await service.generateKey()
|
|
107
|
+
let clientDataHash = Data(SHA256.hash(data: Data(customerId.utf8)))
|
|
108
|
+
let attestation = try await service.attestKey(keyId, clientDataHash: clientDataHash)
|
|
109
|
+
resolve([
|
|
110
|
+
"platform": "ios",
|
|
111
|
+
"keyId": keyId,
|
|
112
|
+
"attestation": attestation.base64EncodedString(),
|
|
113
|
+
])
|
|
114
|
+
} catch {
|
|
115
|
+
resolve(nil)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
private static func payload(_ verification: VerificationResult<Transaction>) -> [String: Any] {
|
|
92
121
|
let transaction = verification.unsafePayloadValue
|
|
93
122
|
var payload: [String: Any] = [
|
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
|
@@ -125,7 +125,14 @@ function localizeBlock(
|
|
|
125
125
|
case "text":
|
|
126
126
|
return { ...block, text: at("text", block.text) };
|
|
127
127
|
case "button":
|
|
128
|
+
case "toggle":
|
|
128
129
|
return { ...block, label: at("label", block.label) };
|
|
130
|
+
case "timer":
|
|
131
|
+
return {
|
|
132
|
+
...block,
|
|
133
|
+
format: opt("format", block.format),
|
|
134
|
+
expiredText: opt("expiredText", block.expiredText),
|
|
135
|
+
};
|
|
129
136
|
case "list":
|
|
130
137
|
return {
|
|
131
138
|
...block,
|
|
@@ -186,6 +193,13 @@ export function localizeBlockDoc(
|
|
|
186
193
|
...doc,
|
|
187
194
|
defaultLocale: chain[0],
|
|
188
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
|
+
: {}),
|
|
189
203
|
};
|
|
190
204
|
}
|
|
191
205
|
|
package/src/blocks/selection.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
CardBlock,
|
|
12
12
|
PaywallBlock,
|
|
13
13
|
PaywallBlockDoc,
|
|
14
|
+
ToggleBlock,
|
|
14
15
|
} from "./types.js";
|
|
15
16
|
import { DESIGN_CANVAS } from "./types.js";
|
|
16
17
|
import { resolveTags } from "./tags.js";
|
|
@@ -33,6 +34,18 @@ export function selectedPackageId(
|
|
|
33
34
|
return packages[0]?.packageId;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export function toggleSelection(
|
|
38
|
+
block: ToggleBlock,
|
|
39
|
+
packages: ReadonlyArray<{ packageId: string }>,
|
|
40
|
+
selectedId: string | undefined
|
|
41
|
+
): { on: boolean; target: string } | null {
|
|
42
|
+
const on = packages[block.onPackageIndex];
|
|
43
|
+
const off = packages[block.offPackageIndex];
|
|
44
|
+
if (on === undefined || off === undefined) return null;
|
|
45
|
+
const isOn = on.packageId === selectedId;
|
|
46
|
+
return { on: isOn, target: (isOn ? off : on).packageId };
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
/** The context a package-bearing card opens for itself and its subtree. */
|
|
37
50
|
export function packageContext(
|
|
38
51
|
pkg: { packageId: string } | undefined,
|
package/src/blocks/tags.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// approximated — a wrong price must never reach a customer silently.
|
|
12
12
|
|
|
13
13
|
import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
|
|
14
|
+
import type { TimerBlock } from "./types.js";
|
|
14
15
|
|
|
15
16
|
/** Renewal cycles, in months. Lifetime and one-time products have no cycle. */
|
|
16
17
|
const MONTHS: Record<string, number | undefined> = {
|
|
@@ -123,3 +124,40 @@ export function resolveTags(
|
|
|
123
124
|
}
|
|
124
125
|
});
|
|
125
126
|
}
|
|
127
|
+
|
|
128
|
+
export function formatCountdown(format: string, remaining: number): string {
|
|
129
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
130
|
+
let span = Infinity;
|
|
131
|
+
const part = (token: string, unit: number) => {
|
|
132
|
+
const value = Math.floor((remaining % span) / unit);
|
|
133
|
+
if (format.includes(token)) span = unit;
|
|
134
|
+
return value;
|
|
135
|
+
};
|
|
136
|
+
const days = part("{d}", 86400);
|
|
137
|
+
const hours = part("{hh}", 3600);
|
|
138
|
+
const minutes = part("{mm}", 60);
|
|
139
|
+
const seconds = part("{ss}", 1);
|
|
140
|
+
return format
|
|
141
|
+
.replace(/\{d\}/g, String(days))
|
|
142
|
+
.replace(/\{hh\}/g, pad(hours))
|
|
143
|
+
.replace(/\{mm\}/g, pad(minutes))
|
|
144
|
+
.replace(/\{ss\}/g, pad(seconds));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function timerText(
|
|
148
|
+
block: TimerBlock,
|
|
149
|
+
shownAt: number,
|
|
150
|
+
now: number,
|
|
151
|
+
): string | null {
|
|
152
|
+
const deadline =
|
|
153
|
+
block.endsAt !== undefined
|
|
154
|
+
? Date.parse(block.endsAt)
|
|
155
|
+
: shownAt + (block.duration ?? 0) * 1000;
|
|
156
|
+
const remaining = Number.isNaN(deadline)
|
|
157
|
+
? 0
|
|
158
|
+
: Math.max(0, Math.floor((deadline - now) / 1000));
|
|
159
|
+
if (remaining > 0) {
|
|
160
|
+
return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
|
|
161
|
+
}
|
|
162
|
+
return block.expiredText ?? null;
|
|
163
|
+
}
|