react-native-revnix 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +29 -9
- package/dist/blocks/locale.js +5 -0
- package/dist/blocks/types.d.ts +20 -5
- package/dist/blocks/types.js +20 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +43 -5
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +11 -1
- package/dist/esm/blocks/locale.js +5 -0
- package/dist/esm/blocks/types.js +18 -0
- package/dist/esm/client.js +44 -6
- package/dist/esm/errors.js +9 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/store/index.js +1 -1
- 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/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/store/index.js +1 -1
- package/dist/store.d.ts +7 -1
- package/dist/types.d.ts +3 -0
- 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/ios/RevnixStore.m +1 -0
- package/ios/RevnixStore.swift +3 -2
- package/package.json +1 -1
- package/src/blocks/locale.ts +7 -0
- package/src/blocks/types.ts +35 -5
- package/src/client.ts +48 -7
- package/src/errors.ts +10 -0
- package/src/index.ts +1 -0
- package/src/store/index.ts +2 -2
- package/src/store.ts +7 -1
- package/src/types.ts +3 -0
- package/src/ui/RevnixBlockPaywall.tsx +103 -46
- package/src/ui/RevnixPaywall.tsx +11 -2
- package/src/version.ts +1 -1
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
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
PurchaseAlreadyOwnedError,
|
|
2
3
|
PurchaseBlockedError,
|
|
3
4
|
RevnixAuthError,
|
|
4
5
|
RevnixError,
|
|
@@ -193,6 +194,33 @@ const pendingKey = (p: RegisterPurchaseInput) =>
|
|
|
193
194
|
const worthQueueing = (err: unknown): boolean =>
|
|
194
195
|
!(err instanceof RevnixError) || err.isRetryable;
|
|
195
196
|
|
|
197
|
+
const ATTRIBUTION_QUERY_PARAMS = new Set([
|
|
198
|
+
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", "campaign_id",
|
|
199
|
+
"pid", "source", "af_channel", "medium", "c", "campaign", "term", "af_ad",
|
|
200
|
+
"content", "gclid", "gbraid", "wbraid", "ttclid", "sccid", "twclid",
|
|
201
|
+
"revnix_link", "web_id",
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
function queryParamName(pair: string): string {
|
|
205
|
+
const raw = pair.split("=")[0] ?? "";
|
|
206
|
+
try {
|
|
207
|
+
return decodeURIComponent(raw.replace(/\+/g, " ")).toLowerCase();
|
|
208
|
+
} catch {
|
|
209
|
+
return raw.toLowerCase();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function attributionOnlyUrl(rawUrl: string): string {
|
|
214
|
+
const url = rawUrl.trim();
|
|
215
|
+
const parts = /^([a-z][a-z0-9+.-]*:)(\/\/[^/?#]*)?([^?#]*)(?:\?([^#]*))?/i.exec(url);
|
|
216
|
+
if (!parts) return url.split(/[?#]/)[0] ?? "";
|
|
217
|
+
const [, scheme, authority = "", path, query = ""] = parts;
|
|
218
|
+
const kept = query
|
|
219
|
+
.split("&")
|
|
220
|
+
.filter((pair) => ATTRIBUTION_QUERY_PARAMS.has(queryParamName(pair)));
|
|
221
|
+
return `${scheme}${authority.replace(/^\/\/.*@/, "//")}${path}${kept.length > 0 ? `?${kept.join("&")}` : ""}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
196
224
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
197
225
|
|
|
198
226
|
// REV-199 (CTO-R2 M24): ±20% on every scheduled delay.
|
|
@@ -263,6 +291,7 @@ export class RevnixClient {
|
|
|
263
291
|
private readonly onDeferredDeepLink?: RevnixConfig["onDeferredDeepLink"];
|
|
264
292
|
private readonly onAttribution?: RevnixConfig["onAttribution"];
|
|
265
293
|
private readonly deviceIntegrity?: RevnixConfig["deviceIntegrity"];
|
|
294
|
+
private readonly consumableProductIds: string[];
|
|
266
295
|
private integrityRequest: {
|
|
267
296
|
customerId: string;
|
|
268
297
|
body: Promise<{ integrity?: IntegrityEvidence }>;
|
|
@@ -340,6 +369,7 @@ export class RevnixClient {
|
|
|
340
369
|
this.onDeferredDeepLink = config.onDeferredDeepLink;
|
|
341
370
|
this.onAttribution = config.onAttribution;
|
|
342
371
|
this.deviceIntegrity = config.deviceIntegrity;
|
|
372
|
+
this.consumableProductIds = config.consumableProductIds ?? [];
|
|
343
373
|
this.readYourWritesDelaysMs =
|
|
344
374
|
config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
|
|
345
375
|
this.customerIdPromise = this.loadCustomerId(config.customerId);
|
|
@@ -351,11 +381,12 @@ export class RevnixClient {
|
|
|
351
381
|
if (config.advertisingId) this.reportAdvertisingId(config.advertisingId);
|
|
352
382
|
const store = getStoreAdapter();
|
|
353
383
|
if (store) {
|
|
354
|
-
this.stopStoreObserver = store.observe((tx) =>
|
|
384
|
+
this.stopStoreObserver = store.observe((tx) => {
|
|
385
|
+
if (tx.acknowledged && !this.isConsumable(tx)) return;
|
|
355
386
|
void this.registerStoreTransaction(store, tx).catch((err) =>
|
|
356
387
|
this.diag("storeObserver", err)
|
|
357
|
-
)
|
|
358
|
-
);
|
|
388
|
+
);
|
|
389
|
+
});
|
|
359
390
|
}
|
|
360
391
|
// REV-157: warm the placement cache at launch, so the first paywall of the
|
|
361
392
|
// session can render offline. Fire-and-forget; a failed key just stays on
|
|
@@ -547,6 +578,12 @@ export class RevnixClient {
|
|
|
547
578
|
const customerId = await this.customerIdPromise;
|
|
548
579
|
const tx = await store.purchase(productId, { customerId });
|
|
549
580
|
if (!tx) return null;
|
|
581
|
+
if (tx.alreadyOwned && this.isConsumable(tx)) {
|
|
582
|
+
await this.registerStoreTransaction(store, tx, opts);
|
|
583
|
+
throw new PurchaseAlreadyOwnedError(
|
|
584
|
+
`Revnix: an earlier purchase of ${productId} was still owned and has been registered; buy again`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
550
587
|
return this.registerStoreTransaction(store, tx, opts);
|
|
551
588
|
}
|
|
552
589
|
|
|
@@ -566,6 +603,10 @@ export class RevnixClient {
|
|
|
566
603
|
return restored;
|
|
567
604
|
}
|
|
568
605
|
|
|
606
|
+
private isConsumable(tx: StoreTransaction): boolean {
|
|
607
|
+
return tx.store === "google" && this.consumableProductIds.includes(tx.productId);
|
|
608
|
+
}
|
|
609
|
+
|
|
569
610
|
private requireStore(): StoreAdapter {
|
|
570
611
|
const store = getStoreAdapter();
|
|
571
612
|
if (!store) {
|
|
@@ -582,9 +623,9 @@ export class RevnixClient {
|
|
|
582
623
|
opts?: Pick<RegisterPurchaseInput, "viewId" | "paywallId" | "placementKey">,
|
|
583
624
|
finish = true
|
|
584
625
|
): Promise<RegisterPurchaseResult> {
|
|
585
|
-
const finishTx = () =>
|
|
626
|
+
const finishTx = (consume = false) =>
|
|
586
627
|
finish
|
|
587
|
-
? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
|
|
628
|
+
? store.finish(tx.transactionId, consume).catch((err) => this.diag("storeFinish", err))
|
|
588
629
|
: undefined;
|
|
589
630
|
try {
|
|
590
631
|
const result = await this.registerPurchase({
|
|
@@ -597,7 +638,7 @@ export class RevnixClient {
|
|
|
597
638
|
signedTransactionInfo: tx.signedTransactionInfo,
|
|
598
639
|
...opts,
|
|
599
640
|
});
|
|
600
|
-
await finishTx();
|
|
641
|
+
await finishTx(this.isConsumable(tx));
|
|
601
642
|
return result;
|
|
602
643
|
} catch (err) {
|
|
603
644
|
if (!(err instanceof PurchaseBlockedError)) await finishTx();
|
|
@@ -1400,7 +1441,7 @@ export class RevnixClient {
|
|
|
1400
1441
|
return;
|
|
1401
1442
|
}
|
|
1402
1443
|
void this.rememberDeepLink(url);
|
|
1403
|
-
const extra = { url: url.slice(0, 1024) };
|
|
1444
|
+
const extra = { url: attributionOnlyUrl(url).slice(0, 1024) };
|
|
1404
1445
|
const batch = this.launchBatch;
|
|
1405
1446
|
if (batch === null) {
|
|
1406
1447
|
this.fireImplicit("deeplink_open", extra);
|
package/src/errors.ts
CHANGED
|
@@ -24,6 +24,16 @@ export class PurchaseBlockedError extends RevnixError {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** purchase() found an earlier Google Play consumable of this product still
|
|
28
|
+
* owned. It was registered and consumed instead of charging again; ask the
|
|
29
|
+
* customer to buy again. */
|
|
30
|
+
export class PurchaseAlreadyOwnedError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "PurchaseAlreadyOwnedError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
/** C7: the request didn't complete within the configured timeout (default
|
|
28
38
|
* 10s). `fetch` has no default timeout on React Native / browsers, so without
|
|
29
39
|
* this a slow or dead backend would hang the app until the OS TCP timeout. */
|
package/src/index.ts
CHANGED
package/src/store/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { IntegrityEvidence, RevnixConfig } from "../types.js";
|
|
|
10
10
|
interface NativeRevnixStore {
|
|
11
11
|
purchase(productId: string, customerId: string): Promise<StoreTransaction | null>;
|
|
12
12
|
restore(): Promise<StoreTransaction[]>;
|
|
13
|
-
finish(transactionId: string): Promise<void>;
|
|
13
|
+
finish(transactionId: string, consume: boolean): Promise<void>;
|
|
14
14
|
deviceIntegrity?(customerId: string): Promise<IntegrityEvidence | null>;
|
|
15
15
|
advertisingId?(): Promise<string | null>;
|
|
16
16
|
}
|
|
@@ -28,7 +28,7 @@ export const RevnixStore: StoreAdapter = native
|
|
|
28
28
|
? {
|
|
29
29
|
purchase: (productId, { customerId }) => native.purchase(productId, customerId),
|
|
30
30
|
restore: () => native.restore(),
|
|
31
|
-
finish: (transactionId) => native.finish(transactionId),
|
|
31
|
+
finish: (transactionId, consume) => native.finish(transactionId, consume === true),
|
|
32
32
|
observe(onTransaction) {
|
|
33
33
|
const sub = new NativeEventEmitter(NativeModules.RevnixStore).addListener(
|
|
34
34
|
"revnixStoreTransaction",
|
package/src/store.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface StoreTransaction {
|
|
|
13
13
|
expiresAt?: number;
|
|
14
14
|
/** Apple only: the StoreKit 2 JWS. */
|
|
15
15
|
signedTransactionInfo?: string;
|
|
16
|
+
/** Google only: already acknowledged in Play. */
|
|
17
|
+
acknowledged?: boolean;
|
|
18
|
+
/** Google only: purchase() answered with an earlier purchase Play reported
|
|
19
|
+
* as already owned, not a new charge. */
|
|
20
|
+
alreadyOwned?: boolean;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
export interface StoreAdapter {
|
|
@@ -22,7 +27,8 @@ export interface StoreAdapter {
|
|
|
22
27
|
opts: { customerId: string }
|
|
23
28
|
): Promise<StoreTransaction | null>;
|
|
24
29
|
restore(): Promise<StoreTransaction[]>;
|
|
25
|
-
|
|
30
|
+
/** `consume`: Google only, consume instead of acknowledge. */
|
|
31
|
+
finish(transactionId: string, consume?: boolean): Promise<void>;
|
|
26
32
|
/** Transactions arriving outside purchase(): renewals, other devices, Ask
|
|
27
33
|
* to Buy, refunds. Returns an unsubscribe. */
|
|
28
34
|
observe(onTransaction: (tx: StoreTransaction) => void): () => void;
|
package/src/types.ts
CHANGED
|
@@ -161,6 +161,9 @@ export interface RevnixConfig {
|
|
|
161
161
|
* read. A null answer deletes a previously stored `gps_adid`; a
|
|
162
162
|
* rejection changes nothing and reports `op: "advertisingId"`. */
|
|
163
163
|
advertisingId?: () => Promise<string | null | undefined>;
|
|
164
|
+
/** Google Play product ids sold as consumables. A registered purchase of
|
|
165
|
+
* one is consumed instead of acknowledged so it can be bought again. */
|
|
166
|
+
consumableProductIds?: string[];
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
/** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
|
|
@@ -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.9.0";
|