react-native-revnix 1.4.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +766 -0
  3. package/dist/blocks/background.d.ts +101 -0
  4. package/dist/blocks/background.js +165 -0
  5. package/dist/blocks/color.d.ts +88 -0
  6. package/dist/blocks/color.js +329 -0
  7. package/dist/blocks/locale.d.ts +42 -0
  8. package/dist/blocks/locale.js +263 -0
  9. package/dist/blocks/selection.d.ts +49 -0
  10. package/dist/blocks/selection.js +109 -0
  11. package/dist/blocks/style.d.ts +93 -0
  12. package/dist/blocks/style.js +365 -0
  13. package/dist/blocks/tags.d.ts +12 -0
  14. package/dist/blocks/tags.js +121 -0
  15. package/dist/blocks/types.d.ts +301 -0
  16. package/dist/blocks/types.js +44 -0
  17. package/dist/client.d.ts +402 -0
  18. package/dist/client.js +1653 -0
  19. package/dist/device.d.ts +29 -0
  20. package/dist/device.js +137 -0
  21. package/dist/errors.d.ts +39 -0
  22. package/dist/errors.js +68 -0
  23. package/dist/esm/blocks/background.js +160 -0
  24. package/dist/esm/blocks/color.js +318 -0
  25. package/dist/esm/blocks/locale.js +255 -0
  26. package/dist/esm/blocks/selection.js +100 -0
  27. package/dist/esm/blocks/style.js +356 -0
  28. package/dist/esm/blocks/tags.js +117 -0
  29. package/dist/esm/blocks/types.js +40 -0
  30. package/dist/esm/client.js +1649 -0
  31. package/dist/esm/device.js +130 -0
  32. package/dist/esm/errors.js +59 -0
  33. package/dist/esm/iap.js +101 -0
  34. package/dist/esm/implicit.js +63 -0
  35. package/dist/esm/index.js +29 -0
  36. package/dist/esm/package.json +1 -0
  37. package/dist/esm/storage.js +78 -0
  38. package/dist/esm/types.js +4 -0
  39. package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
  40. package/dist/esm/ui/RevnixPaywall.js +890 -0
  41. package/dist/esm/ui/index.js +6 -0
  42. package/dist/esm/version.js +4 -0
  43. package/dist/iap.d.ts +20 -0
  44. package/dist/iap.js +104 -0
  45. package/dist/implicit.d.ts +69 -0
  46. package/dist/implicit.js +67 -0
  47. package/dist/index.d.ts +18 -0
  48. package/dist/index.js +50 -0
  49. package/dist/storage.d.ts +14 -0
  50. package/dist/storage.js +85 -0
  51. package/dist/types.d.ts +463 -0
  52. package/dist/types.js +5 -0
  53. package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
  54. package/dist/ui/RevnixBlockPaywall.js +742 -0
  55. package/dist/ui/RevnixPaywall.d.ts +148 -0
  56. package/dist/ui/RevnixPaywall.js +927 -0
  57. package/dist/ui/index.d.ts +7 -0
  58. package/dist/ui/index.js +25 -0
  59. package/dist/version.d.ts +1 -0
  60. package/dist/version.js +7 -0
  61. package/package.json +79 -0
  62. package/src/blocks/background.ts +232 -0
  63. package/src/blocks/color.ts +352 -0
  64. package/src/blocks/locale.ts +271 -0
  65. package/src/blocks/paywall-background-wire.json +12 -0
  66. package/src/blocks/paywall-selection-wire.json +149 -0
  67. package/src/blocks/selection.ts +161 -0
  68. package/src/blocks/style.ts +411 -0
  69. package/src/blocks/tags.ts +125 -0
  70. package/src/blocks/types.ts +352 -0
  71. package/src/client.ts +1993 -0
  72. package/src/device.ts +146 -0
  73. package/src/errors.ts +69 -0
  74. package/src/iap.ts +137 -0
  75. package/src/implicit.ts +129 -0
  76. package/src/index.ts +82 -0
  77. package/src/storage.ts +92 -0
  78. package/src/types.ts +528 -0
  79. package/src/ui/RevnixBlockPaywall.tsx +1423 -0
  80. package/src/ui/RevnixPaywall.tsx +1542 -0
  81. package/src/ui/index.ts +43 -0
  82. package/src/version.ts +4 -0
  83. package/ui.d.ts +1 -0
  84. package/ui.js +5 -0
