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.
@@ -135,13 +135,15 @@ export interface BlockStyle {
135
135
  * one. Ignored outside a package card — a root block is always drawn. */
136
136
  export type BlockVisibility = "selected" | "unselected";
137
137
 
138
- export type BlockAction = "close";
138
+ /** `close` dismisses the paywall; `next`/`back` move between onboarding
139
+ * screens and only appear inside `screens`. */
140
+ export type BlockAction = "close" | "next" | "back";
139
141
 
140
142
  export interface TextBlock {
141
143
  id: string;
142
144
  type: "text";
143
145
  text: string;
144
- /** Tapping this block dismisses the paywall. See BlockAction. */
146
+ /** What tapping this block does. See BlockAction. */
145
147
  action?: BlockAction;
146
148
  style?: BlockStyle;
147
149
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -159,7 +161,7 @@ export interface ImageBlock {
159
161
  shape?: "rect" | "circle";
160
162
  fit?: "cover" | "contain";
161
163
  placeholder?: string;
162
- /** Tapping this block dismisses the paywall. See BlockAction. */
164
+ /** What tapping this block does. See BlockAction. */
163
165
  action?: BlockAction;
164
166
  style?: BlockStyle;
165
167
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -206,8 +208,9 @@ export interface ButtonBlock {
206
208
  id: string;
207
209
  type: "button";
208
210
  label: string;
209
- /** `close` turns this button into a dismiss ("Not now") instead of the
210
- * purchase CTA, which is what a button means by default. */
211
+ /** `close` turns this button into a dismiss ("Not now"), and `next`/`back`
212
+ * into onboarding navigation, instead of the purchase CTA, which is what a
213
+ * button means by default. */
211
214
  action?: BlockAction;
212
215
  style?: BlockStyle;
213
216
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -356,6 +359,10 @@ export interface PaywallBlockDoc {
356
359
  accentInk: string;
357
360
  fontFamily?: string;
358
361
  blocks: PaywallBlock[];
362
+ /** Onboarding: intro screens shown in order before `blocks`, which stays
363
+ * the last (purchase) screen. They share the doc's palette, layout and
364
+ * locale table. Absent or empty = `blocks` alone. */
365
+ screens?: PaywallBlock[][];
359
366
  /** REV-271: the language the blocks' own copy is written in. Absent = "en".
360
367
  * Never a key in a stored doc's `locales` — the tree IS that language's
361
368
  * version. A localized copy names the language it was localized to. */
@@ -399,3 +406,26 @@ export function hasCloseAction(blocks: PaywallBlock[]): boolean {
399
406
  b.action === "close"
400
407
  );
401
408
  }
409
+
410
+ export function onboardingScreen(
411
+ doc: PaywallBlockDoc,
412
+ index: number
413
+ ): { blocks: PaywallBlock[]; dots?: { count: number; active: number } } {
414
+ const intro = doc.screens?.[index];
415
+ if (intro === undefined) return { blocks: doc.blocks };
416
+ return {
417
+ blocks: intro,
418
+ dots: { count: doc.screens!.length + 1, active: index },
419
+ };
420
+ }
421
+
422
+ export function stepScreen(
423
+ doc: PaywallBlockDoc,
424
+ index: number,
425
+ action: BlockAction | undefined
426
+ ): number | undefined {
427
+ if (index >= (doc.screens?.length ?? 0)) return undefined;
428
+ if (action === "next") return index + 1;
429
+ if (action === "back" && index > 0) return index - 1;
430
+ return undefined;
431
+ }
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
@@ -4,6 +4,7 @@ import type { RevnixConfig } from "./types.js";
4
4
 
5
5
  export { RevnixClient } from "./client.js";
6
6
  export {
7
+ PurchaseAlreadyOwnedError,
7
8
  PurchaseBlockedError,
8
9
  RevnixAuthError,
9
10
  RevnixError,
@@ -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
- finish(transactionId: string): Promise<void>;
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="Close"
669
+ accessibilityLabel={pressLabel}
665
670
  hitSlop={8}
666
671
  onPress={onPress}
667
672
  style={toViewStyle(style, styleCtx)}
@@ -745,46 +750,67 @@ function Carousel({
745
750
  ))}
746
751
  </ScrollView>
747
752
  {pages.length > 1 ? (
753
+ <Dots count={pages.length} active={page} doc={doc} style={{ bottom: 8 }} />
754
+ ) : null}
755
+ </View>
756
+ );
757
+ }
758
+
759
+ function Dots({
760
+ count,
761
+ active,
762
+ doc,
763
+ style,
764
+ }: {
765
+ count: number;
766
+ active: number;
767
+ doc: PaywallBlockDoc;
768
+ style: ViewStyle;
769
+ }) {
770
+ return (
771
+ <View
772
+ pointerEvents="none"
773
+ style={[
774
+ {
775
+ position: "absolute",
776
+ left: 0,
777
+ right: 0,
778
+ flexDirection: "row",
779
+ justifyContent: "center",
780
+ gap: 6,
781
+ },
782
+ style,
783
+ ]}
784
+ >
785
+ {Array.from({ length: count }, (_, i) => (
748
786
  <View
749
- pointerEvents="none"
787
+ key={i}
750
788
  style={{
751
- position: "absolute",
752
- bottom: 8,
753
- left: 0,
754
- right: 0,
755
- flexDirection: "row",
756
- justifyContent: "center",
757
- gap: 6,
789
+ width: 6,
790
+ height: 6,
791
+ borderRadius: 3,
792
+ backgroundColor:
793
+ i === active ? doc.accent : withAlpha(doc.textColor, 0.3),
758
794
  }}
759
- >
760
- {pages.map((_, i) => (
761
- <View
762
- key={i}
763
- style={{
764
- width: 6,
765
- height: 6,
766
- borderRadius: 3,
767
- backgroundColor:
768
- i === page ? doc.accent : withAlpha(doc.textColor, 0.3),
769
- }}
770
- />
771
- ))}
772
- </View>
773
- ) : null}
795
+ />
796
+ ))}
774
797
  </View>
775
798
  );
776
799
  }
