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,706 @@
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
+ import * as React from "react";
20
+ import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, StatusBar, Text, View, } from "react-native";
21
+ import { gradientFallbackColor, isGradient, parseGradient, resolveColor, surfaceSolid, withAlpha, } from "../blocks/color.js";
22
+ import { DEFAULT_FONT_SIZE, hairlineColor, inherit, inheritedTextStyle, toTextStyle, toViewStyle, } from "../blocks/style.js";
23
+ import { backgroundLayers, coverPlacement, } from "../blocks/background.js";
24
+ import { detectLocale, linkLabels, localizeBlockDoc } from "../blocks/locale.js";
25
+ import { resolveTags } from "../blocks/tags.js";
26
+ import { canvasMetrics, effectiveStyle, isVisible, packageContext, selectedPackageId, } from "../blocks/selection.js";
27
+ import { DESIGN_CANVAS, hasCloseAction, } from "../blocks/types.js";
28
+ /** CSS angle (0 = up, 90 = right) → RN gradient start/end points. */
29
+ function angleToPoints(angle) {
30
+ const rad = (angle * Math.PI) / 180;
31
+ const dx = Math.sin(rad);
32
+ const dy = -Math.cos(rad);
33
+ return {
34
+ start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
35
+ end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
36
+ };
37
+ }
38
+ /**
39
+ * A radial gradient has no React Native equivalent, so it is stood in for by a
40
+ * linear one. Two things make that stand-in read like the original rather than
41
+ * a coloured band:
42
+ *
43
+ * * DIRECTION comes from the radial's own centre — a glow anchored at the top
44
+ * of the screen has to fade downwards, not upwards.
45
+ * * ALPHA is scaled down. A radial reaches its opening colour only at the
46
+ * centre point and falls off in every direction; a linear paints that
47
+ * colour across the entire width. Carrying the stated alpha across would
48
+ * turn a soft glow into a solid header.
49
+ *
50
+ * Linear layers translate exactly, and a solid is a flat fill.
51
+ */
52
+ const RADIAL_ALPHA = 0.55;
53
+ function layerColors(layer) {
54
+ if (layer.kind === "linear" && layer.colors.length >= 2) {
55
+ const colors = layer.colors;
56
+ const locations = layer.locations && layer.locations.length >= 2
57
+ ? layer.locations
58
+ : undefined;
59
+ return { colors, locations, angle: layer.angle };
60
+ }
61
+ const first = layer.colors[0];
62
+ if (layer.kind !== "radial") {
63
+ return { colors: [first, first], angle: layer.angle };
64
+ }
65
+ const last = layer.colors[layer.colors.length - 1];
66
+ // Fade away from the centre: a centre in the top third falls downwards, one
67
+ // in the bottom third upwards, anything else keeps the default.
68
+ const y = layer.center?.y ?? 0.5;
69
+ const angle = y >= 0.65 ? 0 : 180;
70
+ // Where the radial's outermost stop sits, so the glow stops where it did.
71
+ const extent = layer.locations?.[layer.locations.length - 1] ?? 1;
72
+ return {
73
+ colors: [withAlpha(first, RADIAL_ALPHA), withAlpha(last, 0)],
74
+ locations: [0, Math.max(0.05, Math.min(1, extent))],
75
+ angle,
76
+ };
77
+ }
78
+ const FILL_ABSOLUTE = {
79
+ position: "absolute",
80
+ top: 0,
81
+ right: 0,
82
+ bottom: 0,
83
+ left: 0,
84
+ };
85
+ /** The gradient layers of a fill, as absolutely-positioned siblings to be
86
+ * rendered behind a box's content. Null whenever the fill is a plain color,
87
+ * or no gradient component was supplied — in both cases the caller's
88
+ * `backgroundColor` (the design's ground color) is the whole paint job. */
89
+ function GradientLayers({ fill, doc, gradient: Gradient, radius, onDiagnostic, }) {
90
+ const resolved = resolveColor(fill, doc);
91
+ if (!resolved || !isGradient(resolved))
92
+ return null;
93
+ if (!Gradient) {
94
+ // The fill still paints — `toViewStyle` degraded it to its ground stop —
95
+ // but the design asked for a gradient and this app passed no gradient
96
+ // component, so say so rather than letting it silently render flat.
97
+ onDiagnostic?.(`gradient flattened: pass RevnixPaywall a \`gradient\` component to render ${resolved}`);
98
+ return null;
99
+ }
100
+ const layers = parseGradient(resolved);
101
+ if (layers.length === 0)
102
+ return null;
103
+ return (React.createElement(React.Fragment, null, [...layers].reverse().map((layer, i) => {
104
+ const { colors, locations, angle } = layerColors(layer);
105
+ const { start, end } = angleToPoints(angle);
106
+ return (React.createElement(Gradient, { key: i, colors: colors, locations: locations, start: start, end: end, style: radius === undefined
107
+ ? FILL_ABSOLUTE
108
+ : [FILL_ABSOLUTE, { borderRadius: radius }] }));
109
+ })));
110
+ }
111
+ /** A block's own gradient fill, ready to drop in as the first child of
112
+ * whatever box that block renders. `toViewStyle` has already painted the
113
+ * ground stop as the box's backgroundColor, so this adds the real gradient
114
+ * over it — and renders nothing at all for a plain colour. */
115
+ function BlockFill({ block, ctx, }) {
116
+ return (React.createElement(GradientLayers, { fill: block.style?.fill, doc: ctx.doc, gradient: ctx.gradient, radius: block.style?.radius, onDiagnostic: ctx.onDiagnostic }));
117
+ }
118
+ /** The solid a fill paints under any gradient layers. */
119
+ function fillColor(fill, doc) {
120
+ const resolved = resolveColor(fill, doc);
121
+ if (!resolved)
122
+ return undefined;
123
+ return isGradient(resolved) ? gradientFallbackColor(resolved) : resolved;
124
+ }
125
+ /** Paints a fill that may be a CSS gradient, behind `children`.
126
+ *
127
+ * The solid goes under the gradient only when the gradient itself cannot be
128
+ * drawn. A scrim is transparent at one end on purpose, and a solid under it
129
+ * was a black sheet over the background photo — see `surfaceSolid`. */
130
+ function Surface({ fill, doc, gradient, style, children, }) {
131
+ const solid = surfaceSolid(resolveColor(fill, doc), gradient !== undefined);
132
+ return (React.createElement(View, { style: [style, solid ? { backgroundColor: solid } : null] },
133
+ React.createElement(GradientLayers, { fill: fill, doc: doc, gradient: gradient }),
134
+ children));
135
+ }
136
+ /**
137
+ * The screen background's photo layer.
138
+ *
139
+ * React Native's <Image> has no `object-position`, so a focal point other
140
+ * than the centre is drawn by hand: measure the box, ask the platform for
141
+ * the source dimensions, and place the scaled image with `coverPlacement` —
142
+ * the same rule CSS applies for `object-position: X% Y%`. Until both
143
+ * measurements land (and always for a centred focal point, and for
144
+ * `contain`) the plain resizeMode path is already correct, so there is no
145
+ * flash of a wrongly-cropped photo.
146
+ */
147
+ function BackgroundPhoto({ image, blur: Blur, }) {
148
+ const centred = image.focalX === 50 && image.focalY === 50;
149
+ const needsPlacement = image.fit === "cover" && !centred;
150
+ const [box, setBox] = React.useState(null);
151
+ const [source, setSource] = React.useState(null);
152
+ React.useEffect(() => {
153
+ if (!needsPlacement)
154
+ return;
155
+ let live = true;
156
+ Image.getSize(image.url, (width, height) => live && setSource({ width, height }),
157
+ // A failed measure is not an error worth surfacing — the centred
158
+ // resizeMode path below stays in place.
159
+ () => undefined);
160
+ return () => {
161
+ live = false;
162
+ };
163
+ }, [image.url, needsPlacement]);
164
+ const placement = needsPlacement && box && source
165
+ ? coverPlacement(box, source, image.focalX, image.focalY)
166
+ : null;
167
+ const photo = placement ? (React.createElement(Image, { source: { uri: image.url }, style: {
168
+ position: "absolute",
169
+ left: placement.left,
170
+ top: placement.top,
171
+ width: placement.width,
172
+ height: placement.height,
173
+ }, resizeMode: "cover" })) : (React.createElement(Image, { source: { uri: image.url }, style: FILL_ABSOLUTE, resizeMode: image.fit }));
174
+ return (React.createElement(View, { pointerEvents: "none", style: [FILL_ABSOLUTE, { opacity: image.opacity, overflow: "hidden" }], onLayout: needsPlacement
175
+ ? (e) => setBox({
176
+ width: e.nativeEvent.layout.width,
177
+ height: e.nativeEvent.layout.height,
178
+ })
179
+ : undefined },
180
+ photo,
181
+ image.blur !== undefined && Blur ? (React.createElement(Blur, { intensity: Math.min(100, Math.round((image.blur / 30) * 100)), radius: image.blur, style: FILL_ABSOLUTE })) : null));
182
+ }
183
+ /**
184
+ * The whole screen background: ground, then photo, then scrim — the same
185
+ * three layers, in the same order, as the dashboard renderer paints. A
186
+ * document whose background is still a plain string resolves to a ground
187
+ * and nothing else, so this is byte-for-byte the old `Surface` for every
188
+ * paywall that has not had its background edited.
189
+ */
190
+ function ScreenBackground({ doc, gradient, blur, style, onDiagnostic, children, }) {
191
+ const layers = backgroundLayers(doc.background, (value) => resolveColor(value, doc) ?? value);
192
+ const ground = fillColor(layers.ground, doc);
193
+ return (React.createElement(View, { style: [style, ground ? { backgroundColor: ground } : null] },
194
+ React.createElement(GradientLayers, { fill: layers.ground, doc: doc, gradient: gradient, onDiagnostic: onDiagnostic }),
195
+ layers.image ? (React.createElement(BackgroundPhoto, { image: layers.image, blur: blur })) : null,
196
+ layers.overlay ? (React.createElement(Surface, { fill: layers.overlay.fill, doc: doc, gradient: gradient, style: [FILL_ABSOLUTE, { opacity: layers.overlay.opacity }] })) : null,
197
+ children));
198
+ }
199
+ /** A container block's box. The style lands on the PRESSABLE itself when the
200
+ * card is a selection target — wrapping a styled View in a bare Pressable
201
+ * would leave absolute offsets resolving against a zero-sized parent, which
202
+ * makes an `inset` card vanish and a pinned badge fly off the edge. */
203
+ function CardBox({ style, fill, doc, gradient, radius, onPress, children, }) {
204
+ const layers = (React.createElement(GradientLayers, { fill: fill, doc: doc, gradient: gradient, radius: radius }));
205
+ if (onPress) {
206
+ return (React.createElement(Pressable, { onPress: onPress, style: style },
207
+ layers,
208
+ children));
209
+ }
210
+ return (React.createElement(View, { style: style },
211
+ layers,
212
+ children));
213
+ }
214
+ /** The dismiss affordance the renderer supplies itself (REV-252).
215
+ *
216
+ * Drawn only when the design authors none of its own and the host wired an
217
+ * onClose — which is what makes every paywall published before close existed
218
+ * dismissible without being re-authored, while a design that DOES carry a
219
+ * close chip never ends up showing two.
220
+ *
221
+ * Deliberately plain: it is a safety net, not a design element. It is tinted
222
+ * from the screen's own ink rather than a fixed white so it stays legible on
223
+ * a light design as well as a dark one, and it sits outside the canvas
224
+ * transform so it is never scaled away on a small screen. */
225
+ function FallbackClose({ doc, onPress, safeAreaTop, }) {
226
+ return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close", hitSlop: 10, onPress: onPress, style: {
227
+ position: "absolute",
228
+ top: 14 + safeAreaTop,
229
+ right: 14,
230
+ zIndex: 50,
231
+ width: 30,
232
+ height: 30,
233
+ borderRadius: 15,
234
+ alignItems: "center",
235
+ justifyContent: "center",
236
+ backgroundColor: withAlpha(doc.textColor, 0.14),
237
+ } },
238
+ React.createElement(Text, { style: { color: doc.textColor, fontSize: 17, lineHeight: 20 } }, "\u00D7")));
239
+ }
240
+ /** Text blocks always render inside a View: a style may carry both typography
241
+ * and box properties (a round chip is a 34pt circle with a centered glyph),
242
+ * and RN cannot centre a glyph inside <Text> the way flexbox does. */
243
+ function BlockText({ text, style, ctx, inherited, onPress, }) {
244
+ const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
245
+ // The block's own typography is folded into the inherited set first, so
246
+ // em letter-spacing and unitless line-height resolve against whichever font
247
+ // size actually applies — the block's, or the one it inherited.
248
+ const own = inherit(inherited, style, styleCtx);
249
+ const body = (React.createElement(React.Fragment, null,
250
+ React.createElement(GradientLayers, { fill: style?.fill, doc: ctx.doc, gradient: ctx.gradient, radius: style?.radius, onDiagnostic: ctx.onDiagnostic }),
251
+ React.createElement(Text, { numberOfLines: style?.nowrap ? 1 : undefined, style: [inheritedTextStyle(own, ctx.style), textOnly(style, styleCtx)] }, text)));
252
+ // The whole box is the target, not just the glyph: a close chip is mostly
253
+ // padding, and a 12pt × is under the 44pt tap minimum on its own. The style
254
+ // lands on the Pressable itself for the same reason CardBox does it —
255
+ // wrapping a styled View would leave a pinned chip resolving its offsets
256
+ // against a zero-sized parent.
257
+ if (onPress) {
258
+ return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close", hitSlop: 8, onPress: onPress, style: toViewStyle(style, styleCtx) }, body));
259
+ }
260
+ return React.createElement(View, { style: toViewStyle(style, styleCtx) }, body);
261
+ }
262
+ /** The press handler for a block the design marks as the close, or undefined
263
+ * when it is not one (or the host wired no dismissal). One helper so text,
264
+ * image and button blocks cannot drift on what "action: close" means. */
265
+ function closePress(block, ctx) {
266
+ return block.action === "close" && ctx.onClose ? ctx.onClose : undefined;
267
+ }
268
+ /** Typography that is NOT inherited (decoration) — everything else already
269
+ * arrives through the inherited set. */
270
+ function textOnly(s, ctx) {
271
+ const t = toTextStyle(s, ctx);
272
+ return t.textDecorationLine !== undefined
273
+ ? { textDecorationLine: t.textDecorationLine }
274
+ : null;
275
+ }
276
+ function Products({ block, ctx, inherited, }) {
277
+ const { doc, packages } = ctx;
278
+ const base = inheritedTextStyle(inherited, ctx.style);
279
+ const row = block.direction === "row";
280
+ return (React.createElement(View, { style: [
281
+ {
282
+ flexDirection: row ? "row" : "column",
283
+ gap: block.style?.gap ?? 8,
284
+ },
285
+ toViewStyle(block.style, ctx.style),
286
+ ] },
287
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
288
+ packages.map((pkg) => {
289
+ const selected = pkg.packageId === ctx.selectedId;
290
+ const cardStyle = selected ? block.highlightStyle : block.cardStyle;
291
+ return (React.createElement(Pressable, { key: pkg.packageId, onPress: () => ctx.onSelect(pkg.packageId), style: [
292
+ {
293
+ position: "relative",
294
+ flexGrow: row ? 1 : undefined,
295
+ flexBasis: row ? 0 : undefined,
296
+ gap: 3,
297
+ borderRadius: 14,
298
+ paddingVertical: 12,
299
+ paddingHorizontal: 14,
300
+ borderWidth: 1.5,
301
+ borderColor: selected ? doc.accent : withAlpha("#808080", 0.35),
302
+ backgroundColor: selected
303
+ ? withAlpha(doc.accent, 0.12)
304
+ : "transparent",
305
+ },
306
+ toViewStyle(cardStyle, ctx.style),
307
+ ] },
308
+ React.createElement(GradientLayers, { fill: cardStyle?.fill, doc: doc, gradient: ctx.gradient, radius: cardStyle?.radius ?? 14, onDiagnostic: ctx.onDiagnostic }),
309
+ selected && block.badgeText ? (React.createElement(View, { style: {
310
+ position: "absolute",
311
+ top: -9,
312
+ right: 12,
313
+ backgroundColor: doc.accent,
314
+ paddingVertical: 2,
315
+ paddingHorizontal: 8,
316
+ borderRadius: 99,
317
+ } },
318
+ React.createElement(Text, { style: {
319
+ color: doc.accentInk,
320
+ fontSize: 10,
321
+ fontWeight: "800",
322
+ letterSpacing: 0.5,
323
+ } }, block.badgeText))) : null,
324
+ React.createElement(Text, { style: [
325
+ base,
326
+ {
327
+ fontWeight: "700",
328
+ fontSize: 14,
329
+ textAlign: row ? "center" : "left",
330
+ },
331
+ ] }, resolveTags(block.titleTpl ?? "{title}", pkg, packages)),
332
+ React.createElement(Text, { style: [
333
+ base,
334
+ { fontSize: 13, textAlign: row ? "center" : "left" },
335
+ ] }, resolveTags(block.priceTpl ?? "{price}", pkg, packages)),
336
+ selected && block.highlightSub ? (React.createElement(Text, { style: [
337
+ base,
338
+ {
339
+ fontSize: 11.5,
340
+ opacity: 0.75,
341
+ textAlign: row ? "center" : "left",
342
+ },
343
+ ] }, block.highlightSub)) : null));
344
+ })));
345
+ }
346
+ /** grid track list ("1fr 60px 66px") → per-column flex/width. */
347
+ function gridTracks(block) {
348
+ if (block.gridColumns) {
349
+ return block.gridColumns
350
+ .trim()
351
+ .split(/\s+/)
352
+ .map((track) => {
353
+ const fr = track.match(/^([\d.]*)fr$/);
354
+ if (fr)
355
+ return { flex: fr[1] ? parseFloat(fr[1]) : 1 };
356
+ const px = track.match(/^([\d.]+)px$/);
357
+ if (px)
358
+ return { width: parseFloat(px[1]) };
359
+ return { flex: 1 };
360
+ });
361
+ }
362
+ return Array.from({ length: block.columns ?? 2 }, () => ({ flex: 1 }));
363
+ }
364
+ function Block({ block, ctx, pkg, inherited, selection, }) {
365
+ const { doc } = ctx;
366
+ const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
367
+ // A pinned card judges its own context; every other block inherits the
368
+ // nearest plan card's. (A repeat card is judged per instance below.)
369
+ const context = block.type === "card" &&
370
+ block.packageIndex !== undefined &&
371
+ ctx.packages[block.packageIndex] !== undefined
372
+ ? packageContext(ctx.packages[block.packageIndex], ctx.selectedId)
373
+ : selection;
374
+ if (!isVisible(block, context))
375
+ return null;
376
+ // The style this block draws with in its state: `selectedStyle` merged
377
+ // over `style` while selected. Every read below goes through it.
378
+ const style = effectiveStyle(block, context);
379
+ // Web flex items shrink by default (`flex: 0 1 auto`); RN's do not. Without
380
+ // matching that, a row the browser fits by squeezing its children overflows
381
+ // the screen here instead.
382
+ const view = { flexShrink: 1, ...toViewStyle(style, styleCtx) };
383
+ const text = inherit(inherited, style, styleCtx);
384
+ const size = text.fontSize;
385
+ switch (block.type) {
386
+ case "text":
387
+ return (React.createElement(BlockText, { text: resolveTags(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: closePress(block, ctx) }));
388
+ case "image": {
389
+ const url = block.url || ctx.heroImageUrl;
390
+ const round = block.shape === "circle";
391
+ const s = style;
392
+ // A converted design sizes its own slot; the 160pt default is only for
393
+ // a slot dropped into a flow column, and must not fight it.
394
+ const sized = s !== undefined &&
395
+ (s.inset ||
396
+ s.height !== undefined ||
397
+ s.aspectRatio !== undefined ||
398
+ (s.top !== undefined && s.bottom !== undefined) ||
399
+ s.flex !== undefined);
400
+ // An icon the design marks as the close (REV-252). Same rule as text:
401
+ // the whole box is the target, and the style lands on the Pressable so
402
+ // a pinned icon keeps its offsets.
403
+ const closeImage = closePress(block, ctx);
404
+ const ImageBox = closeImage ? Pressable : View;
405
+ return (React.createElement(ImageBox, { accessibilityRole: closeImage ? "button" : undefined, accessibilityLabel: closeImage ? "Close" : undefined, hitSlop: closeImage ? 8 : undefined, onPress: closeImage, style: [
406
+ sized ? null : { height: 160 },
407
+ { borderRadius: round ? 9999 : sized ? 0 : 16, overflow: "hidden", flexShrink: 0 },
408
+ view,
409
+ ] },
410
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
411
+ url ? (React.createElement(Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : (React.createElement(View, { style: {
412
+ width: "100%",
413
+ height: "100%",
414
+ backgroundColor: withAlpha("#7d879b", 0.2),
415
+ } }))));
416
+ }
417
+ case "list":
418
+ return (React.createElement(View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
419
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
420
+ block.items.map((item, i) => (React.createElement(View, { key: i, style: { flexDirection: "row", gap: 9 } },
421
+ React.createElement(Text, { style: [
422
+ inheritedTextStyle(text, ctx.style),
423
+ {
424
+ color: resolveColor(block.iconColor, doc) ?? doc.accent,
425
+ fontWeight: "800",
426
+ },
427
+ ] }, item.icon ?? "✓"),
428
+ React.createElement(View, { style: { flexShrink: 1 } },
429
+ React.createElement(Text, { style: [
430
+ inheritedTextStyle(text, ctx.style),
431
+ { fontWeight: item.description ? "700" : "500" },
432
+ ] }, item.title),
433
+ item.description ? (React.createElement(Text, { style: [
434
+ inheritedTextStyle(text, ctx.style),
435
+ { fontSize: 12, opacity: 0.7 },
436
+ ] }, item.description)) : null))))));
437
+ case "products":
438
+ return React.createElement(Products, { block: block, ctx: ctx, inherited: text });
439
+ case "button": {
440
+ // A converted CTA sizes itself and centres with flex; the padded default
441
+ // is only for buttons added by hand.
442
+ const sized = style?.height !== undefined;
443
+ // A button the design marks as the close dismisses instead of buying,
444
+ // and takes no accent fill: the CTA must stay the one accented thing on
445
+ // the screen, or a "Not now" competes with "Subscribe" for the eye.
446
+ const close = closePress(block, ctx);
447
+ // A purchase in flight: the button keeps its box and fill, loses its
448
+ // label to a spinner in the ink colour, and ignores taps. A close
449
+ // button is never blocked — the user can always leave.
450
+ const busy = !close && ctx.loading;
451
+ return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: busy, busy }, disabled: busy, onPress: close ?? ctx.onPurchase, style: ({ pressed }) => [
452
+ {
453
+ backgroundColor: close ? "transparent" : doc.accent,
454
+ borderRadius: 12,
455
+ paddingVertical: sized ? 0 : 15,
456
+ paddingHorizontal: 16,
457
+ alignItems: "center",
458
+ justifyContent: "center",
459
+ },
460
+ view,
461
+ // Pressed feedback (contract §4): 80% while the finger is down.
462
+ pressed && !busy
463
+ ? {
464
+ opacity: (typeof view.opacity === "number" ? view.opacity : 1) * 0.8,
465
+ }
466
+ : null,
467
+ ] },
468
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
469
+ React.createElement(Text, { style: [
470
+ inheritedTextStyle(text, ctx.style),
471
+ {
472
+ color: close ? doc.textColor : doc.accentInk,
473
+ fontWeight: "800",
474
+ textAlign: "center",
475
+ },
476
+ // The CTA's own colour/size, when the design set one.
477
+ toTextStyle(style, styleCtx),
478
+ busy ? { opacity: 0 } : null,
479
+ ] }, resolveTags(block.label, pkg, ctx.packages)),
480
+ busy ? (React.createElement(ActivityIndicator, { color: doc.accentInk, style: { position: "absolute" } })) : null));
481
+ }
482
+ case "links": {
483
+ const open = (url) => url ? () => void Linking.openURL(url) : undefined;
484
+ const labels = linkLabels(ctx.doc.defaultLocale);
485
+ const links = [];
486
+ if (block.showRestore ?? true)
487
+ links.push({ label: labels.restore, onPress: ctx.onRestore });
488
+ if (block.showTerms ?? true)
489
+ links.push({
490
+ label: labels.terms,
491
+ onPress: ctx.onTerms ?? open(block.termsUrl ?? ctx.footer?.termsUrl),
492
+ });
493
+ if (block.showPrivacy ?? true)
494
+ links.push({
495
+ label: labels.privacy,
496
+ onPress: ctx.onPrivacy ?? open(block.privacyUrl ?? ctx.footer?.privacyUrl),
497
+ });
498
+ if (links.length === 0)
499
+ return null;
500
+ return (React.createElement(View, { style: [
501
+ { flexDirection: "row", justifyContent: "center", gap: 20 },
502
+ view,
503
+ ] },
504
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
505
+ links.map((link) => (React.createElement(Pressable, { key: link.label, onPress: link.onPress },
506
+ React.createElement(Text, { style: [
507
+ inheritedTextStyle(text, ctx.style),
508
+ { fontSize: text.fontSize === inherited.fontSize ? 12 : text.fontSize },
509
+ { opacity: 0.65 },
510
+ ] }, link.label))))));
511
+ }
512
+ case "line":
513
+ return (
514
+ // The web rule paints in `currentColor` at low alpha — that is the
515
+ // INHERITED text colour, not the screen default.
516
+ React.createElement(View, { style: [{ height: 1, backgroundColor: hairlineColor(text.color) }, view] },
517
+ React.createElement(BlockFill, { block: block, ctx: ctx })));
518
+ case "spacer":
519
+ // A flexing spacer still honours the rest of its style (a minHeight
520
+ // floor, margins) — flex decides how it grows, not what it is.
521
+ return (React.createElement(View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
522
+ case "card": {
523
+ const layout = block.layout ?? "column";
524
+ const base = layout === "stack"
525
+ ? { position: "relative" }
526
+ : layout === "grid"
527
+ ? {
528
+ position: "relative",
529
+ flexDirection: "row",
530
+ flexWrap: "wrap",
531
+ gap: style?.gap ?? 10,
532
+ }
533
+ : {
534
+ position: "relative",
535
+ flexDirection: layout === "row" ? "row" : "column",
536
+ gap: style?.gap ?? 10,
537
+ };
538
+ const tracks = layout === "grid" ? gridTracks(block) : null;
539
+ const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
540
+ const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
541
+ if (!tracks)
542
+ return node;
543
+ const track = tracks[i % tracks.length];
544
+ return (React.createElement(View, { key: child.id, style: track.width !== undefined
545
+ ? { width: track.width }
546
+ : // A grid row must WRAP after `columns` items, so each cell
547
+ // claims its share of the width as a basis rather than
548
+ // flexing to fit everything on one line.
549
+ { flexBasis: `${100 / tracks.length}%`, flexGrow: 0, flexShrink: 1 } }, node));
550
+ });
551
+ // A card that describes a package the offering does not reach is
552
+ // dropped rather than shown with unresolved tags.
553
+ if (block.packageIndex !== undefined &&
554
+ ctx.packages[block.packageIndex] === undefined) {
555
+ return null;
556
+ }
557
+ // A repeat card is one block drawn once per package, each instance in
558
+ // its own context, so its box, fill and inherited text are resolved
559
+ // per instance rather than from the `style` computed above.
560
+ if (block.repeat === "packages") {
561
+ const list = ctx.packages
562
+ .length
563
+ ? ctx.packages
564
+ : [undefined];
565
+ return (React.createElement(React.Fragment, null, list.map((p, i) => {
566
+ const instance = packageContext(p, ctx.selectedId) ?? selection;
567
+ if (!isVisible(block, instance))
568
+ return null;
569
+ const es = effectiveStyle(block, instance);
570
+ return (React.createElement(CardBox, { key: p?.packageId ?? i, style: [
571
+ base,
572
+ layout === "stack" ? null : { borderRadius: 16 },
573
+ { flexShrink: 1, ...toViewStyle(es, styleCtx) },
574
+ ], fill: es?.fill, doc: doc, gradient: ctx.gradient, radius: es?.radius ?? (layout === "stack" ? undefined : 16), onPress: p ? () => ctx.onSelect(p.packageId) : undefined }, renderChildren(p, instance, inherit(inherited, es, styleCtx))));
575
+ })));
576
+ }
577
+ const boxStyle = [
578
+ base,
579
+ layout === "stack" ? null : { borderRadius: 16 },
580
+ view,
581
+ ];
582
+ const cardPackage = block.packageIndex !== undefined
583
+ ? (ctx.packages[block.packageIndex] ?? pkg)
584
+ : pkg;
585
+ // A card pinned to a package doubles as its selection target — that is
586
+ // how hand-styled plan rows (a highlighted annual beside a plain
587
+ // monthly) become tappable without a products block.
588
+ const pinned = block.packageIndex !== undefined && cardPackage !== undefined;
589
+ return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, renderChildren(cardPackage, context)));
590
+ }
591
+ }
592
+ }
593
+ export function RevnixBlockPaywall(props) {
594
+ const { packages } = props;
595
+ // REV-271: the language overlay is applied ONCE, here, so every renderer
596
+ // path below reads plain strings and cannot forget to localize one. Memoized
597
+ // on the doc and the locale because it rebuilds the tree, and the renderer
598
+ // re-runs on every selection tap.
599
+ const doc = React.useMemo(() => localizeBlockDoc(props.doc, props.locale ?? detectLocale()), [props.doc, props.locale]);
600
+ const [internalSelected, setInternalSelected] = React.useState(undefined);
601
+ // Contract §1: host → own tap → design highlight → first package, each
602
+ // only when the offering carries it.
603
+ const selectedId = selectedPackageId(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
604
+ const select = (packageId) => {
605
+ setInternalSelected(packageId);
606
+ props.onSelectPackage?.(packageId);
607
+ };
608
+ const styleCtx = {
609
+ doc,
610
+ fontSize: DEFAULT_FONT_SIZE,
611
+ fontFamilyMap: props.fontFamilyMap,
612
+ onDiagnostic: props.onDiagnostic,
613
+ paintsGradients: props.gradient !== undefined,
614
+ };
615
+ // The screen's own typography is the root of the inheritance chain, exactly
616
+ // as the document's colors and font sit on the web renderer's screen div.
617
+ const rootText = {
618
+ color: doc.textColor,
619
+ fontSize: DEFAULT_FONT_SIZE,
620
+ fontFamilyName: doc.fontFamily,
621
+ };
622
+ const ctx = {
623
+ doc,
624
+ packages,
625
+ selectedId,
626
+ onSelect: select,
627
+ onPurchase: () => {
628
+ if (props.loading || !selectedId)
629
+ return;
630
+ props.onPurchase(selectedId);
631
+ },
632
+ onRestore: props.onRestore,
633
+ onTerms: props.onTerms,
634
+ onPrivacy: props.onPrivacy,
635
+ onClose: props.onClose,
636
+ footer: props.footer,
637
+ heroImageUrl: props.heroImageUrl,
638
+ gradient: props.gradient,
639
+ onDiagnostic: props.onDiagnostic,
640
+ style: styleCtx,
641
+ loading: props.loading === true,
642
+ };
643
+ const children = doc.blocks.map((block) => (React.createElement(Block, { key: block.id, block: block, ctx: ctx, pkg: packages.find((p) => p.packageId === selectedId) ?? packages[0], inherited: rootText })));
644
+ // REV-252: the renderer's own close, drawn only for a design that authors
645
+ // none. Rendered as a sibling of the tree — outside the canvas scale — so
646
+ // it keeps its tap size and its distance from the screen edge whatever the
647
+ // device width does to the design.
648
+ const safeAreaTop = props.safeAreaTop ??
649
+ (Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0);
650
+ const fallbackClose = props.onClose && !hasCloseAction(doc.blocks) ? (React.createElement(FallbackClose, { doc: doc, onPress: props.onClose, safeAreaTop: safeAreaTop })) : null;
651
+ const [width, setWidth] = React.useState(null);
652
+ const [viewportHeight, setViewportHeight] = React.useState(null);
653
+ if (doc.layout === "canvas") {
654
+ // Authored against the fixed device screen: lay the tree out at 1× and
655
+ // scale the whole thing, so absolute placement inside stack containers
656
+ // stays true at any width. RN scales about the centre, hence the
657
+ // compensating translate — the design must grow from the top-left.
658
+ // Contract §3. The canvas keeps the aspect ratio it was designed at,
659
+ // scaled by width up to the 480pt tablet cap and centred beyond it. On a
660
+ // screen SHORTER than 852:393 it overflows and scrolls — that is what
661
+ // keeps the CTA and the legal links reachable. On a TALLER screen the
662
+ // design is stretched to the viewport instead of leaving a band below
663
+ // it: the root card is "100%" tall and bottom-anchored groups follow it,
664
+ // which is exactly what the web preview does for a taller frame.
665
+ const metrics = width === null ? null : canvasMetrics(width, viewportHeight);
666
+ const scale = metrics?.scale ?? 1;
667
+ const overflows = metrics?.overflows ?? false;
668
+ const canvasHeight = metrics?.scaledHeight ?? DESIGN_CANVAS.height;
669
+ const left = metrics?.left ?? 0;
670
+ return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
671
+ React.createElement(ScrollView, { contentContainerStyle: { height: canvasHeight },
672
+ // A designed screen never shows a scrollbar — the web preview has
673
+ // none, and in an RTL app the indicator lands on the left edge of
674
+ // the artwork. When nothing overflows, scrolling is off entirely so
675
+ // a drag cannot bounce the design.
676
+ showsVerticalScrollIndicator: false, scrollEnabled: overflows, bounces: overflows, onLayout: (e) => setViewportHeight(e.nativeEvent.layout.height) },
677
+ React.createElement(View, { onLayout: (e) => setWidth(e.nativeEvent.layout.width), style: { flex: 1, overflow: "hidden" } },
678
+ React.createElement(View, { style: {
679
+ position: "absolute",
680
+ top: 0,
681
+ left: 0,
682
+ width: DESIGN_CANVAS.width,
683
+ // Laid out at design height, or taller when the viewport is:
684
+ // the extra height is in DESIGN units so the scale still
685
+ // maps it onto the screen exactly.
686
+ height: canvasHeight / scale,
687
+ opacity: width === null ? 0 : 1,
688
+ transform: [
689
+ {
690
+ translateX: -(DESIGN_CANVAS.width * (1 - scale)) / 2 + left,
691
+ },
692
+ { translateY: -((canvasHeight / scale) * (1 - scale)) / 2 },
693
+ { scale },
694
+ ],
695
+ } }, children))),
696
+ fallbackClose));
697
+ }
698
+ return (React.createElement(ScreenBackground, { doc: doc, gradient: props.gradient, blur: props.blur, onDiagnostic: props.onDiagnostic, style: [{ flex: 1 }, props.style] },
699
+ React.createElement(ScrollView, { contentContainerStyle: {
700
+ gap: 12,
701
+ paddingHorizontal: 20,
702
+ paddingTop: 18,
703
+ paddingBottom: 30,
704
+ }, showsVerticalScrollIndicator: false, alwaysBounceVertical: false, overScrollMode: "never" }, children),
705
+ fallbackClose));
706
+ }