react-native-revnix 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +7 -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 +45 -3
- package/dist/blocks/types.js +1 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +73 -5
- package/dist/esm/blocks/locale.js +7 -0
- package/dist/esm/blocks/selection.js +8 -0
- package/dist/esm/blocks/tags.js +31 -0
- package/dist/esm/blocks/types.js +1 -0
- package/dist/esm/client.js +73 -5
- package/dist/esm/store/index.js +7 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +103 -22
- 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 +100 -19
- 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 +7 -0
- package/src/blocks/selection.ts +13 -0
- package/src/blocks/tags.ts +38 -0
- package/src/blocks/types.ts +51 -2
- 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 +186 -20
- package/src/version.ts +1 -1
|
@@ -295,6 +295,56 @@ function BlockText({ text, style, ctx, inherited, onPress, }) {
|
|
|
295
295
|
}
|
|
296
296
|
return React.createElement(react_native_1.View, { style: (0, style_js_1.toViewStyle)(style, styleCtx) }, body);
|
|
297
297
|
}
|
|
298
|
+
function Timer({ block, style, ctx, inherited, }) {
|
|
299
|
+
const [now, setNow] = React.useState(Date.now);
|
|
300
|
+
React.useEffect(() => {
|
|
301
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
302
|
+
return () => clearInterval(id);
|
|
303
|
+
}, []);
|
|
304
|
+
const text = (0, tags_js_1.timerText)(block, ctx.shownAt, now);
|
|
305
|
+
if (text === null)
|
|
306
|
+
return null;
|
|
307
|
+
return React.createElement(BlockText, { text: text, style: style, ctx: ctx, inherited: inherited });
|
|
308
|
+
}
|
|
309
|
+
function Carousel({ pages, autoAdvance, doc, }) {
|
|
310
|
+
const scroller = React.useRef(null);
|
|
311
|
+
const [size, setSize] = React.useState({ width: 0, height: 0 });
|
|
312
|
+
const [page, setPage] = React.useState(0);
|
|
313
|
+
React.useEffect(() => {
|
|
314
|
+
if (!autoAdvance || size.width === 0 || pages.length < 2)
|
|
315
|
+
return;
|
|
316
|
+
const id = setInterval(() => {
|
|
317
|
+
scroller.current?.scrollTo({
|
|
318
|
+
x: ((page + 1) % pages.length) * size.width,
|
|
319
|
+
animated: true,
|
|
320
|
+
});
|
|
321
|
+
}, autoAdvance * 1000);
|
|
322
|
+
return () => clearInterval(id);
|
|
323
|
+
}, [autoAdvance, size.width, page, pages.length]);
|
|
324
|
+
return (React.createElement(react_native_1.View, { style: { flex: 1 }, onLayout: (e) => setSize({
|
|
325
|
+
width: e.nativeEvent.layout.width,
|
|
326
|
+
height: e.nativeEvent.layout.height,
|
|
327
|
+
}) },
|
|
328
|
+
React.createElement(react_native_1.ScrollView, { ref: scroller, horizontal: true, pagingEnabled: true, showsHorizontalScrollIndicator: false, scrollEventThrottle: 16, onScroll: (e) => {
|
|
329
|
+
if (size.width > 0) {
|
|
330
|
+
setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
|
|
331
|
+
}
|
|
332
|
+
} }, pages.map((node, i) => (React.createElement(react_native_1.View, { key: i, style: size }, node)))),
|
|
333
|
+
pages.length > 1 ? (React.createElement(react_native_1.View, { pointerEvents: "none", style: {
|
|
334
|
+
position: "absolute",
|
|
335
|
+
bottom: 8,
|
|
336
|
+
left: 0,
|
|
337
|
+
right: 0,
|
|
338
|
+
flexDirection: "row",
|
|
339
|
+
justifyContent: "center",
|
|
340
|
+
gap: 6,
|
|
341
|
+
} }, pages.map((_, i) => (React.createElement(react_native_1.View, { key: i, style: {
|
|
342
|
+
width: 6,
|
|
343
|
+
height: 6,
|
|
344
|
+
borderRadius: 3,
|
|
345
|
+
backgroundColor: i === page ? doc.accent : (0, color_js_1.withAlpha)(doc.textColor, 0.3),
|
|
346
|
+
} }))))) : null));
|
|
347
|
+
}
|
|
298
348
|
/** The press handler for a block the design marks as the close, or undefined
|
|
299
349
|
* when it is not one (or the host wired no dismissal). One helper so text,
|
|
300
350
|
* image and button blocks cannot drift on what "action: close" means. */
|
|
@@ -399,6 +449,11 @@ function gridTracks(block) {
|
|
|
399
449
|
}
|
|
400
450
|
function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
401
451
|
const { doc } = ctx;
|
|
452
|
+
React.useEffect(() => {
|
|
453
|
+
if (block.type === "video") {
|
|
454
|
+
ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
|
|
455
|
+
}
|
|
456
|
+
}, [block.type]);
|
|
402
457
|
const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
|
|
403
458
|
// A pinned card judges its own context; every other block inherits the
|
|
404
459
|
// nearest plan card's. (A repeat card is judged per instance below.)
|
|
@@ -421,9 +476,27 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
421
476
|
switch (block.type) {
|
|
422
477
|
case "text":
|
|
423
478
|
return (React.createElement(BlockText, { text: (0, tags_js_1.resolveTags)(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: closePress(block, ctx) }));
|
|
424
|
-
case "
|
|
425
|
-
|
|
426
|
-
|
|
479
|
+
case "timer":
|
|
480
|
+
return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
|
|
481
|
+
case "toggle": {
|
|
482
|
+
const toggle = (0, selection_js_1.toggleSelection)(block, ctx.packages, ctx.selectedId);
|
|
483
|
+
if (!toggle)
|
|
484
|
+
return null;
|
|
485
|
+
const flip = () => ctx.onSelect(toggle.target);
|
|
486
|
+
return (React.createElement(react_native_1.Pressable, { accessibilityRole: "switch", accessibilityState: { checked: toggle.on }, onPress: flip, style: [{ flexDirection: "row", alignItems: "center", gap: 12 }, view] },
|
|
487
|
+
React.createElement(BlockFill, { block: block, ctx: ctx }),
|
|
488
|
+
React.createElement(react_native_1.Text, { style: [
|
|
489
|
+
{ flex: 1 },
|
|
490
|
+
(0, style_js_1.inheritedTextStyle)(text, ctx.style),
|
|
491
|
+
textOnly(style, styleCtx),
|
|
492
|
+
] }, (0, tags_js_1.resolveTags)(block.label, ctx.packages.find((p) => p.packageId === ctx.selectedId), ctx.packages)),
|
|
493
|
+
React.createElement(react_native_1.Switch, { value: toggle.on, onValueChange: flip, trackColor: { true: doc.accent } })));
|
|
494
|
+
}
|
|
495
|
+
case "image":
|
|
496
|
+
case "video": {
|
|
497
|
+
const image = block.type === "image";
|
|
498
|
+
const url = image ? block.url || ctx.heroImageUrl : block.poster;
|
|
499
|
+
const round = image && block.shape === "circle";
|
|
427
500
|
const s = style;
|
|
428
501
|
// A converted design sizes its own slot; the 160pt default is only for
|
|
429
502
|
// a slot dropped into a flow column, and must not fight it.
|
|
@@ -436,7 +509,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
436
509
|
// An icon the design marks as the close (REV-252). Same rule as text:
|
|
437
510
|
// the whole box is the target, and the style lands on the Pressable so
|
|
438
511
|
// a pinned icon keeps its offsets.
|
|
439
|
-
const closeImage = closePress(block, ctx);
|
|
512
|
+
const closeImage = image ? closePress(block, ctx) : undefined;
|
|
440
513
|
const ImageBox = closeImage ? react_native_1.Pressable : react_native_1.View;
|
|
441
514
|
return (React.createElement(ImageBox, { accessibilityRole: closeImage ? "button" : undefined, accessibilityLabel: closeImage ? "Close" : undefined, hitSlop: closeImage ? 8 : undefined, onPress: closeImage, style: [
|
|
442
515
|
sized ? null : { height: 160 },
|
|
@@ -444,11 +517,11 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
444
517
|
view,
|
|
445
518
|
] },
|
|
446
519
|
React.createElement(BlockFill, { block: block, ctx: ctx }),
|
|
447
|
-
url ? (React.createElement(react_native_1.Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : (React.createElement(react_native_1.View, { style: {
|
|
520
|
+
url ? (React.createElement(react_native_1.Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : image ? (React.createElement(react_native_1.View, { style: {
|
|
448
521
|
width: "100%",
|
|
449
522
|
height: "100%",
|
|
450
523
|
backgroundColor: (0, color_js_1.withAlpha)("#7d879b", 0.2),
|
|
451
|
-
} }))));
|
|
524
|
+
} })) : null));
|
|
452
525
|
}
|
|
453
526
|
case "list":
|
|
454
527
|
return (React.createElement(react_native_1.View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
|
|
@@ -557,20 +630,23 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
557
630
|
return (React.createElement(react_native_1.View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
|
|
558
631
|
case "card": {
|
|
559
632
|
const layout = block.layout ?? "column";
|
|
633
|
+
const carousel = layout === "carousel";
|
|
560
634
|
const base = layout === "stack"
|
|
561
635
|
? { position: "relative" }
|
|
562
|
-
:
|
|
563
|
-
? {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
636
|
+
: carousel
|
|
637
|
+
? { position: "relative", overflow: "hidden" }
|
|
638
|
+
: layout === "grid"
|
|
639
|
+
? {
|
|
640
|
+
position: "relative",
|
|
641
|
+
flexDirection: "row",
|
|
642
|
+
flexWrap: "wrap",
|
|
643
|
+
gap: style?.gap ?? 10,
|
|
644
|
+
}
|
|
645
|
+
: {
|
|
646
|
+
position: "relative",
|
|
647
|
+
flexDirection: layout === "row" ? "row" : "column",
|
|
648
|
+
gap: style?.gap ?? 10,
|
|
649
|
+
};
|
|
574
650
|
const tracks = layout === "grid" ? gridTracks(block) : null;
|
|
575
651
|
const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
|
|
576
652
|
const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
|
|
@@ -614,6 +690,9 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
614
690
|
base,
|
|
615
691
|
layout === "stack" ? null : { borderRadius: 16 },
|
|
616
692
|
view,
|
|
693
|
+
carousel
|
|
694
|
+
? { height: typeof style?.height === "number" ? style.height : 220 }
|
|
695
|
+
: null,
|
|
617
696
|
];
|
|
618
697
|
const cardPackage = block.packageIndex !== undefined
|
|
619
698
|
? (ctx.packages[block.packageIndex] ?? pkg)
|
|
@@ -622,7 +701,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
|
|
|
622
701
|
// how hand-styled plan rows (a highlighted annual beside a plain
|
|
623
702
|
// monthly) become tappable without a products block.
|
|
624
703
|
const pinned = block.packageIndex !== undefined && cardPackage !== undefined;
|
|
625
|
-
return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, renderChildren(cardPackage, context)));
|
|
704
|
+
return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, carousel ? (React.createElement(Carousel, { pages: renderChildren(cardPackage, context), autoAdvance: block.autoAdvance, doc: doc })) : (renderChildren(cardPackage, context))));
|
|
626
705
|
}
|
|
627
706
|
}
|
|
628
707
|
}
|
|
@@ -634,6 +713,7 @@ function RevnixBlockPaywall(props) {
|
|
|
634
713
|
// re-runs on every selection tap.
|
|
635
714
|
const doc = React.useMemo(() => (0, locale_js_1.localizeBlockDoc)(props.doc, props.locale ?? (0, locale_js_1.detectLocale)()), [props.doc, props.locale]);
|
|
636
715
|
const [internalSelected, setInternalSelected] = React.useState(undefined);
|
|
716
|
+
const [shownAt] = React.useState(Date.now);
|
|
637
717
|
// Contract §1: host → own tap → design highlight → first package, each
|
|
638
718
|
// only when the offering carries it.
|
|
639
719
|
const selectedId = (0, selection_js_1.selectedPackageId)(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
|
|
@@ -675,6 +755,7 @@ function RevnixBlockPaywall(props) {
|
|
|
675
755
|
onDiagnostic: props.onDiagnostic,
|
|
676
756
|
style: styleCtx,
|
|
677
757
|
loading: props.loading === true,
|
|
758
|
+
shownAt,
|
|
678
759
|
};
|
|
679
760
|
const children = doc.blocks.map((block) => (React.createElement(Block, { key: block.id, block: block, ctx: ctx, pkg: packages.find((p) => p.packageId === selectedId) ?? packages[0], inherited: rootText })));
|
|
680
761
|
// REV-252: the renderer's own close, drawn only for a design that authors
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "1.
|
|
1
|
+
export declare const SDK_VERSION = "1.7.0";
|
package/dist/version.js
CHANGED
|
@@ -4,4 +4,4 @@ exports.SDK_VERSION = void 0;
|
|
|
4
4
|
// L3: the single source of the SDK version inside src/. Must match
|
|
5
5
|
// package.json "version" — version.test.ts fails the build when they drift
|
|
6
6
|
// (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
|
|
7
|
-
exports.SDK_VERSION = "1.
|
|
7
|
+
exports.SDK_VERSION = "1.7.0";
|
package/ios/RevnixStore.m
CHANGED
|
@@ -15,4 +15,8 @@ RCT_EXTERN_METHOD(finish:(NSString *)transactionId
|
|
|
15
15
|
resolver:(RCTPromiseResolveBlock)resolve
|
|
16
16
|
rejecter:(RCTPromiseRejectBlock)reject)
|
|
17
17
|
|
|
18
|
+
RCT_EXTERN_METHOD(deviceIntegrity:(NSString *)customerId
|
|
19
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
20
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
21
|
+
|
|
18
22
|
@end
|
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.7.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,
|
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
|
+
}
|
package/src/blocks/types.ts
CHANGED
|
@@ -257,13 +257,58 @@ export interface SpacerBlock {
|
|
|
257
257
|
visibility?: BlockVisibility;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
/** Countdown text. `endsAt` is one deadline for everyone; `duration` counts
|
|
261
|
+
* from the moment this paywall view opened. At zero it draws `expiredText`,
|
|
262
|
+
* or nothing when that is absent. Tokens in `format`: {d} {hh} {mm} {ss}. */
|
|
263
|
+
export interface TimerBlock {
|
|
264
|
+
id: string;
|
|
265
|
+
type: "timer";
|
|
266
|
+
endsAt?: string;
|
|
267
|
+
duration?: number;
|
|
268
|
+
format?: string;
|
|
269
|
+
expiredText?: string;
|
|
270
|
+
style?: BlockStyle;
|
|
271
|
+
selectedStyle?: BlockStyle;
|
|
272
|
+
visibility?: BlockVisibility;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A muted looping clip. React Native has no built-in player, so this SDK
|
|
276
|
+
* draws `poster` in its place, sized like an image block. */
|
|
277
|
+
export interface VideoBlock {
|
|
278
|
+
id: string;
|
|
279
|
+
type: "video";
|
|
280
|
+
url: string;
|
|
281
|
+
poster?: string;
|
|
282
|
+
fit?: "cover" | "contain";
|
|
283
|
+
style?: BlockStyle;
|
|
284
|
+
selectedStyle?: BlockStyle;
|
|
285
|
+
visibility?: BlockVisibility;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The free-trial switch: ON while package `onPackageIndex` is selected, and
|
|
289
|
+
* tapping it selects the other one. Hidden when the offering lacks either
|
|
290
|
+
* index. */
|
|
291
|
+
export interface ToggleBlock {
|
|
292
|
+
id: string;
|
|
293
|
+
type: "toggle";
|
|
294
|
+
label: string;
|
|
295
|
+
onPackageIndex: number;
|
|
296
|
+
offPackageIndex: number;
|
|
297
|
+
style?: BlockStyle;
|
|
298
|
+
selectedStyle?: BlockStyle;
|
|
299
|
+
visibility?: BlockVisibility;
|
|
300
|
+
}
|
|
301
|
+
|
|
260
302
|
/** The one container block. `layout` picks how children are placed: column /
|
|
261
303
|
* row are flex lines, `stack` layers them (children position with
|
|
262
|
-
* style.inset or the edge offsets),
|
|
304
|
+
* style.inset or the edge offsets), `grid` is an N-column grid, and
|
|
305
|
+
* `carousel` pages through its children one swipe at a time. */
|
|
263
306
|
export interface CardBlock {
|
|
264
307
|
id: string;
|
|
265
308
|
type: "card";
|
|
266
|
-
layout?: "column" | "row" | "stack" | "grid";
|
|
309
|
+
layout?: "column" | "row" | "stack" | "grid" | "carousel";
|
|
310
|
+
/** carousel only: seconds per page before it moves on by itself. */
|
|
311
|
+
autoAdvance?: number;
|
|
267
312
|
/** Renders this container once per package in the attached offering. Its
|
|
268
313
|
* subtree reads the package through tag variables. */
|
|
269
314
|
repeat?: "packages";
|
|
@@ -292,6 +337,9 @@ export type PaywallBlock =
|
|
|
292
337
|
| LinksBlock
|
|
293
338
|
| LineBlock
|
|
294
339
|
| SpacerBlock
|
|
340
|
+
| TimerBlock
|
|
341
|
+
| VideoBlock
|
|
342
|
+
| ToggleBlock
|
|
295
343
|
| CardBlock;
|
|
296
344
|
|
|
297
345
|
/** The published document: screen palette plus the block tree. */
|
|
@@ -345,6 +393,7 @@ export function hasCloseAction(blocks: PaywallBlock[]): boolean {
|
|
|
345
393
|
: b.type === "card"
|
|
346
394
|
? b.repeat === undefined &&
|
|
347
395
|
b.packageIndex === undefined &&
|
|
396
|
+
b.layout !== "carousel" &&
|
|
348
397
|
hasCloseAction(b.children)
|
|
349
398
|
: (b.type === "text" || b.type === "image" || b.type === "button") &&
|
|
350
399
|
b.action === "close"
|
package/src/client.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type {
|
|
|
45
45
|
IdentifyResult,
|
|
46
46
|
IdentifyStrategy,
|
|
47
47
|
IdentityInfo,
|
|
48
|
+
IntegrityEvidence,
|
|
48
49
|
LastDeepLink,
|
|
49
50
|
MmpAttribution,
|
|
50
51
|
PaywallInteraction,
|
|
@@ -67,9 +68,11 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
|
|
|
67
68
|
const PLACEMENTS_CACHE_KEY = "revnix.placements";
|
|
68
69
|
const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
|
|
69
70
|
const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
|
|
71
|
+
const LAST_AD_ID_KEY = "revnix.lastAdId";
|
|
70
72
|
const EVENT_QUEUE_KEY = "revnix.eventQueue";
|
|
71
73
|
const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
|
|
72
74
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
75
|
+
const INTEGRITY_TIMEOUT_MS = 10_000;
|
|
73
76
|
const EVENT_QUEUE_CAP = 1000;
|
|
74
77
|
const EVENT_BATCH_SIZE = 100;
|
|
75
78
|
const EVENT_FLUSH_THRESHOLD = 20;
|
|
@@ -200,6 +203,21 @@ const jittered = (ms: number) => ms * (0.8 + Math.random() * 0.4);
|
|
|
200
203
|
// half a minute; long enough that gating five screens costs one request.
|
|
201
204
|
const DEFAULT_ENTITLEMENTS_TTL_MS = 30_000;
|
|
202
205
|
|
|
206
|
+
const fitsWithin = (value: unknown, max: number): value is string =>
|
|
207
|
+
typeof value === "string" && value.length > 0 && value.length <= max;
|
|
208
|
+
|
|
209
|
+
function wellFormedIntegrity(evidence: unknown): IntegrityEvidence | undefined {
|
|
210
|
+
if (typeof evidence !== "object" || evidence === null) return undefined;
|
|
211
|
+
const { platform, keyId, attestation, token } = evidence as Record<string, unknown>;
|
|
212
|
+
if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16_384)) {
|
|
213
|
+
return { platform, keyId, attestation };
|
|
214
|
+
}
|
|
215
|
+
if (platform === "android" && fitsWithin(token, 16_384)) {
|
|
216
|
+
return { platform, token };
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
export class RevnixClient {
|
|
204
222
|
private readonly apiKey: string;
|
|
205
223
|
private readonly baseUrl: string;
|
|
@@ -244,6 +262,11 @@ export class RevnixClient {
|
|
|
244
262
|
private readonly onImplicitPaywall?: RevnixConfig["onImplicitPaywall"];
|
|
245
263
|
private readonly onDeferredDeepLink?: RevnixConfig["onDeferredDeepLink"];
|
|
246
264
|
private readonly onAttribution?: RevnixConfig["onAttribution"];
|
|
265
|
+
private readonly deviceIntegrity?: RevnixConfig["deviceIntegrity"];
|
|
266
|
+
private integrityRequest: {
|
|
267
|
+
customerId: string;
|
|
268
|
+
body: Promise<{ integrity?: IntegrityEvidence }>;
|
|
269
|
+
} | null = null;
|
|
247
270
|
private deferredDeepLinkClaimed = false;
|
|
248
271
|
private landingUrl: string | null = null;
|
|
249
272
|
private lastDeepLinkWrite: Promise<void> = Promise.resolve();
|
|
@@ -316,6 +339,7 @@ export class RevnixClient {
|
|
|
316
339
|
this.onDiagnostic = config.onDiagnostic;
|
|
317
340
|
this.onDeferredDeepLink = config.onDeferredDeepLink;
|
|
318
341
|
this.onAttribution = config.onAttribution;
|
|
342
|
+
this.deviceIntegrity = config.deviceIntegrity;
|
|
319
343
|
this.readYourWritesDelaysMs =
|
|
320
344
|
config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
|
|
321
345
|
this.customerIdPromise = this.loadCustomerId(config.customerId);
|
|
@@ -324,6 +348,7 @@ export class RevnixClient {
|
|
|
324
348
|
void this.retryPendingPurchases().catch((err) =>
|
|
325
349
|
this.diag("retryPendingPurchases", err)
|
|
326
350
|
);
|
|
351
|
+
if (config.advertisingId) this.reportAdvertisingId(config.advertisingId);
|
|
327
352
|
const store = getStoreAdapter();
|
|
328
353
|
if (store) {
|
|
329
354
|
this.stopStoreObserver = store.observe((tx) =>
|
|
@@ -1247,6 +1272,7 @@ export class RevnixClient {
|
|
|
1247
1272
|
...(attribution.campaign ? { campaign: attribution.campaign } : {}),
|
|
1248
1273
|
...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
|
|
1249
1274
|
...(attribution.creative ? { creative: attribution.creative } : {}),
|
|
1275
|
+
...(await this.integrityFor(customerId)),
|
|
1250
1276
|
});
|
|
1251
1277
|
await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
|
|
1252
1278
|
})
|
|
@@ -1283,6 +1309,23 @@ export class RevnixClient {
|
|
|
1283
1309
|
.catch((err) => this.diag("setPushToken", err));
|
|
1284
1310
|
}
|
|
1285
1311
|
|
|
1312
|
+
private reportAdvertisingId(
|
|
1313
|
+
source: NonNullable<RevnixConfig["advertisingId"]>
|
|
1314
|
+
): void {
|
|
1315
|
+
void Promise.resolve()
|
|
1316
|
+
.then(source)
|
|
1317
|
+
.then(async (value) => {
|
|
1318
|
+
const id = value || null;
|
|
1319
|
+
const customerId = await this.customerIdPromise;
|
|
1320
|
+
const payload = JSON.stringify({ customerId, id });
|
|
1321
|
+
const last = await this.storage.getItem(LAST_AD_ID_KEY);
|
|
1322
|
+
if (last === payload || (id === null && last === null)) return;
|
|
1323
|
+
await this.setAttributes({ gps_adid: id });
|
|
1324
|
+
await this.storage.setItem(LAST_AD_ID_KEY, payload);
|
|
1325
|
+
})
|
|
1326
|
+
.catch((err) => this.diag("advertisingId", err));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1286
1329
|
/** REV-272: the two moments that happen ON a paywall, with the loop guard
|
|
1287
1330
|
* applied. `fromPaywallId` travels so the server can refuse to hand back
|
|
1288
1331
|
* the very paywall being dismissed — the backstop for a host that reports
|
|
@@ -1519,13 +1562,14 @@ export class RevnixClient {
|
|
|
1519
1562
|
// one-launch phantoms. Same guard, same reason, as triggerImplicit.
|
|
1520
1563
|
if (this.ephemeralAnonId) return;
|
|
1521
1564
|
void this.customerIdPromise
|
|
1522
|
-
.then((customerId) =>
|
|
1565
|
+
.then(async (customerId) =>
|
|
1523
1566
|
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1524
1567
|
customerId,
|
|
1525
1568
|
occurredAt: Date.now(),
|
|
1526
1569
|
sdkVersion: SDK_VERSION,
|
|
1527
1570
|
installReferrer: referrer.slice(0, 1024),
|
|
1528
1571
|
...this.installMetadata,
|
|
1572
|
+
...(await this.integrityFor(customerId)),
|
|
1529
1573
|
})
|
|
1530
1574
|
)
|
|
1531
1575
|
.then((body) => {
|
|
@@ -1560,13 +1604,14 @@ export class RevnixClient {
|
|
|
1560
1604
|
if (this.disposed || token.trim().length === 0) return Promise.resolve(undefined);
|
|
1561
1605
|
if (this.ephemeralAnonId) return Promise.resolve(undefined);
|
|
1562
1606
|
return this.customerIdPromise
|
|
1563
|
-
.then((customerId) =>
|
|
1607
|
+
.then(async (customerId) =>
|
|
1564
1608
|
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1565
1609
|
customerId,
|
|
1566
1610
|
occurredAt: Date.now(),
|
|
1567
1611
|
sdkVersion: SDK_VERSION,
|
|
1568
1612
|
attributionToken: token.slice(0, 2048),
|
|
1569
1613
|
...this.installMetadata,
|
|
1614
|
+
...(await this.integrityFor(customerId)),
|
|
1570
1615
|
})
|
|
1571
1616
|
)
|
|
1572
1617
|
.then((body) => {
|
|
@@ -1921,12 +1966,17 @@ export class RevnixClient {
|
|
|
1921
1966
|
/** Fire-and-forget install report (REV-093). Never blocks startup and
|
|
1922
1967
|
* never throws — the backend absorbs retries idempotently per id. */
|
|
1923
1968
|
private reportInstall(customerId: string): void {
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1969
|
+
const occurredAt = Date.now();
|
|
1970
|
+
void this.integrityFor(customerId)
|
|
1971
|
+
.then((integrity) =>
|
|
1972
|
+
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1973
|
+
customerId,
|
|
1974
|
+
occurredAt,
|
|
1975
|
+
sdkVersion: SDK_VERSION,
|
|
1976
|
+
...this.installMetadata,
|
|
1977
|
+
...integrity,
|
|
1978
|
+
})
|
|
1979
|
+
)
|
|
1930
1980
|
.then((body) => {
|
|
1931
1981
|
if (!this.ephemeralAnonId) this.refreshAttribution();
|
|
1932
1982
|
return this.deliverDeferredDeepLink(body.deferredDeepLink);
|
|
@@ -1934,6 +1984,38 @@ export class RevnixClient {
|
|
|
1934
1984
|
.catch((err) => this.diag("reportInstall", err));
|
|
1935
1985
|
}
|
|
1936
1986
|
|
|
1987
|
+
private integrityFor(
|
|
1988
|
+
customerId: string
|
|
1989
|
+
): Promise<{ integrity?: IntegrityEvidence }> {
|
|
1990
|
+
const source = this.deviceIntegrity;
|
|
1991
|
+
if (!source) return Promise.resolve({});
|
|
1992
|
+
if (this.integrityRequest?.customerId !== customerId) {
|
|
1993
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1994
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
1995
|
+
timer = setTimeout(
|
|
1996
|
+
() => reject(new Error("device integrity timed out")),
|
|
1997
|
+
INTEGRITY_TIMEOUT_MS
|
|
1998
|
+
);
|
|
1999
|
+
});
|
|
2000
|
+
const body = Promise.race([
|
|
2001
|
+
Promise.resolve().then(() => source(customerId)),
|
|
2002
|
+
timeout,
|
|
2003
|
+
])
|
|
2004
|
+
.then((evidence) => {
|
|
2005
|
+
const integrity = wellFormedIntegrity(evidence);
|
|
2006
|
+
if (!integrity) throw new Error("device integrity unavailable");
|
|
2007
|
+
return { integrity };
|
|
2008
|
+
})
|
|
2009
|
+
.catch((err: unknown) => {
|
|
2010
|
+
this.diag("deviceIntegrity", err);
|
|
2011
|
+
return {};
|
|
2012
|
+
})
|
|
2013
|
+
.finally(() => clearTimeout(timer));
|
|
2014
|
+
this.integrityRequest = { customerId, body };
|
|
2015
|
+
}
|
|
2016
|
+
return this.integrityRequest.body;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1937
2019
|
/** REV-268: assemble and encode the device facts once. Runtime-detected
|
|
1938
2020
|
* facts first, the app's `device` over them, then the three the SDK owns.
|
|
1939
2021
|
* `installedAt` is the first launch this storage ever saw — written on
|
package/src/index.ts
CHANGED
package/src/store/index.ts
CHANGED
|
@@ -5,11 +5,14 @@ import {
|
|
|
5
5
|
type StoreAdapter,
|
|
6
6
|
type StoreTransaction,
|
|
7
7
|
} from "../store.js";
|
|
8
|
+
import type { IntegrityEvidence, RevnixConfig } from "../types.js";
|
|
8
9
|
|
|
9
10
|
interface NativeRevnixStore {
|
|
10
11
|
purchase(productId: string, customerId: string): Promise<StoreTransaction | null>;
|
|
11
12
|
restore(): Promise<StoreTransaction[]>;
|
|
12
13
|
finish(transactionId: string): Promise<void>;
|
|
14
|
+
deviceIntegrity?(customerId: string): Promise<IntegrityEvidence | null>;
|
|
15
|
+
advertisingId?(): Promise<string | null>;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
const native: NativeRevnixStore | undefined = NativeModules.RevnixStore;
|
|
@@ -41,4 +44,16 @@ export const RevnixStore: StoreAdapter = native
|
|
|
41
44
|
observe: () => () => {},
|
|
42
45
|
};
|
|
43
46
|
|
|
47
|
+
/** Pass as `deviceIntegrity` in the Revnix config: App Attest on iOS, Play
|
|
48
|
+
* Integrity on Android, via the RevnixStore native module. Resolves null
|
|
49
|
+
* where the device or build cannot produce evidence. */
|
|
50
|
+
export const nativeDeviceIntegrity: NonNullable<RevnixConfig["deviceIntegrity"]> = (
|
|
51
|
+
customerId
|
|
52
|
+
) => native?.deviceIntegrity?.(customerId) ?? Promise.resolve(null);
|
|
53
|
+
|
|
54
|
+
/** Pass as `advertisingId` in the Revnix config: the Google Advertising ID
|
|
55
|
+
* on Android, via the RevnixStore native module. Resolves null on iOS. */
|
|
56
|
+
export const nativeAdvertisingId: NonNullable<RevnixConfig["advertisingId"]> = () =>
|
|
57
|
+
native?.advertisingId?.() ?? Promise.resolve(null);
|
|
58
|
+
|
|
44
59
|
if (native) setStoreAdapter(RevnixStore);
|