@@ -0,0 +1,1423 @@
1
+ // RevnixBlockPaywall — renders a designed paywall (a block tree) natively.
2
+ //
3
+ // The dashboard's paywall builder publishes `config.blocks`: a tree of styled
4
+ // elements the builder previews with BlockScreen.tsx (revnix-app). This is the
5
+ // second interpreter of that same document, so the design a merchant approves
6
+ // in the dashboard is the design their customers see. Keep the two in
7
+ // lockstep — a block type only one side understands ships as a surprise.
8
+ //
9
+ // What React Native cannot reproduce is listed on BlockStyle and handled by
10
+ // degrading rather than failing:
11
+ // * CSS gradients need a gradient component from the host app (`gradient`
12
+ // prop). Without one the screen paints the design's ground color.
13
+ // * backdrop blur has no RN equivalent; the translucent fill carries it.
14
+ // * Design fonts render only through `fontFamilyMap` — RN cannot load a
15
+ // family by name at runtime.
16
+ //
17
+ // RevnixPaywall delegates here whenever a config carries `blocks`, so apps
18
+ // get designed paywalls without changing a line of their integration.
19
+
20
+ import * as React from "react";
21
+ import {
22
+ ActivityIndicator,
23
+ Image,
24
+ Linking,
25
+ Platform,
26
+ Pressable,
27
+ ScrollView,
28
+ StatusBar,
29
+ Text,
30
+ View,
31
+ type ColorValue,
32
+ type StyleProp,
33
+ type ViewStyle,
34
+ } from "react-native";
35
+
36
+ import {
37
+ gradientFallbackColor,
38
+ isGradient,
39
+ parseGradient,
40
+ resolveColor,
41
+ surfaceSolid,
42
+ withAlpha,
43
+ type GradientLayer,
44
+ } from "../blocks/color.js";
45
+ import {
46
+ DEFAULT_FONT_SIZE,
47
+ hairlineColor,
48
+ inherit,
49
+ inheritedTextStyle,
50
+ resolveFontFamily,
51
+ toTextStyle,
52
+ toViewStyle,
53
+ type FontFamilyMap,
54
+ type InheritedText,
55
+ type StyleContext,
56
+ } from "../blocks/style.js";
57
+ import {
58
+ backgroundLayers,
59
+ coverPlacement,
60
+ type ResolvedBackgroundImage,
61
+ } from "../blocks/background.js";
62
+ import { detectLocale, linkLabels, localizeBlockDoc } from "../blocks/locale.js";
63
+ import { resolveTags } from "../blocks/tags.js";
64
+ import {
65
+ canvasMetrics,
66
+ effectiveStyle,
67
+ isVisible,
68
+ packageContext,
69
+ selectedPackageId,
70
+ } from "../blocks/selection.js";
71
+ import type { SelectionContext } from "../blocks/selection.js";
72
+ import {
73
+ DESIGN_CANVAS,
74
+ hasCloseAction,
75
+ type BlockStyle,
76
+ type PaywallBlock,
77
+ type PaywallBlockDoc,
78
+ type ProductsBlock,
79
+ } from "../blocks/types.js";
80
+ import type { RevnixPaywallPackage } from "./RevnixPaywall.js";
81
+
82
+ /** Props a gradient component must accept. Both `expo-linear-gradient` and
83
+ * `react-native-linear-gradient` satisfy this, so an app passes its own:
84
+ * `<RevnixPaywall gradient={LinearGradient} />`.
85
+ *
86
+ * `colors` is a two-or-more tuple rather than an array because that is what
87
+ * expo-linear-gradient's own props declare — a plain `string[]` would not be
88
+ * assignable and the host could not pass its component at all. The renderer
89
+ * therefore never emits a one-stop gradient. */
90
+ export interface RevnixGradientProps {
91
+ colors: readonly [ColorValue, ColorValue, ...ColorValue[]];
92
+ locations?: readonly [number, number, ...number[]] | null;
93
+ /** The object and tuple forms expo-linear-gradient accepts — both are here
94
+ * so `typeof LinearGradient` is assignable to this component type. */
95
+ start?: GradientPoint | null;
96
+ end?: GradientPoint | null;
97
+ style?: StyleProp<ViewStyle>;
98
+ }
99
+
100
+ /** Mutable tuple, not readonly: that is the shape expo-linear-gradient's own
101
+ * native point type declares, and a readonly one is not assignable to it. */
102
+ export type GradientPoint = { x: number; y: number } | [number, number];
103
+
104
+ export type RevnixGradientComponent = React.ComponentType<RevnixGradientProps>;
105
+
106
+ /** Props a blur component must accept. `intensity` is 0–100 (expo-blur's
107
+ * scale); a component that wants a px radius can read `radius`. */
108
+ export interface RevnixBlurProps {
109
+ intensity?: number;
110
+ radius?: number;
111
+ style?: StyleProp<ViewStyle>;
112
+ children?: React.ReactNode;
113
+ }
114
+
115
+ export type RevnixBlurComponent = React.ComponentType<RevnixBlurProps>;
116
+
117
+ export interface RevnixBlockPaywallProps {
118
+ doc: PaywallBlockDoc;
119
+ /** REV-271: which language to draw the design's copy in. Omitted, the
120
+ * device's own is detected; a host that already knows its in-app language
121
+ * (an app with its own language switch) passes it so the paywall follows
122
+ * the app rather than the OS. A paywall with no translations ignores this
123
+ * entirely, and any string the chosen language does not translate falls
124
+ * back to the authored copy — never to blank. */
125
+ locale?: string;
126
+ packages: RevnixPaywallPackage[];
127
+ onPurchase: (packageId: string) => void;
128
+ selectedPackageId?: string;
129
+ onSelectPackage?: (packageId: string) => void;
130
+ highlightPackageId?: string;
131
+ /** Fallback for an image block that names no url of its own. */
132
+ heroImageUrl?: string;
133
+ onRestore?: () => void;
134
+ onTerms?: () => void;
135
+ onPrivacy?: () => void;
136
+ /** Dismissal (REV-252). Omitted means no close is drawn at all — a dead
137
+ * close button is worse than none. The host performs the dismissal; the
138
+ * paywall never unmounts itself. */
139
+ onClose?: () => void;
140
+ /** Paywall-level Terms/Privacy, used when a links block sets none. */
141
+ footer?: { termsUrl?: string; privacyUrl?: string };
142
+ /** Renders CSS gradients. Without it, gradients paint as a solid ground. */
143
+ gradient?: RevnixGradientComponent;
144
+ /** Renders a blurred background photo. React Native has no blur primitive,
145
+ * so a design that blurs its background photo needs the host's own —
146
+ * `@react-native-community/blur` or `expo-blur` satisfy this. Without it
147
+ * the photo renders sharp; everything else about the background is
148
+ * unaffected. Same opt-in shape as `gradient`. */
149
+ blur?: RevnixBlurComponent;
150
+ /** Design font family → the face(s) the app has registered. Either one
151
+ * family for every weight, or a weight → family table (RN picks a face by
152
+ * family NAME, so each weight is its own registered family). Names absent
153
+ * from the map render in the system font. */
154
+ fontFamilyMap?: FontFamilyMap;
155
+ /** Reports a paint string the renderer could not read, or a gradient it had
156
+ * to flatten because no `gradient` component was passed.
157
+ *
158
+ * Local only: nothing is sent anywhere. The screen still draws — a fill
159
+ * falls back to a colour from the design rather than to black — so this is
160
+ * the only way to learn that a paywall is rendering approximately. */
161
+ onDiagnostic?: (message: string) => void;
162
+ style?: StyleProp<ViewStyle>;
163
+ /** While true, purchase buttons are disabled and show a spinner in place
164
+ * of their label (close buttons stay live). */
165
+ loading?: boolean;
166
+ /** Top inset in points for the fallback close chip when the host draws the
167
+ * paywall edge-to-edge (a status bar / notch). Defaults to the Android
168
+ * status-bar height; on iOS the host's SafeAreaView normally covers it. */
169
+ safeAreaTop?: number;
170
+ }
171
+
172
+ /** CSS angle (0 = up, 90 = right) → RN gradient start/end points. */
173
+ function angleToPoints(angle: number): {
174
+ start: { x: number; y: number };
175
+ end: { x: number; y: number };
176
+ } {
177
+ const rad = (angle * Math.PI) / 180;
178
+ const dx = Math.sin(rad);
179
+ const dy = -Math.cos(rad);
180
+ return {
181
+ start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
182
+ end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
183
+ };
184
+ }
185
+
186
+ /**
187
+ * A radial gradient has no React Native equivalent, so it is stood in for by a
188
+ * linear one. Two things make that stand-in read like the original rather than
189
+ * a coloured band:
190
+ *
191
+ * * DIRECTION comes from the radial's own centre — a glow anchored at the top
192
+ * of the screen has to fade downwards, not upwards.
193
+ * * ALPHA is scaled down. A radial reaches its opening colour only at the
194
+ * centre point and falls off in every direction; a linear paints that
195
+ * colour across the entire width. Carrying the stated alpha across would
196
+ * turn a soft glow into a solid header.
197
+ *
198
+ * Linear layers translate exactly, and a solid is a flat fill.
199
+ */
200
+ const RADIAL_ALPHA = 0.55;
201
+
202
+ function layerColors(layer: GradientLayer): {
203
+ colors: readonly [string, string, ...string[]];
204
+ locations?: readonly [number, number, ...number[]];
205
+ angle: number;
206
+ } {
207
+ if (layer.kind === "linear" && layer.colors.length >= 2) {
208
+ const colors = layer.colors as [string, string, ...string[]];
209
+ const locations =
210
+ layer.locations && layer.locations.length >= 2
211
+ ? (layer.locations as [number, number, ...number[]])
212
+ : undefined;
213
+ return { colors, locations, angle: layer.angle };
214
+ }
215
+ const first = layer.colors[0]!;
216
+ if (layer.kind !== "radial") {
217
+ return { colors: [first, first], angle: layer.angle };
218
+ }
219
+ const last = layer.colors[layer.colors.length - 1]!;
220
+ // Fade away from the centre: a centre in the top third falls downwards, one
221
+ // in the bottom third upwards, anything else keeps the default.
222
+ const y = layer.center?.y ?? 0.5;
223
+ const angle = y >= 0.65 ? 0 : 180;
224
+ // Where the radial's outermost stop sits, so the glow stops where it did.
225
+ const extent = layer.locations?.[layer.locations.length - 1] ?? 1;
226
+ return {
227
+ colors: [withAlpha(first, RADIAL_ALPHA), withAlpha(last, 0)],
228
+ locations: [0, Math.max(0.05, Math.min(1, extent))],
229
+ angle,
230
+ };
231
+ }
232
+
233
+ const FILL_ABSOLUTE = {
234
+ position: "absolute",
235
+ top: 0,
236
+ right: 0,
237
+ bottom: 0,
238
+ left: 0,
239
+ } as const;
240
+
241
+ /** The gradient layers of a fill, as absolutely-positioned siblings to be
242
+ * rendered behind a box's content. Null whenever the fill is a plain color,
243
+ * or no gradient component was supplied — in both cases the caller's
244
+ * `backgroundColor` (the design's ground color) is the whole paint job. */
245
+ function GradientLayers({
246
+ fill,
247
+ doc,
248
+ gradient: Gradient,
249
+ radius,
250
+ onDiagnostic,
251
+ }: {
252
+ fill: string | undefined;
253
+ doc: PaywallBlockDoc;
254
+ gradient?: RevnixGradientComponent;
255
+ onDiagnostic?: (message: string) => void;
256
+ /** The box's corner radius. CSS clips a background to it for free; here the
257
+ * layers are real children, so they have to be rounded themselves or a
258
+ * rounded card shows square gradient corners. */
259
+ radius?: number;
260
+ }): React.ReactElement | null {
261
+ const resolved = resolveColor(fill, doc);
262
+ if (!resolved || !isGradient(resolved)) return null;
263
+ if (!Gradient) {
264
+ // The fill still paints — `toViewStyle` degraded it to its ground stop —
265
+ // but the design asked for a gradient and this app passed no gradient
266
+ // component, so say so rather than letting it silently render flat.
267
+ onDiagnostic?.(
268
+ `gradient flattened: pass RevnixPaywall a \`gradient\` component to render ${resolved}`,
269
+ );
270
+ return null;
271
+ }
272
+ const layers = parseGradient(resolved);
273
+ if (layers.length === 0) return null;
274
+ return (
275
+ <React.Fragment>
276
+ {/* CSS paints the first layer on top, so render in reverse. */}
277
+ {[...layers].reverse().map((layer, i) => {
278
+ const { colors, locations, angle } = layerColors(layer);
279
+ const { start, end } = angleToPoints(angle);
280
+ return (
281
+ <Gradient
282
+ key={i}
283
+ colors={colors}
284
+ locations={locations}
285
+ start={start}
286
+ end={end}
287
+ style={
288
+ radius === undefined
289
+ ? FILL_ABSOLUTE
290
+ : [FILL_ABSOLUTE, { borderRadius: radius }]
291
+ }
292
+ />
293
+ );
294
+ })}
295
+ </React.Fragment>
296
+ );
297
+ }
298
+
299
+ /** A block's own gradient fill, ready to drop in as the first child of
300
+ * whatever box that block renders. `toViewStyle` has already painted the
301
+ * ground stop as the box's backgroundColor, so this adds the real gradient
302
+ * over it — and renders nothing at all for a plain colour. */
303
+ function BlockFill({
304
+ block,
305
+ ctx,
306
+ }: {
307
+ block: PaywallBlock;
308
+ ctx: RenderContext;
309
+ }): React.ReactElement | null {
310
+ return (
311
+ <GradientLayers
312
+ fill={block.style?.fill}
313
+ doc={ctx.doc}
314
+ gradient={ctx.gradient}
315
+ radius={block.style?.radius}
316
+ onDiagnostic={ctx.onDiagnostic}
317
+ />
318
+ );
319
+ }
320
+
321
+ /** The solid a fill paints under any gradient layers. */
322
+ function fillColor(
323
+ fill: string | undefined,
324
+ doc: PaywallBlockDoc,
325
+ ): string | undefined {
326
+ const resolved = resolveColor(fill, doc);
327
+ if (!resolved) return undefined;
328
+ return isGradient(resolved) ? gradientFallbackColor(resolved) : resolved;
329
+ }
330
+
331
+ /** Paints a fill that may be a CSS gradient, behind `children`.
332
+ *
333
+ * The solid goes under the gradient only when the gradient itself cannot be
334
+ * drawn. A scrim is transparent at one end on purpose, and a solid under it
335
+ * was a black sheet over the background photo — see `surfaceSolid`. */
336
+ function Surface({
337
+ fill,
338
+ doc,
339
+ gradient,
340
+ style,
341
+ children,
342
+ }: {
343
+ fill: string | undefined;
344
+ doc: PaywallBlockDoc;
345
+ gradient?: RevnixGradientComponent;
346
+ style?: StyleProp<ViewStyle>;
347
+ children?: React.ReactNode;
348
+ }) {
349
+ const solid = surfaceSolid(resolveColor(fill, doc), gradient !== undefined);
350
+ return (
351
+ <View style={[style, solid ? { backgroundColor: solid } : null]}>
352
+ <GradientLayers fill={fill} doc={doc} gradient={gradient} />
353
+ {children}
354
+ </View>
355
+ );
356
+ }
357
+
358
+ /**
359
+ * The screen background's photo layer.
360
+ *
361
+ * React Native's <Image> has no `object-position`, so a focal point other
362
+ * than the centre is drawn by hand: measure the box, ask the platform for
363
+ * the source dimensions, and place the scaled image with `coverPlacement` —
364
+ * the same rule CSS applies for `object-position: X% Y%`. Until both
365
+ * measurements land (and always for a centred focal point, and for
366
+ * `contain`) the plain resizeMode path is already correct, so there is no
367
+ * flash of a wrongly-cropped photo.
368
+ */
369
+ function BackgroundPhoto({
370
+ image,
371
+ blur: Blur,
372
+ }: {
373
+ image: ResolvedBackgroundImage;
374
+ blur?: RevnixBlurComponent;
375
+ }) {
376
+ const centred = image.focalX === 50 && image.focalY === 50;
377
+ const needsPlacement = image.fit === "cover" && !centred;
378
+
379
+ const [box, setBox] = React.useState<{ width: number; height: number } | null>(
380
+ null,
381
+ );
382
+ const [source, setSource] = React.useState<{
383
+ width: number;
384
+ height: number;
385
+ } | null>(null);
386
+
387
+ React.useEffect(() => {
388
+ if (!needsPlacement) return;
389
+ let live = true;
390
+ Image.getSize(
391
+ image.url,
392
+ (width, height) => live && setSource({ width, height }),
393
+ // A failed measure is not an error worth surfacing — the centred
394
+ // resizeMode path below stays in place.
395
+ () => undefined,
396
+ );
397
+ return () => {
398
+ live = false;
399
+ };
400
+ }, [image.url, needsPlacement]);
401
+
402
+ const placement =
403
+ needsPlacement && box && source
404
+ ? coverPlacement(box, source, image.focalX, image.focalY)
405
+ : null;
406
+
407
+ const photo = placement ? (
408
+ <Image
409
+ source={{ uri: image.url }}
410
+ style={{
411
+ position: "absolute",
412
+ left: placement.left,
413
+ top: placement.top,
414
+ width: placement.width,
415
+ height: placement.height,
416
+ }}
417
+ resizeMode="cover"
418
+ />
419
+ ) : (
420
+ <Image
421
+ source={{ uri: image.url }}
422
+ style={FILL_ABSOLUTE}
423
+ resizeMode={image.fit}
424
+ />
425
+ );
426
+
427
+ return (
428
+ <View
429
+ pointerEvents="none"
430
+ style={[FILL_ABSOLUTE, { opacity: image.opacity, overflow: "hidden" }]}
431
+ onLayout={
432
+ needsPlacement
433
+ ? (e) =>
434
+ setBox({
435
+ width: e.nativeEvent.layout.width,
436
+ height: e.nativeEvent.layout.height,
437
+ })
438
+ : undefined
439
+ }
440
+ >
441
+ {photo}
442
+ {image.blur !== undefined && Blur ? (
443
+ <Blur
444
+ intensity={Math.min(100, Math.round((image.blur / 30) * 100))}
445
+ radius={image.blur}
446
+ style={FILL_ABSOLUTE}
447
+ />
448
+ ) : null}
449
+ </View>
450
+ );
451
+ }
452
+
453
+ /**
454
+ * The whole screen background: ground, then photo, then scrim — the same
455
+ * three layers, in the same order, as the dashboard renderer paints. A
456
+ * document whose background is still a plain string resolves to a ground
457
+ * and nothing else, so this is byte-for-byte the old `Surface` for every
458
+ * paywall that has not had its background edited.
459
+ */
460
+ function ScreenBackground({
461
+ doc,
462
+ gradient,
463
+ blur,
464
+ style,
465
+ onDiagnostic,
466
+ children,
467
+ }: {
468
+ doc: PaywallBlockDoc;
469
+ gradient?: RevnixGradientComponent;
470
+ blur?: RevnixBlurComponent;
471
+ style?: StyleProp<ViewStyle>;
472
+ onDiagnostic?: (message: string) => void;
473
+ children?: React.ReactNode;
474
+ }) {
475
+ const layers = backgroundLayers(
476
+ doc.background,
477
+ (value) => resolveColor(value, doc) ?? value,
478
+ );
479
+ const ground = fillColor(layers.ground, doc);
480
+ return (
481
+ <View style={[style, ground ? { backgroundColor: ground } : null]}>
482
+ <GradientLayers
483
+ fill={layers.ground}
484
+ doc={doc}
485
+ gradient={gradient}
486
+ onDiagnostic={onDiagnostic}
487
+ />
488
+ {layers.image ? (
489
+ <BackgroundPhoto image={layers.image} blur={blur} />
490
+ ) : null}
491
+ {layers.overlay ? (
492
+ <Surface
493
+ fill={layers.overlay.fill}
494
+ doc={doc}
495
+ gradient={gradient}
496
+ style={[FILL_ABSOLUTE, { opacity: layers.overlay.opacity }]}
497
+ />
498
+ ) : null}
499
+ {children}
500
+ </View>
501
+ );
502
+ }
503
+
504
+ /** A container block's box. The style lands on the PRESSABLE itself when the
505
+ * card is a selection target — wrapping a styled View in a bare Pressable
506
+ * would leave absolute offsets resolving against a zero-sized parent, which
507
+ * makes an `inset` card vanish and a pinned badge fly off the edge. */
508
+ function CardBox({
509
+ style,
510
+ fill,
511
+ doc,
512
+ gradient,
513
+ radius,
514
+ onPress,
515
+ children,
516
+ }: {
517
+ style: StyleProp<ViewStyle>;
518
+ fill: string | undefined;
519
+ doc: PaywallBlockDoc;
520
+ gradient?: RevnixGradientComponent;
521
+ radius?: number;
522
+ onPress?: () => void;
523
+ children: React.ReactNode;
524
+ }) {
525
+ const layers = (
526
+ <GradientLayers fill={fill} doc={doc} gradient={gradient} radius={radius} />
527
+ );
528
+ if (onPress) {
529
+ return (
530
+ <Pressable onPress={onPress} style={style}>
531
+ {layers}
532
+ {children}
533
+ </Pressable>
534
+ );
535
+ }
536
+ return (
537
+ <View style={style}>
538
+ {layers}
539
+ {children}
540
+ </View>
541
+ );
542
+ }
543
+
544
+ /** The dismiss affordance the renderer supplies itself (REV-252).
545
+ *
546
+ * Drawn only when the design authors none of its own and the host wired an
547
+ * onClose — which is what makes every paywall published before close existed
548
+ * dismissible without being re-authored, while a design that DOES carry a
549
+ * close chip never ends up showing two.
550
+ *
551
+ * Deliberately plain: it is a safety net, not a design element. It is tinted
552
+ * from the screen's own ink rather than a fixed white so it stays legible on
553
+ * a light design as well as a dark one, and it sits outside the canvas
554
+ * transform so it is never scaled away on a small screen. */
555
+ function FallbackClose({
556
+ doc,
557
+ onPress,
558
+ safeAreaTop,
559
+ }: {
560
+ doc: PaywallBlockDoc;
561
+ onPress: () => void;
562
+ safeAreaTop: number;
563
+ }) {
564
+ return (
565
+ <Pressable
566
+ accessibilityRole="button"
567
+ accessibilityLabel="Close"
568
+ hitSlop={10}
569
+ onPress={onPress}
570
+ style={{
571
+ position: "absolute",
572
+ top: 14 + safeAreaTop,
573
+ right: 14,
574
+ zIndex: 50,
575
+ width: 30,
576
+ height: 30,
577
+ borderRadius: 15,
578
+ alignItems: "center",
579
+ justifyContent: "center",
580
+ backgroundColor: withAlpha(doc.textColor, 0.14),
581
+ }}
582
+ >
583
+ <Text style={{ color: doc.textColor, fontSize: 17, lineHeight: 20 }}>
584
+ ×
585
+ </Text>
586
+ </Pressable>
587
+ );
588
+ }
589
+
590
+ interface RenderContext {
591
+ doc: PaywallBlockDoc;
592
+ packages: RevnixPaywallPackage[];
593
+ selectedId?: string;
594
+ onSelect: (packageId: string) => void;
595
+ onPurchase: () => void;
596
+ onRestore?: () => void;
597
+ onTerms?: () => void;
598
+ onPrivacy?: () => void;
599
+ onClose?: () => void;
600
+ footer?: { termsUrl?: string; privacyUrl?: string };
601
+ heroImageUrl?: string;
602
+ gradient?: RevnixGradientComponent;
603
+ /** Where the renderer reports a paint string it could not read, or a
604
+ * gradient it had to flatten for want of a gradient component. Local only:
605
+ * nothing is sent anywhere. */
606
+ onDiagnostic?: (message: string) => void;
607
+ style: StyleContext;
608
+ /** Host purchase in flight: purchase buttons disable and spin. */
609
+ loading: boolean;
610
+ }
611
+
612
+ /** Text blocks always render inside a View: a style may carry both typography
613
+ * and box properties (a round chip is a 34pt circle with a centered glyph),
614
+ * and RN cannot centre a glyph inside <Text> the way flexbox does. */
615
+ function BlockText({
616
+ text,
617
+ style,
618
+ ctx,
619
+ inherited,
620
+ onPress,
621
+ }: {
622
+ text: string;
623
+ style: BlockStyle | undefined;
624
+ ctx: RenderContext;
625
+ inherited: InheritedText;
626
+ /** Set when the design marks this element as the close (REV-252). */
627
+ onPress?: () => void;
628
+ }) {
629
+ const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
630
+ // The block's own typography is folded into the inherited set first, so
631
+ // em letter-spacing and unitless line-height resolve against whichever font
632
+ // size actually applies — the block's, or the one it inherited.
633
+ const own = inherit(inherited, style, styleCtx);
634
+ const body = (
635
+ <React.Fragment>
636
+ <GradientLayers
637
+ fill={style?.fill}
638
+ doc={ctx.doc}
639
+ gradient={ctx.gradient}
640
+ radius={style?.radius}
641
+ onDiagnostic={ctx.onDiagnostic}
642
+ />
643
+ <Text
644
+ numberOfLines={style?.nowrap ? 1 : undefined}
645
+ style={[inheritedTextStyle(own, ctx.style), textOnly(style, styleCtx)]}
646
+ >
647
+ {text}
648
+ </Text>
649
+ </React.Fragment>
650
+ );
651
+ // The whole box is the target, not just the glyph: a close chip is mostly
652
+ // padding, and a 12pt × is under the 44pt tap minimum on its own. The style
653
+ // lands on the Pressable itself for the same reason CardBox does it —
654
+ // wrapping a styled View would leave a pinned chip resolving its offsets
655
+ // against a zero-sized parent.
656
+ if (onPress) {
657
+ return (
658
+ <Pressable
659
+ accessibilityRole="button"
660
+ accessibilityLabel="Close"
661
+ hitSlop={8}
662
+ onPress={onPress}
663
+ style={toViewStyle(style, styleCtx)}
664
+ >
665
+ {body}
666
+ </Pressable>
667
+ );
668
+ }
669
+ return <View style={toViewStyle(style, styleCtx)}>{body}</View>;
670
+ }
671
+
672
+ /** The press handler for a block the design marks as the close, or undefined
673
+ * when it is not one (or the host wired no dismissal). One helper so text,
674
+ * image and button blocks cannot drift on what "action: close" means. */
675
+ function closePress(
676
+ block: { action?: "close" },
677
+ ctx: RenderContext
678
+ ): (() => void) | undefined {
679
+ return block.action === "close" && ctx.onClose ? ctx.onClose : undefined;
680
+ }
681
+
682
+ /** Typography that is NOT inherited (decoration) — everything else already
683
+ * arrives through the inherited set. */
684
+ function textOnly(s: BlockStyle | undefined, ctx: StyleContext) {
685
+ const t = toTextStyle(s, ctx);
686
+ return t.textDecorationLine !== undefined
687
+ ? { textDecorationLine: t.textDecorationLine }
688
+ : null;
689
+ }
690
+
691
+ function Products({
692
+ block,
693
+ ctx,
694
+ inherited,
695
+ }: {
696
+ block: ProductsBlock;
697
+ ctx: RenderContext;
698
+ inherited: InheritedText;
699
+ }) {
700
+ const { doc, packages } = ctx;
701
+ const base = inheritedTextStyle(inherited, ctx.style);
702
+ const row = block.direction === "row";
703
+ return (
704
+ <View
705
+ style={[
706
+ {
707
+ flexDirection: row ? "row" : "column",
708
+ gap: block.style?.gap ?? 8,
709
+ },
710
+ toViewStyle(block.style, ctx.style),
711
+ ]}
712
+ >
713
+ <BlockFill block={block} ctx={ctx} />
714
+ {packages.map((pkg) => {
715
+ const selected = pkg.packageId === ctx.selectedId;
716
+ const cardStyle = selected ? block.highlightStyle : block.cardStyle;
717
+ return (
718
+ <Pressable
719
+ key={pkg.packageId}
720
+ onPress={() => ctx.onSelect(pkg.packageId)}
721
+ style={[
722
+ {
723
+ position: "relative",
724
+ flexGrow: row ? 1 : undefined,
725
+ flexBasis: row ? 0 : undefined,
726
+ gap: 3,
727
+ borderRadius: 14,
728
+ paddingVertical: 12,
729
+ paddingHorizontal: 14,
730
+ borderWidth: 1.5,
731
+ borderColor: selected ? doc.accent : withAlpha("#808080", 0.35),
732
+ backgroundColor: selected
733
+ ? withAlpha(doc.accent, 0.12)
734
+ : "transparent",
735
+ },
736
+ toViewStyle(cardStyle, ctx.style),
737
+ ]}
738
+ >
739
+ <GradientLayers
740
+ fill={cardStyle?.fill}
741
+ doc={doc}
742
+ gradient={ctx.gradient}
743
+ radius={cardStyle?.radius ?? 14}
744
+ onDiagnostic={ctx.onDiagnostic}
745
+ />
746
+ {selected && block.badgeText ? (
747
+ <View
748
+ style={{
749
+ position: "absolute",
750
+ top: -9,
751
+ right: 12,
752
+ backgroundColor: doc.accent,
753
+ paddingVertical: 2,
754
+ paddingHorizontal: 8,
755
+ borderRadius: 99,
756
+ }}
757
+ >
758
+ <Text
759
+ style={{
760
+ color: doc.accentInk,
761
+ fontSize: 10,
762
+ fontWeight: "800",
763
+ letterSpacing: 0.5,
764
+ }}
765
+ >
766
+ {block.badgeText}
767
+ </Text>
768
+ </View>
769
+ ) : null}
770
+ <Text
771
+ style={[
772
+ base,
773
+ {
774
+ fontWeight: "700",
775
+ fontSize: 14,
776
+ textAlign: row ? "center" : "left",
777
+ },
778
+ ]}
779
+ >
780
+ {resolveTags(block.titleTpl ?? "{title}", pkg, packages)}
781
+ </Text>
782
+ <Text
783
+ style={[
784
+ base,
785
+ { fontSize: 13, textAlign: row ? "center" : "left" },
786
+ ]}
787
+ >
788
+ {resolveTags(block.priceTpl ?? "{price}", pkg, packages)}
789
+ </Text>
790
+ {selected && block.highlightSub ? (
791
+ <Text
792
+ style={[
793
+ base,
794
+ {
795
+ fontSize: 11.5,
796
+ opacity: 0.75,
797
+ textAlign: row ? "center" : "left",
798
+ },
799
+ ]}
800
+ >
801
+ {block.highlightSub}
802
+ </Text>
803
+ ) : null}
804
+ </Pressable>
805
+ );
806
+ })}
807
+ </View>
808
+ );
809
+ }
810
+
811
+ /** grid track list ("1fr 60px 66px") → per-column flex/width. */
812
+ function gridTracks(
813
+ block: Extract<PaywallBlock, { type: "card" }>,
814
+ ): Array<{ flex?: number; width?: number }> {
815
+ if (block.gridColumns) {
816
+ return block.gridColumns
817
+ .trim()
818
+ .split(/\s+/)
819
+ .map((track) => {
820
+ const fr = track.match(/^([\d.]*)fr$/);
821
+ if (fr) return { flex: fr[1] ? parseFloat(fr[1]) : 1 };
822
+ const px = track.match(/^([\d.]+)px$/);
823
+ if (px) return { width: parseFloat(px[1]!) };
824
+ return { flex: 1 };
825
+ });
826
+ }
827
+ return Array.from({ length: block.columns ?? 2 }, () => ({ flex: 1 }));
828
+ }
829
+
830
+ function Block({
831
+ block,
832
+ ctx,
833
+ pkg,
834
+ inherited,
835
+ selection,
836
+ }: {
837
+ block: PaywallBlock;
838
+ ctx: RenderContext;
839
+ /** The package this subtree describes — set inside a repeat container. */
840
+ pkg?: RevnixPaywallPackage;
841
+ inherited: InheritedText;
842
+ /** REV-262: whether the nearest plan card above is the selected one.
843
+ * Undefined outside any plan card. */
844
+ selection?: SelectionContext;
845
+ }): React.ReactElement | null {
846
+ const { doc } = ctx;
847
+ const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
848
+ // A pinned card judges its own context; every other block inherits the
849
+ // nearest plan card's. (A repeat card is judged per instance below.)
850
+ const context: SelectionContext =
851
+ block.type === "card" &&
852
+ block.packageIndex !== undefined &&
853
+ ctx.packages[block.packageIndex] !== undefined
854
+ ? packageContext(ctx.packages[block.packageIndex], ctx.selectedId)
855
+ : selection;
856
+ if (!isVisible(block, context)) return null;
857
+ // The style this block draws with in its state: `selectedStyle` merged
858
+ // over `style` while selected. Every read below goes through it.
859
+ const style = effectiveStyle(block, context);
860
+ // Web flex items shrink by default (`flex: 0 1 auto`); RN's do not. Without
861
+ // matching that, a row the browser fits by squeezing its children overflows
862
+ // the screen here instead.
863
+ const view = { flexShrink: 1, ...toViewStyle(style, styleCtx) };
864
+ const text = inherit(inherited, style, styleCtx);
865
+ const size = text.fontSize;
866
+
867
+ switch (block.type) {
868
+ case "text":
869
+ return (
870
+ <BlockText
871
+ text={resolveTags(block.text, pkg, ctx.packages)}
872
+ style={style}
873
+ ctx={ctx}
874
+ inherited={inherited}
875
+ onPress={closePress(block, ctx)}
876
+ />
877
+ );
878
+
879
+ case "image": {
880
+ const url = block.url || ctx.heroImageUrl;
881
+ const round = block.shape === "circle";
882
+ const s = style;
883
+ // A converted design sizes its own slot; the 160pt default is only for
884
+ // a slot dropped into a flow column, and must not fight it.
885
+ const sized =
886
+ s !== undefined &&
887
+ (s.inset ||
888
+ s.height !== undefined ||
889
+ s.aspectRatio !== undefined ||
890
+ (s.top !== undefined && s.bottom !== undefined) ||
891
+ s.flex !== undefined);
892
+ // An icon the design marks as the close (REV-252). Same rule as text:
893
+ // the whole box is the target, and the style lands on the Pressable so
894
+ // a pinned icon keeps its offsets.
895
+ const closeImage = closePress(block, ctx);
896
+ const ImageBox = closeImage ? Pressable : View;
897
+ return (
898
+ <ImageBox
899
+ accessibilityRole={closeImage ? "button" : undefined}
900
+ accessibilityLabel={closeImage ? "Close" : undefined}
901
+ hitSlop={closeImage ? 8 : undefined}
902
+ onPress={closeImage}
903
+ style={[
904
+ sized ? null : { height: 160 },
905
+ { borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
906
+ view,
907
+ ]}
908
+ >
909
+ <BlockFill block={block} ctx={ctx} />
910
+ {url ? (
911
+ <Image
912
+ source={{ uri: url }}
913
+ resizeMode={block.fit ?? "cover"}
914
+ style={{ width: "100%", height: "100%" }}
915
+ />
916
+ ) : (
917
+ <View
918
+ style={{
919
+ width: "100%",
920
+ height: "100%",
921
+ backgroundColor: withAlpha("#7d879b", 0.2),
922
+ }}
923
+ />
924
+ )}
925
+ </ImageBox>
926
+ );
927
+ }
928
+
929
+ case "list":
930
+ return (
931
+ <View
932
+ style={[{ flexDirection: "column", gap: style?.gap ?? 8 }, view]}
933
+ >
934
+ <BlockFill block={block} ctx={ctx} />
935
+ {block.items.map((item, i) => (
936
+ <View key={i} style={{ flexDirection: "row", gap: 9 }}>
937
+ <Text
938
+ style={[
939
+ inheritedTextStyle(text, ctx.style),
940
+ {
941
+ color: resolveColor(block.iconColor, doc) ?? doc.accent,
942
+ fontWeight: "800",
943
+ },
944
+ ]}
945
+ >
946
+ {item.icon ?? "✓"}
947
+ </Text>
948
+ <View style={{ flexShrink: 1 }}>
949
+ <Text
950
+ style={[
951
+ inheritedTextStyle(text, ctx.style),
952
+ { fontWeight: item.description ? "700" : "500" },
953
+ ]}
954
+ >
955
+ {item.title}
956
+ </Text>
957
+ {item.description ? (
958
+ <Text
959
+ style={[
960
+ inheritedTextStyle(text, ctx.style),
961
+ { fontSize: 12, opacity: 0.7 },
962
+ ]}
963
+ >
964
+ {item.description}
965
+ </Text>
966
+ ) : null}
967
+ </View>
968
+ </View>
969
+ ))}
970
+ </View>
971
+ );
972
+
973
+ case "products":
974
+ return <Products block={block} ctx={ctx} inherited={text} />;
975
+
976
+ case "button": {
977
+ // A converted CTA sizes itself and centres with flex; the padded default
978
+ // is only for buttons added by hand.
979
+ const sized = style?.height !== undefined;
980
+ // A button the design marks as the close dismisses instead of buying,
981
+ // and takes no accent fill: the CTA must stay the one accented thing on
982
+ // the screen, or a "Not now" competes with "Subscribe" for the eye.
983
+ const close = closePress(block, ctx);
984
+ // A purchase in flight: the button keeps its box and fill, loses its
985
+ // label to a spinner in the ink colour, and ignores taps. A close
986
+ // button is never blocked — the user can always leave.
987
+ const busy = !close && ctx.loading;
988
+ return (
989
+ <Pressable
990
+ accessibilityRole="button"
991
+ accessibilityState={{ disabled: busy, busy }}
992
+ disabled={busy}
993
+ onPress={close ?? ctx.onPurchase}
994
+ style={({ pressed }) => [
995
+ {
996
+ backgroundColor: close ? "transparent" : doc.accent,
997
+ borderRadius: 12,
998
+ paddingVertical: sized ? 0 : 15,
999
+ paddingHorizontal: 16,
1000
+ alignItems: "center",
1001
+ justifyContent: "center",
1002
+ },
1003
+ view,
1004
+ // Pressed feedback (contract §4): 80% while the finger is down.
1005
+ pressed && !busy
1006
+ ? {
1007
+ opacity:
1008
+ (typeof view.opacity === "number" ? view.opacity : 1) * 0.8,
1009
+ }
1010
+ : null,
1011
+ ]}
1012
+ >
1013
+ <BlockFill block={block} ctx={ctx} />
1014
+ <Text
1015
+ style={[
1016
+ inheritedTextStyle(text, ctx.style),
1017
+ {
1018
+ color: close ? doc.textColor : doc.accentInk,
1019
+ fontWeight: "800",
1020
+ textAlign: "center",
1021
+ },
1022
+ // The CTA's own colour/size, when the design set one.
1023
+ toTextStyle(style, styleCtx),
1024
+ busy ? { opacity: 0 } : null,
1025
+ ]}
1026
+ >
1027
+ {resolveTags(block.label, pkg, ctx.packages)}
1028
+ </Text>
1029
+ {busy ? (
1030
+ <ActivityIndicator
1031
+ color={doc.accentInk}
1032
+ style={{ position: "absolute" }}
1033
+ />
1034
+ ) : null}
1035
+ </Pressable>
1036
+ );
1037
+ }
1038
+
1039
+ case "links": {
1040
+ const open = (url?: string) =>
1041
+ url ? () => void Linking.openURL(url) : undefined;
1042
+ const labels = linkLabels(ctx.doc.defaultLocale);
1043
+ const links: Array<{ label: string; onPress?: () => void }> = [];
1044
+ if (block.showRestore ?? true)
1045
+ links.push({ label: labels.restore, onPress: ctx.onRestore });
1046
+ if (block.showTerms ?? true)
1047
+ links.push({
1048
+ label: labels.terms,
1049
+ onPress:
1050
+ ctx.onTerms ?? open(block.termsUrl ?? ctx.footer?.termsUrl),
1051
+ });
1052
+ if (block.showPrivacy ?? true)
1053
+ links.push({
1054
+ label: labels.privacy,
1055
+ onPress:
1056
+ ctx.onPrivacy ?? open(block.privacyUrl ?? ctx.footer?.privacyUrl),
1057
+ });
1058
+ if (links.length === 0) return null;
1059
+ return (
1060
+ <View
1061
+ style={[
1062
+ { flexDirection: "row", justifyContent: "center", gap: 20 },
1063
+ view,
1064
+ ]}
1065
+ >
1066
+ <BlockFill block={block} ctx={ctx} />
1067
+ {links.map((link) => (
1068
+ <Pressable key={link.label} onPress={link.onPress}>
1069
+ <Text
1070
+ style={[
1071
+ inheritedTextStyle(text, ctx.style),
1072
+ { fontSize: text.fontSize === inherited.fontSize ? 12 : text.fontSize },
1073
+ { opacity: 0.65 },
1074
+ ]}
1075
+ >
1076
+ {link.label}
1077
+ </Text>
1078
+ </Pressable>
1079
+ ))}
1080
+ </View>
1081
+ );
1082
+ }
1083
+
1084
+ case "line":
1085
+ return (
1086
+ // The web rule paints in `currentColor` at low alpha — that is the
1087
+ // INHERITED text colour, not the screen default.
1088
+ <View
1089
+ style={[{ height: 1, backgroundColor: hairlineColor(text.color) }, view]}
1090
+ >
1091
+ <BlockFill block={block} ctx={ctx} />
1092
+ </View>
1093
+ );
1094
+
1095
+ case "spacer":
1096
+ // A flexing spacer still honours the rest of its style (a minHeight
1097
+ // floor, margins) — flex decides how it grows, not what it is.
1098
+ return (
1099
+ <View style={[block.flex ? { flexGrow: 1 } : { height: 16 }, view]} />
1100
+ );
1101
+
1102
+ case "card": {
1103
+ const layout = block.layout ?? "column";
1104
+ const base: ViewStyle =
1105
+ layout === "stack"
1106
+ ? { position: "relative" }
1107
+ : layout === "grid"
1108
+ ? {
1109
+ position: "relative",
1110
+ flexDirection: "row",
1111
+ flexWrap: "wrap",
1112
+ gap: style?.gap ?? 10,
1113
+ }
1114
+ : {
1115
+ position: "relative",
1116
+ flexDirection: layout === "row" ? "row" : "column",
1117
+ gap: style?.gap ?? 10,
1118
+ };
1119
+
1120
+ const tracks = layout === "grid" ? gridTracks(block) : null;
1121
+ const renderChildren = (
1122
+ context: RevnixPaywallPackage | undefined,
1123
+ childSelection: SelectionContext,
1124
+ childText: InheritedText = text
1125
+ ) =>
1126
+ block.children.map((child, i) => {
1127
+ const node = (
1128
+ <Block
1129
+ key={child.id}
1130
+ block={child}
1131
+ ctx={ctx}
1132
+ pkg={context}
1133
+ inherited={childText}
1134
+ selection={childSelection}
1135
+ />
1136
+ );
1137
+ if (!tracks) return node;
1138
+ const track = tracks[i % tracks.length]!;
1139
+ return (
1140
+ <View
1141
+ key={child.id}
1142
+ style={
1143
+ track.width !== undefined
1144
+ ? { width: track.width }
1145
+ : // A grid row must WRAP after `columns` items, so each cell
1146
+ // claims its share of the width as a basis rather than
1147
+ // flexing to fit everything on one line.
1148
+ { flexBasis: `${100 / tracks.length}%`, flexGrow: 0, flexShrink: 1 }
1149
+ }
1150
+ >
1151
+ {node}
1152
+ </View>
1153
+ );
1154
+ });
1155
+
1156
+ // A card that describes a package the offering does not reach is
1157
+ // dropped rather than shown with unresolved tags.
1158
+ if (
1159
+ block.packageIndex !== undefined &&
1160
+ ctx.packages[block.packageIndex] === undefined
1161
+ ) {
1162
+ return null;
1163
+ }
1164
+
1165
+ // A repeat card is one block drawn once per package, each instance in
1166
+ // its own context, so its box, fill and inherited text are resolved
1167
+ // per instance rather than from the `style` computed above.
1168
+ if (block.repeat === "packages") {
1169
+ const list: Array<RevnixPaywallPackage | undefined> = ctx.packages
1170
+ .length
1171
+ ? ctx.packages
1172
+ : [undefined];
1173
+ return (
1174
+ <React.Fragment>
1175
+ {list.map((p, i) => {
1176
+ const instance = packageContext(p, ctx.selectedId) ?? selection;
1177
+ if (!isVisible(block, instance)) return null;
1178
+ const es = effectiveStyle(block, instance);
1179
+ return (
1180
+ <CardBox
1181
+ key={p?.packageId ?? i}
1182
+ style={[
1183
+ base,
1184
+ layout === "stack" ? null : { borderRadius: 16 },
1185
+ { flexShrink: 1, ...toViewStyle(es, styleCtx) },
1186
+ ]}
1187
+ fill={es?.fill}
1188
+ doc={doc}
1189
+ gradient={ctx.gradient}
1190
+ radius={es?.radius ?? (layout === "stack" ? undefined : 16)}
1191
+ onPress={p ? () => ctx.onSelect(p.packageId) : undefined}
1192
+ >
1193
+ {renderChildren(p, instance, inherit(inherited, es, styleCtx))}
1194
+ </CardBox>
1195
+ );
1196
+ })}
1197
+ </React.Fragment>
1198
+ );
1199
+ }
1200
+
1201
+ const boxStyle = [
1202
+ base,
1203
+ layout === "stack" ? null : { borderRadius: 16 },
1204
+ view,
1205
+ ];
1206
+
1207
+ const cardPackage =
1208
+ block.packageIndex !== undefined
1209
+ ? (ctx.packages[block.packageIndex] ?? pkg)
1210
+ : pkg;
1211
+
1212
+ // A card pinned to a package doubles as its selection target — that is
1213
+ // how hand-styled plan rows (a highlighted annual beside a plain
1214
+ // monthly) become tappable without a products block.
1215
+ const pinned = block.packageIndex !== undefined && cardPackage !== undefined;
1216
+ return (
1217
+ <CardBox
1218
+ style={boxStyle}
1219
+ fill={style?.fill}
1220
+ doc={doc}
1221
+ gradient={ctx.gradient}
1222
+ radius={style?.radius ?? (layout === "stack" ? undefined : 16)}
1223
+ onPress={pinned ? () => ctx.onSelect(cardPackage!.packageId) : undefined}
1224
+ >
1225
+ {renderChildren(cardPackage, context)}
1226
+ </CardBox>
1227
+ );
1228
+ }
1229
+ }
1230
+ }
1231
+
1232
+ export function RevnixBlockPaywall(props: RevnixBlockPaywallProps) {
1233
+ const { packages } = props;
1234
+ // REV-271: the language overlay is applied ONCE, here, so every renderer
1235
+ // path below reads plain strings and cannot forget to localize one. Memoized
1236
+ // on the doc and the locale because it rebuilds the tree, and the renderer
1237
+ // re-runs on every selection tap.
1238
+ const doc = React.useMemo(
1239
+ () => localizeBlockDoc(props.doc, props.locale ?? detectLocale()),
1240
+ [props.doc, props.locale]
1241
+ );
1242
+
1243
+ const [internalSelected, setInternalSelected] = React.useState<
1244
+ string | undefined
1245
+ >(undefined);
1246
+ // Contract §1: host → own tap → design highlight → first package, each
1247
+ // only when the offering carries it.
1248
+ const selectedId = selectedPackageId(
1249
+ packages,
1250
+ props.selectedPackageId,
1251
+ internalSelected,
1252
+ props.highlightPackageId
1253
+ );
1254
+
1255
+ const select = (packageId: string) => {
1256
+ setInternalSelected(packageId);
1257
+ props.onSelectPackage?.(packageId);
1258
+ };
1259
+
1260
+ const styleCtx: StyleContext = {
1261
+ doc,
1262
+ fontSize: DEFAULT_FONT_SIZE,
1263
+ fontFamilyMap: props.fontFamilyMap,
1264
+ onDiagnostic: props.onDiagnostic,
1265
+ paintsGradients: props.gradient !== undefined,
1266
+ };
1267
+
1268
+ // The screen's own typography is the root of the inheritance chain, exactly
1269
+ // as the document's colors and font sit on the web renderer's screen div.
1270
+ const rootText: InheritedText = {
1271
+ color: doc.textColor,
1272
+ fontSize: DEFAULT_FONT_SIZE,
1273
+ fontFamilyName: doc.fontFamily,
1274
+ };
1275
+
1276
+ const ctx: RenderContext = {
1277
+ doc,
1278
+ packages,
1279
+ selectedId,
1280
+ onSelect: select,
1281
+ onPurchase: () => {
1282
+ if (props.loading || !selectedId) return;
1283
+ props.onPurchase(selectedId);
1284
+ },
1285
+ onRestore: props.onRestore,
1286
+ onTerms: props.onTerms,
1287
+ onPrivacy: props.onPrivacy,
1288
+ onClose: props.onClose,
1289
+ footer: props.footer,
1290
+ heroImageUrl: props.heroImageUrl,
1291
+ gradient: props.gradient,
1292
+ onDiagnostic: props.onDiagnostic,
1293
+ style: styleCtx,
1294
+ loading: props.loading === true,
1295
+ };
1296
+
1297
+ const children = doc.blocks.map((block) => (
1298
+ <Block
1299
+ key={block.id}
1300
+ block={block}
1301
+ ctx={ctx}
1302
+ pkg={packages.find((p) => p.packageId === selectedId) ?? packages[0]}
1303
+ inherited={rootText}
1304
+ />
1305
+ ));
1306
+
1307
+ // REV-252: the renderer's own close, drawn only for a design that authors
1308
+ // none. Rendered as a sibling of the tree — outside the canvas scale — so
1309
+ // it keeps its tap size and its distance from the screen edge whatever the
1310
+ // device width does to the design.
1311
+ const safeAreaTop =
1312
+ props.safeAreaTop ??
1313
+ (Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0);
1314
+ const fallbackClose =
1315
+ props.onClose && !hasCloseAction(doc.blocks) ? (
1316
+ <FallbackClose
1317
+ doc={doc}
1318
+ onPress={props.onClose}
1319
+ safeAreaTop={safeAreaTop}
1320
+ />
1321
+ ) : null;
1322
+
1323
+ const [width, setWidth] = React.useState<number | null>(null);
1324
+ const [viewportHeight, setViewportHeight] = React.useState<number | null>(
1325
+ null
1326
+ );
1327
+
1328
+ if (doc.layout === "canvas") {
1329
+ // Authored against the fixed device screen: lay the tree out at 1× and
1330
+ // scale the whole thing, so absolute placement inside stack containers
1331
+ // stays true at any width. RN scales about the centre, hence the
1332
+ // compensating translate — the design must grow from the top-left.
1333
+ // Contract §3. The canvas keeps the aspect ratio it was designed at,
1334
+ // scaled by width up to the 480pt tablet cap and centred beyond it. On a
1335
+ // screen SHORTER than 852:393 it overflows and scrolls — that is what
1336
+ // keeps the CTA and the legal links reachable. On a TALLER screen the
1337
+ // design is stretched to the viewport instead of leaving a band below
1338
+ // it: the root card is "100%" tall and bottom-anchored groups follow it,
1339
+ // which is exactly what the web preview does for a taller frame.
1340
+ const metrics =
1341
+ width === null ? null : canvasMetrics(width, viewportHeight);
1342
+ const scale = metrics?.scale ?? 1;
1343
+ const overflows = metrics?.overflows ?? false;
1344
+ const canvasHeight = metrics?.scaledHeight ?? DESIGN_CANVAS.height;
1345
+ const left = metrics?.left ?? 0;
1346
+ return (
1347
+ <ScreenBackground
1348
+ doc={doc}
1349
+ gradient={props.gradient}
1350
+ blur={props.blur}
1351
+ onDiagnostic={props.onDiagnostic}
1352
+ style={[{ flex: 1 }, props.style]}
1353
+ >
1354
+ <ScrollView
1355
+ contentContainerStyle={{ height: canvasHeight }}
1356
+ // A designed screen never shows a scrollbar — the web preview has
1357
+ // none, and in an RTL app the indicator lands on the left edge of
1358
+ // the artwork. When nothing overflows, scrolling is off entirely so
1359
+ // a drag cannot bounce the design.
1360
+ showsVerticalScrollIndicator={false}
1361
+ scrollEnabled={overflows}
1362
+ bounces={overflows}
1363
+ onLayout={(e) => setViewportHeight(e.nativeEvent.layout.height)}
1364
+ >
1365
+ <View
1366
+ onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
1367
+ style={{ flex: 1, overflow: "hidden" }}
1368
+ >
1369
+ <View
1370
+ style={{
1371
+ position: "absolute",
1372
+ top: 0,
1373
+ left: 0,
1374
+ width: DESIGN_CANVAS.width,
1375
+ // Laid out at design height, or taller when the viewport is:
1376
+ // the extra height is in DESIGN units so the scale still
1377
+ // maps it onto the screen exactly.
1378
+ height: canvasHeight / scale,
1379
+ opacity: width === null ? 0 : 1,
1380
+ transform: [
1381
+ {
1382
+ translateX:
1383
+ -(DESIGN_CANVAS.width * (1 - scale)) / 2 + left,
1384
+ },
1385
+ { translateY: -((canvasHeight / scale) * (1 - scale)) / 2 },
1386
+ { scale },
1387
+ ],
1388
+ }}
1389
+ >
1390
+ {children}
1391
+ </View>
1392
+ </View>
1393
+ </ScrollView>
1394
+ {fallbackClose}
1395
+ </ScreenBackground>
1396
+ );
1397
+ }
1398
+
1399
+ return (
1400
+ <ScreenBackground
1401
+ doc={doc}
1402
+ gradient={props.gradient}
1403
+ blur={props.blur}
1404
+ onDiagnostic={props.onDiagnostic}
1405
+ style={[{ flex: 1 }, props.style]}
1406
+ >
1407
+ <ScrollView
1408
+ contentContainerStyle={{
1409
+ gap: 12,
1410
+ paddingHorizontal: 20,
1411
+ paddingTop: 18,
1412
+ paddingBottom: 30,
1413
+ }}
1414
+ showsVerticalScrollIndicator={false}
1415
+ alwaysBounceVertical={false}
1416
+ overScrollMode="never"
1417
+ >
1418
+ {children}
1419
+ </ScrollView>
1420
+ {fallbackClose}
1421
+ </ScreenBackground>
1422
+ );
1423
+ }