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