777
800
 
778
- /** The press handler for a block the design marks as the close, or undefined
779
- * when it is not one (or the host wired no dismissal). One helper so text,
780
- * image and button blocks cannot drift on what "action: close" means. */
781
- function closePress(
782
- block: { action?: "close" },
801
+ function actionPress(
802
+ block: { action?: BlockAction },
783
803
  ctx: RenderContext
784
804
  ): (() => void) | undefined {
785
- return block.action === "close" && ctx.onClose ? ctx.onClose : undefined;
805
+ return block.action === "close" ? ctx.onClose : ctx.navigate(block.action);
786
806
  }
787
807
 
808
+ const ACTION_LABEL: Record<BlockAction, string> = {
809
+ close: "Close",
810
+ next: "Next",
811
+ back: "Back",
812
+ };
813
+
788
814
  /** Typography that is NOT inherited (decoration) — everything else already
789
815
  * arrives through the inherited set. */
790
816
  function textOnly(s: BlockStyle | undefined, ctx: StyleContext) {
@@ -983,7 +1009,8 @@ function Block({
983
1009
  style={style}
984
1010
  ctx={ctx}
985
1011
  inherited={inherited}
986
- onPress={closePress(block, ctx)}
1012
+ onPress={actionPress(block, ctx)}
1013
+ pressLabel={block.action === "close" ? "Close" : undefined}
987
1014
  />
988
1015
  );
989
1016
 
@@ -1039,17 +1066,17 @@ function Block({
1039
1066
  s.aspectRatio !== undefined ||
1040
1067
  (s.top !== undefined && s.bottom !== undefined) ||
1041
1068
  s.flex !== undefined);
1042
- // An icon the design marks as the close (REV-252). Same rule as text:
1043
- // the whole box is the target, and the style lands on the Pressable so
1044
- // a pinned icon keeps its offsets.
1045
- const closeImage = image ? closePress(block, ctx) : undefined;
1046
- const ImageBox = closeImage ? Pressable : View;
1069
+ const action = block.type === "image" ? block.action : undefined;
1070
+ const imagePress = actionPress({ action }, ctx);
1071
+ const ImageBox = imagePress ? Pressable : View;
1047
1072
  return (
1048
1073
  <ImageBox
1049
- accessibilityRole={closeImage ? "button" : undefined}
1050
- accessibilityLabel={closeImage ? "Close" : undefined}
1051
- hitSlop={closeImage ? 8 : undefined}
1052
- onPress={closeImage}
1074
+ accessibilityRole={imagePress ? "button" : undefined}
1075
+ accessibilityLabel={
1076
+ imagePress && action ? ACTION_LABEL[action] : undefined
1077
+ }
1078
+ hitSlop={imagePress ? 8 : undefined}
1079
+ onPress={imagePress}
1053
1080
  style={[
1054
1081
  sized ? null : { height: 160 },
1055
1082
  { borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
@@ -1130,17 +1157,18 @@ function Block({
1130
1157
  // A button the design marks as the close dismisses instead of buying,
1131
1158
  // and takes no accent fill: the CTA must stay the one accented thing on
1132
1159
  // the screen, or a "Not now" competes with "Subscribe" for the eye.
1133
- const close = closePress(block, ctx);
1160
+ const close = block.action === "close" ? ctx.onClose : undefined;
1161
+ const navigates = block.action === "next" || block.action === "back";
1134
1162
  // A purchase in flight: the button keeps its box and fill, loses its
1135
1163
  // label to a spinner in the ink colour, and ignores taps. A close
1136
1164
  // button is never blocked — the user can always leave.
1137
- const busy = !close && ctx.loading;
1165
+ const busy = !close && !navigates && ctx.loading;
1138
1166
  return (
1139
1167
  <Pressable
1140
1168
  accessibilityRole="button"
1141
1169
  accessibilityState={{ disabled: busy, busy }}
1142
1170
  disabled={busy}
1143
- onPress={close ?? ctx.onPurchase}
1171
+ onPress={navigates ? actionPress(block, ctx) : (close ?? ctx.onPurchase)}
1144
1172
  style={({ pressed }) => [
1145
1173
  {
1146
1174
  backgroundColor: close ? "transparent" : doc.accent,
@@ -1408,6 +1436,13 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1408
1436
  string | undefined
1409
1437
  >(undefined);
1410
1438
  const [shownAt] = React.useState(Date.now);
1439
+ const [screenIndex, setScreenIndex] = React.useState(0);
1440
+ const [screenDoc, setScreenDoc] = React.useState(props.doc);
1441
+ if (screenDoc !== props.doc) {
1442
+ setScreenDoc(props.doc);
1443
+ setScreenIndex(0);
1444
+ }
1445
+ const screen = onboardingScreen(doc, screenIndex);
1411
1446
  // Contract §1: host → own tap → design highlight → first package, each
1412
1447
  // only when the offering carries it.
1413
1448
  const selectedId = selectedPackageId(
@@ -1451,6 +1486,10 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1451
1486
  onTerms: props.onTerms,
1452
1487
  onPrivacy: props.onPrivacy,
1453
1488
  onClose: props.onClose,
1489
+ navigate: (action) => {
1490
+ const to = stepScreen(doc, screenIndex, action);
1491
+ return to === undefined ? undefined : () => setScreenIndex(to);
1492
+ },
1454
1493
  footer: props.footer,
1455
1494
  heroImageUrl: props.heroImageUrl,
1456
1495
  gradient: props.gradient,
@@ -1460,7 +1499,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1460
1499
  shownAt,
1461
1500
  };
1462
1501
 
1463
- const children = doc.blocks.map((block) => (
1502
+ const children = screen.blocks.map((block) => (
1464
1503
  <Block
1465
1504
  key={block.id}
1466
1505
  block={block}
@@ -1478,7 +1517,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1478
1517
  props.safeAreaTop ??
1479
1518
  (Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0);
1480
1519
  const fallbackClose =
1481
- props.onClose && !hasCloseAction(doc.blocks) ? (
1520
+ props.onClose && !hasCloseAction(screen.blocks) ? (
1482
1521
  <FallbackClose
1483
1522
  doc={doc}
1484
1523
  onPress={props.onClose}
@@ -1518,6 +1557,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1518
1557
  style={[{ flex: 1 }, props.style]}
1519
1558
  >
1520
1559
  <ScrollView
1560
+ key={screenIndex}
1521
1561
  contentContainerStyle={{ height: canvasHeight }}
1522
1562
  // A designed screen never shows a scrollbar — the web preview has
1523
1563
  // none, and in an RTL app the indicator lands on the left edge of
@@ -1554,6 +1594,14 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1554
1594
  }}
1555
1595
  >
1556
1596
  {children}
1597
+ {screen.dots ? (
1598
+ <Dots
1599
+ count={screen.dots.count}
1600
+ active={screen.dots.active}
1601
+ doc={doc}
1602
+ style={{ top: 20, zIndex: 50 }}
1603
+ />
1604
+ ) : null}
1557
1605
  </View>
1558
1606
  </View>
1559
1607
  </ScrollView>
@@ -1571,6 +1619,7 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1571
1619
  style={[{ flex: 1 }, props.style]}
1572
1620
  >
1573
1621
  <ScrollView
1622
+ key={screenIndex}
1574
1623
  contentContainerStyle={{
1575
1624
  gap: 12,
1576
1625
  paddingHorizontal: 20,
@@ -1583,6 +1632,14 @@ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1583
1632
  >
1584
1633
  {children}
1585
1634
  </ScrollView>
1635
+ {screen.dots ? (
1636
+ <Dots
1637
+ count={screen.dots.count}
1638
+ active={screen.dots.active}
1639
+ doc={doc}
1640
+ style={{ top: 20 + safeAreaTop, zIndex: 50 }}
1641
+ />
1642
+ ) : null}
1586
1643
  {fallbackClose}
1587
1644
  </ScreenBackground>
1588
1645
  );
@@ -65,7 +65,16 @@ function asBlockDoc(value: unknown): PaywallBlockDoc | null {
65
65
  typeof doc.textColor === "string" &&
66
66
  typeof doc.accent === "string" &&
67
67
  typeof doc.accentInk === "string"
68
- ? (doc as PaywallBlockDoc)
68
+ ? {
69
+ ...(doc as PaywallBlockDoc),
70
+ ...(doc.screens !== undefined
71
+ ? {
72
+ screens: Array.isArray(doc.screens)
73
+ ? doc.screens.filter((s) => Array.isArray(s) && s.length > 0)
74
+ : [],
75
+ }
76
+ : {}),
77
+ }
69
78
  : null;
70
79
  }
71
80
 
@@ -480,7 +489,7 @@ export function RevnixPaywall(props: RevnixPaywallProps) {
480
489
  // arrives after a placeholder (or is swapped by an A/B assignment) would
481
490
  // otherwise crash on "rendered more hooks than during the previous render".
482
491
  const { config } = props;
483
- const blocks = asBlockDoc(config.blocks);
492
+ const blocks = React.useMemo(() => asBlockDoc(config.blocks), [config.blocks]);
484
493
  // Both sinks fire when both are wired; nil when neither is, which is what
485
494
  // stops the renderer building a message nobody will read.
486
495
  const renderDiagnostic =
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // L3: the single source of the SDK version inside src/. Must match
2
2
  // package.json "version" — version.test.ts fails the build when they drift
3
3
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
4
- export const SDK_VERSION = "1.7.0";
4
+ export const SDK_VERSION = "1.9.0";