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,318 @@
1
+ // Color handling for block paywalls.
2
+ //
3
+ // Two jobs the web renderer gets from CSS for free and React Native does not:
4
+ //
5
+ // 1. Palette TOKENS. Converted designs write `@accent`, `@text`, `@bg`,
6
+ // `@accentInk` (optionally with an alpha percentage: `@text/12`) so the
7
+ // four screen colors stay a single source of truth. The web renderer
8
+ // expands the percentage form with `color-mix()`; here we compute the
9
+ // rgba ourselves.
10
+ //
11
+ // 2. GRADIENTS. `background` is a CSS background string and may carry
12
+ // several gradient layers. React Native has no gradient primitive, so we
13
+ // parse the layers out: a host that supplies a gradient component renders
14
+ // them properly, and without one we fall back to the base layer's first
15
+ // color — a solid that keeps the screen on-palette instead of blank.
16
+ import { backgroundBaseColor } from "./background.js";
17
+ const TOKEN = /@(accentInk|accent|bg|text)(?:\/(\d{1,3}))?/g;
18
+ /** Expands palette tokens in a color string and applies any `/pct` alpha. */
19
+ export function resolveColor(value, doc) {
20
+ if (value === undefined)
21
+ return undefined;
22
+ if (!value.includes("@"))
23
+ return value;
24
+ return value.replace(TOKEN, (_match, name, pct) => {
25
+ const base = name === "accentInk"
26
+ ? doc.accentInk
27
+ : name === "accent"
28
+ ? doc.accent
29
+ : name === "bg"
30
+ ? backgroundBaseColor(doc.background)
31
+ : doc.textColor;
32
+ return pct === undefined ? base : withAlpha(base, Number(pct) / 100);
33
+ });
34
+ }
35
+ /** Multiplies a color's alpha. Understands #rgb/#rgba/#rrggbb/#rrggbbaa and
36
+ * rgb()/rgba(); anything else (a named color, a gradient) is returned as-is
37
+ * rather than guessed at. */
38
+ export function withAlpha(color, alpha) {
39
+ const rgb = toRgb(color);
40
+ if (!rgb)
41
+ return color;
42
+ const a = Math.max(0, Math.min(1, rgb.a * alpha));
43
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${round(a)})`;
44
+ }
45
+ function round(n) {
46
+ return Math.round(n * 1000) / 1000;
47
+ }
48
+ export function toRgb(color) {
49
+ const value = color.trim();
50
+ if (value.startsWith("#")) {
51
+ const hex = value.slice(1);
52
+ const expand = (c) => parseInt(c + c, 16);
53
+ if (!/^[0-9a-fA-F]+$/.test(hex))
54
+ return null;
55
+ if (hex.length === 3 || hex.length === 4) {
56
+ return {
57
+ r: expand(hex[0]),
58
+ g: expand(hex[1]),
59
+ b: expand(hex[2]),
60
+ a: hex.length === 4 ? expand(hex[3]) / 255 : 1,
61
+ };
62
+ }
63
+ if (hex.length === 6 || hex.length === 8) {
64
+ const byte = (i) => parseInt(hex.slice(i, i + 2), 16);
65
+ return {
66
+ r: byte(0),
67
+ g: byte(2),
68
+ b: byte(4),
69
+ a: hex.length === 8 ? byte(6) / 255 : 1,
70
+ };
71
+ }
72
+ return null;
73
+ }
74
+ const fn = value.match(/^rgba?\(([^)]+)\)$/i);
75
+ if (fn) {
76
+ const parts = fn[1].split(/[,/\s]+/).filter(Boolean).map(Number);
77
+ if (parts.length < 3 || parts.slice(0, 3).some(Number.isNaN))
78
+ return null;
79
+ return {
80
+ r: parts[0],
81
+ g: parts[1],
82
+ b: parts[2],
83
+ a: parts.length > 3 && !Number.isNaN(parts[3]) ? parts[3] : 1,
84
+ };
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Whether a paint string's BOTTOM layer is a repeating pattern.
90
+ *
91
+ * A `repeating-*` gradient is a TEXTURE, and the colors inside it are stripe
92
+ * colors rather than the surface's. React Native cannot draw one, and painting
93
+ * a color lifted out of its arguments across the whole box is a WRONG answer
94
+ * rather than a degraded one — the library's hairline grid is one color every
95
+ * 26px, and as a solid fill it is a slab. Such a fill paints nothing instead.
96
+ *
97
+ * The bottom layer decides, so a pattern stacked over a real ground
98
+ * (`repeating-…(…), #FBF3E4`) still falls back to that ground.
99
+ */
100
+ export function isRepeatingPattern(value) {
101
+ const s = value?.trim() ?? "";
102
+ if (!s)
103
+ return false;
104
+ const layers = splitLayers(s);
105
+ const bottom = layers.length > 0 ? layers[layers.length - 1] : s;
106
+ return /^repeating-/i.test(bottom);
107
+ }
108
+ /** True when the string is a CSS gradient rather than a plain color. */
109
+ export function isGradient(value) {
110
+ return /gradient\s*\(/i.test(value);
111
+ }
112
+ /** Splits a CSS background into its gradient layers. Layers CSS paints last
113
+ * (the base) come last in the returned array, matching source order. */
114
+ export function parseGradient(value) {
115
+ const layers = [];
116
+ for (const raw of splitLayers(value)) {
117
+ const match = raw.match(/^(linear|radial)-gradient\s*\(([\s\S]*)\)$/i);
118
+ if (!match) {
119
+ // A plain color among the layers is the design's ground. Dropping it
120
+ // would leave the screen painted by a translucent glow instead.
121
+ //
122
+ // It has to LOOK like a color, though: any other layer — a
123
+ // `repeating-linear-gradient`, a `url()` — used to be accepted here and
124
+ // handed to the host's gradient component as a color string, which is not
125
+ // one. Dropping it instead is what lets the caller see there is nothing
126
+ // paintable and degrade to a flat color.
127
+ if (raw && hasColor(raw)) {
128
+ layers.push({ kind: "solid", angle: 180, colors: [raw] });
129
+ }
130
+ continue;
131
+ }
132
+ const kind = match[1].toLowerCase();
133
+ const args = splitTop(match[2]);
134
+ // A radial's center is dropped (RN has no radial gradient), so it is drawn
135
+ // as a top-down fade — 180deg, like every other default.
136
+ let angle = 180;
137
+ let start = 0;
138
+ let center;
139
+ const first = args[0]?.trim() ?? "";
140
+ if (kind === "linear" && /^-?[\d.]+deg$/i.test(first)) {
141
+ angle = parseFloat(first);
142
+ start = 1;
143
+ }
144
+ else if (kind === "linear" && /^to\s+/i.test(first)) {
145
+ angle = TO_ANGLE[first.trim().toLowerCase()] ?? 180;
146
+ start = 1;
147
+ }
148
+ else if (kind === "radial" && !hasColor(first)) {
149
+ // Radial shape/position prelude ("120% 90% at 85% 5%"). RN has no
150
+ // radial gradient, but the centre decides which way the stand-in fades,
151
+ // so it is kept rather than dropped with the rest.
152
+ center = readCenter(first);
153
+ start = 1;
154
+ }
155
+ const colors = [];
156
+ const locations = [];
157
+ let positioned = 0;
158
+ for (const arg of args.slice(start)) {
159
+ // CSS allows TWO positions on one stop — `@accent 0 22%` is the same
160
+ // color at both, the hard edge the library's progress bars and split
161
+ // panels are drawn with — and a unitless `0` is a valid position. Reading
162
+ // only the last one left those designs a smooth fade instead of an edge.
163
+ let body = arg.trim();
164
+ const positions = [];
165
+ while (positions.length < 2) {
166
+ const found = body.match(/\s(-?[\d.]+%|0)$/);
167
+ if (!found)
168
+ break;
169
+ const text = found[1];
170
+ const value = parseFloat(text);
171
+ // The token comes off `body` either way. Leaving a position this build
172
+ // could not read attached to the color would make the color itself
173
+ // unparseable, dropping the whole stop — and a gradient left with one
174
+ // stop does not parse at all. A malformed position is worth losing; the
175
+ // stop is not, so it falls through to the interpolated position.
176
+ body = body.slice(0, body.length - found[0].length).trim();
177
+ if (Number.isNaN(value))
178
+ break;
179
+ positions.unshift(Math.max(0, Math.min(1, text.endsWith("%") ? value / 100 : value)));
180
+ }
181
+ if (!body)
182
+ continue;
183
+ if (positions.length === 0) {
184
+ colors.push(body);
185
+ locations.push(colors.length === 1 ? 0 : 1);
186
+ continue;
187
+ }
188
+ for (const position of positions) {
189
+ colors.push(body);
190
+ locations.push(position);
191
+ positioned++;
192
+ }
193
+ }
194
+ if (colors.length === 0)
195
+ continue;
196
+ layers.push({
197
+ kind,
198
+ angle,
199
+ colors,
200
+ // Partial position data would place stops wrongly; only pass locations
201
+ // through when the source positioned every stop.
202
+ locations: positioned === colors.length ? locations : undefined,
203
+ center,
204
+ });
205
+ }
206
+ return layers;
207
+ }
208
+ const TO_ANGLE = {
209
+ "to top": 0,
210
+ "to right": 90,
211
+ "to bottom": 180,
212
+ "to left": 270,
213
+ "to top right": 45,
214
+ "to right top": 45,
215
+ "to bottom right": 135,
216
+ "to right bottom": 135,
217
+ "to bottom left": 225,
218
+ "to left bottom": 225,
219
+ "to top left": 315,
220
+ "to left top": 315,
221
+ };
222
+ /** The `at X% Y%` part of a radial's prelude, as 0–1. Defaults to the CSS
223
+ * default centre when the prelude names none. */
224
+ function readCenter(prelude) {
225
+ const at = prelude.split(/\bat\b/i)[1];
226
+ const pcts = at ? [...at.matchAll(/(-?[\d.]+)%/g)].map((m) => parseFloat(m[1])) : [];
227
+ return {
228
+ x: (pcts[0] ?? 50) / 100,
229
+ y: (pcts[1] ?? 50) / 100,
230
+ };
231
+ }
232
+ function hasColor(value) {
233
+ const s = value.trim();
234
+ return /^(#|rgb|hsl|@)/i.test(s) || s.toLowerCase() === "transparent";
235
+ }
236
+ /**
237
+ * A field that can only ever be ONE color — a border, text, an icon, a shadow.
238
+ *
239
+ * A gradient there has no native form (nor a CSS one: `border-color` takes no
240
+ * gradient, so the dashboard drops the declaration outright). Collapsing it to
241
+ * the color it stands for keeps the stroke or the glyph visible, which is
242
+ * nearer the design's intent than losing it — and, on React Native, avoids
243
+ * handing the platform a color string it cannot parse.
244
+ */
245
+ export function flatColor(value, doc, onDiagnostic) {
246
+ const resolved = resolveColor(value, doc);
247
+ if (resolved === undefined || !isGradient(resolved))
248
+ return resolved;
249
+ onDiagnostic?.(`gradient flattened in a colour-only field: ${resolved}`);
250
+ return gradientFallbackColor(resolved);
251
+ }
252
+ /** The solid to paint when no gradient component is available: the base
253
+ * layer's first stop, which is the design's ground color.
254
+ *
255
+ * A value with no paintable layer at all — a `repeating-linear-gradient`, or
256
+ * any other form this build does not read — falls through to the same colour
257
+ * extraction the six SDKs share, so a box the design coloured still gets a
258
+ * colour from the design rather than nothing. */
259
+ export function gradientFallbackColor(value) {
260
+ // A pattern's colors are STRIPE colors; spreading one over the whole box is a
261
+ // wrong answer rather than a degraded one, so it paints nothing.
262
+ if (isRepeatingPattern(value))
263
+ return undefined;
264
+ const layers = parseGradient(value);
265
+ const base = layers[layers.length - 1];
266
+ return base?.colors[0] ?? backgroundBaseColor(value);
267
+ }
268
+ /**
269
+ * The solid a surface paints UNDER a fill — or nothing.
270
+ *
271
+ * The solid exists for one reason: an app that passes no gradient component
272
+ * still gets a colour instead of a hole. It was painted unconditionally, and
273
+ * for an opaque fill that never showed, because the real gradient covered it.
274
+ *
275
+ * A scrim showed it. A scrim is a gradient that is transparent at one end ON
276
+ * PURPOSE — that is how a photo stays visible above the copy. Its first stop
277
+ * is the dark end, so the "fallback" under it was a near-black sheet across
278
+ * the whole box, and the gradient's transparent half revealed that sheet
279
+ * instead of the photograph behind it. Every photo background looked like a
280
+ * black screen on React Native, while the dashboard's CSS painted it fine.
281
+ *
282
+ * So: when the gradient will actually be drawn, nothing goes under it. The
283
+ * solid is only for the degraded case — no gradient renderer, or a fill this
284
+ * build cannot read as a gradient at all.
285
+ */
286
+ export function surfaceSolid(resolved, canPaintGradient) {
287
+ if (!resolved)
288
+ return undefined;
289
+ if (!isGradient(resolved))
290
+ return resolved;
291
+ if (canPaintGradient && parseGradient(resolved).length > 0)
292
+ return undefined;
293
+ return gradientFallbackColor(resolved);
294
+ }
295
+ /** Splits comma-separated background layers, ignoring commas inside parens. */
296
+ function splitLayers(value) {
297
+ return splitTop(value).map((s) => s.trim());
298
+ }
299
+ function splitTop(value) {
300
+ const out = [];
301
+ let depth = 0;
302
+ let current = "";
303
+ for (const ch of value) {
304
+ if (ch === "(")
305
+ depth++;
306
+ else if (ch === ")")
307
+ depth--;
308
+ if (ch === "," && depth === 0) {
309
+ out.push(current);
310
+ current = "";
311
+ continue;
312
+ }
313
+ current += ch;
314
+ }
315
+ if (current.trim())
316
+ out.push(current);
317
+ return out;
318
+ }
@@ -0,0 +1,255 @@
1
+ // REV-271: paywall localization, SDK half. A designed paywall carries ONE
2
+ // tree plus a side table of translated strings — never one tree per language
3
+ // — so styles, layout and block ids are shared and only words differ.
4
+ //
5
+ // Selection happens HERE, at render, rather than server-side at resolve. The
6
+ // resolution is cached on the device (REV-157), so a locale chosen by the
7
+ // server would pin a cached paywall to whatever language it was fetched in:
8
+ // change the phone's language and the old copy would keep rendering until the
9
+ // cache expired, and offline it would never change at all. Choosing on the
10
+ // device also keeps the server's fleet-wide placement cache one entry per
11
+ // placement instead of one per language.
12
+ //
13
+ // Mirrored from the dashboard's src/lib/paywall-blocks/locale.ts and into the
14
+ // five native SDKs. The key scheme, the fallback chain and the field list
15
+ // must stay identical on every side — a paywall translated in the dashboard
16
+ // has to resolve the same way on all six.
17
+ /** BCP-47, hyphenated: "es_MX" and "es-mx" both normalize to "es-MX". Applied
18
+ * to authored tags and to the device's reported locale alike, so the two can
19
+ * never miss each other over punctuation or case. */
20
+ export function normalizeLocale(tag) {
21
+ if (typeof tag !== "string")
22
+ return undefined;
23
+ const parts = tag.trim().replace(/_/g, "-").split("-").filter(Boolean);
24
+ const language = parts[0]?.toLowerCase();
25
+ if (language === undefined || !/^[a-z]{2,3}$/.test(language))
26
+ return undefined;
27
+ const rest = [];
28
+ for (const part of parts.slice(1)) {
29
+ // Region subtags are uppercase ("MX", "419"), scripts title case ("Hans").
30
+ // Anything else is a variant and stays lowercase. A subtag outside
31
+ // BCP-47's shapes makes the WHOLE tag junk rather than passing through —
32
+ // otherwise a device reporting "es-!!" would look up a language.
33
+ if (/^[A-Za-z]{2}$/.test(part) || /^\d{3}$/.test(part)) {
34
+ rest.push(part.toUpperCase());
35
+ }
36
+ else if (/^[A-Za-z]{4}$/.test(part)) {
37
+ rest.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase());
38
+ }
39
+ else if (/^[A-Za-z0-9]{5,8}$/.test(part)) {
40
+ rest.push(part.toLowerCase());
41
+ }
42
+ else {
43
+ return undefined;
44
+ }
45
+ }
46
+ return [language, ...rest].join("-");
47
+ }
48
+ function baseLanguage(tag) {
49
+ return tag.split("-")[0] ?? tag;
50
+ }
51
+ /** Which tables to consult, most specific first.
52
+ *
53
+ * "es-MX" on a paywall translated into "es" reads the "es" table: a regional
54
+ * variant that was never authored falls back to its language rather than to
55
+ * the source, which is the difference between a Mexican user reading Spanish
56
+ * and reading English. The authored tree is the last resort and is NOT in
57
+ * this chain — the lookup falls through to it. */
58
+ export function localeChain(available, locale, defaultLocale) {
59
+ const chain = [];
60
+ const push = (tag) => {
61
+ if (tag !== undefined && available.includes(tag) && !chain.includes(tag)) {
62
+ chain.push(tag);
63
+ }
64
+ };
65
+ const wanted = normalizeLocale(locale);
66
+ if (wanted !== undefined) {
67
+ push(wanted);
68
+ push(baseLanguage(wanted));
69
+ // "es" asked for, only "es-MX" authored: one regional table beats the
70
+ // source language, and the first sorted match keeps the choice
71
+ // deterministic across devices rather than dependent on key order.
72
+ if (chain.length === 0) {
73
+ const language = baseLanguage(wanted);
74
+ const regional = available
75
+ .filter((tag) => baseLanguage(tag) === language)
76
+ .sort();
77
+ push(regional[0]);
78
+ }
79
+ }
80
+ push(normalizeLocale(defaultLocale));
81
+ return chain;
82
+ }
83
+ let localeOverride;
84
+ /** Forces the paywall language regardless of the device's, for a host whose
85
+ * in-app language picker differs from the OS locale. `undefined` clears it.
86
+ * Only paywalls MOUNTED AFTER this call pick it up — `detectLocale()` is
87
+ * read once per doc/locale in a `useMemo`. */
88
+ export function setLocale(tag) {
89
+ localeOverride = tag || undefined;
90
+ }
91
+ /** The device's language, for hosts that do not pass one. Hermes without the
92
+ * full ICU build has no `Intl`, and a React Native `navigator.language` is
93
+ * the JS engine's, not the device's — so this is best effort and a host that
94
+ * cares passes `locale` explicitly. */
95
+ export function detectLocale() {
96
+ if (localeOverride !== undefined)
97
+ return localeOverride;
98
+ try {
99
+ const resolved = new Intl.DateTimeFormat().resolvedOptions().locale;
100
+ if (typeof resolved === "string" && resolved.length > 0)
101
+ return resolved;
102
+ }
103
+ catch {
104
+ // Falls through to navigator below.
105
+ }
106
+ const nav = globalThis.navigator;
107
+ return typeof nav?.language === "string" && nav.language.length > 0
108
+ ? nav.language
109
+ : undefined;
110
+ }
111
+ function localizeBlock(block, lookup) {
112
+ const at = (path, authored) => lookup(`${block.id}.${path}`, authored);
113
+ const opt = (path, authored) => authored === undefined ? undefined : at(path, authored);
114
+ switch (block.type) {
115
+ case "text":
116
+ return { ...block, text: at("text", block.text) };
117
+ case "button":
118
+ return { ...block, label: at("label", block.label) };
119
+ case "list":
120
+ return {
121
+ ...block,
122
+ items: block.items.map((item, i) => ({
123
+ ...item,
124
+ title: at(`items.${i}.title`, item.title),
125
+ description: opt(`items.${i}.description`, item.description),
126
+ })),
127
+ };
128
+ case "products":
129
+ return {
130
+ ...block,
131
+ titleTpl: opt("titleTpl", block.titleTpl),
132
+ priceTpl: opt("priceTpl", block.priceTpl),
133
+ highlightSub: opt("highlightSub", block.highlightSub),
134
+ badgeText: opt("badgeText", block.badgeText),
135
+ };
136
+ case "card":
137
+ return {
138
+ ...block,
139
+ children: block.children.map((child) => localizeBlock(child, lookup)),
140
+ };
141
+ default:
142
+ return block;
143
+ }
144
+ }
145
+ /** Returns the document with every string swapped for `locale`'s.
146
+ *
147
+ * Applied ONCE before rendering rather than at each text node: the renderer
148
+ * then needs no localization awareness at all, and the six SDKs cannot drift
149
+ * on which fields are translatable. Returns the input untouched when nothing
150
+ * applies, so an untranslated paywall allocates nothing.
151
+ *
152
+ * `{price}` and the other copy tags survive, because they are resolved AFTER
153
+ * this on the localized string — "Solo {price} al mes" works. */
154
+ export function localizeBlockDoc(doc, locale) {
155
+ const tables = doc.locales;
156
+ if (tables === undefined)
157
+ return doc;
158
+ const available = Object.keys(tables);
159
+ if (available.length === 0)
160
+ return doc;
161
+ const chain = localeChain(available, locale, doc.defaultLocale);
162
+ if (chain.length === 0)
163
+ return doc;
164
+ const lookup = (key, authored) => {
165
+ for (const tag of chain) {
166
+ const value = tables[tag]?.[key];
167
+ // An empty translation means "not translated", never "render nothing":
168
+ // a blank CTA is a dead paywall, and an export/import round-trip leaves
169
+ // empty cells behind for every untouched row.
170
+ if (typeof value === "string" && value !== "")
171
+ return value;
172
+ }
173
+ return authored;
174
+ };
175
+ return {
176
+ ...doc,
177
+ defaultLocale: chain[0],
178
+ blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
179
+ };
180
+ }
181
+ const LINK_LABEL_ALIASES = {
182
+ iw: "he",
183
+ in: "id",
184
+ no: "nb",
185
+ tl: "fil",
186
+ };
187
+ const LINK_LABELS = {
188
+ ar: ["استعادة", "الشروط", "الخصوصية"],
189
+ bg: ["Възстановяване", "Условия", "Поверителност"],
190
+ bn: ["পুনরুদ্ধার", "শর্তাবলী", "গোপনীয়তা"],
191
+ ca: ["Restaura", "Condicions", "Privadesa"],
192
+ cs: ["Obnovit", "Podmínky", "Soukromí"],
193
+ da: ["Gendan", "Vilkår", "Privatliv"],
194
+ de: ["Wiederherstellen", "AGB", "Datenschutz"],
195
+ el: ["Επαναφορά", "Όροι", "Απόρρητο"],
196
+ en: ["Restore", "Terms", "Privacy"],
197
+ es: ["Restaurar", "Términos", "Privacidad"],
198
+ et: ["Taasta", "Tingimused", "Privaatsus"],
199
+ fa: ["بازیابی", "شرایط", "حریم خصوصی"],
200
+ fi: ["Palauta", "Ehdot", "Tietosuoja"],
201
+ fil: ["I-restore", "Mga Tuntunin", "Privacy"],
202
+ fr: ["Restaurer", "Conditions", "Confidentialité"],
203
+ he: ["שחזור", "תנאים", "פרטיות"],
204
+ hi: ["पुनर्स्थापित करें", "शर्तें", "गोपनीयता"],
205
+ hr: ["Vrati", "Uvjeti", "Privatnost"],
206
+ hu: ["Visszaállítás", "Feltételek", "Adatvédelem"],
207
+ id: ["Pulihkan", "Ketentuan", "Privasi"],
208
+ it: ["Ripristina", "Termini", "Privacy"],
209
+ ja: ["購入を復元", "利用規約", "プライバシー"],
210
+ ko: ["구매 복원", "이용약관", "개인정보"],
211
+ lt: ["Atkurti", "Sąlygos", "Privatumas"],
212
+ lv: ["Atjaunot", "Noteikumi", "Privātums"],
213
+ ms: ["Pulihkan", "Terma", "Privasi"],
214
+ nb: ["Gjenopprett", "Vilkår", "Personvern"],
215
+ nl: ["Herstellen", "Voorwaarden", "Privacy"],
216
+ pl: ["Przywróć", "Regulamin", "Prywatność"],
217
+ pt: ["Restaurar", "Termos", "Privacidade"],
218
+ ro: ["Restaurează", "Termeni", "Confidențialitate"],
219
+ ru: ["Восстановить", "Условия", "Конфиденциальность"],
220
+ sk: ["Obnoviť", "Podmienky", "Súkromie"],
221
+ sl: ["Obnovi", "Pogoji", "Zasebnost"],
222
+ sr: ["Врати", "Услови", "Приватност"],
223
+ sv: ["Återställ", "Villkor", "Integritet"],
224
+ th: ["กู้คืน", "ข้อกำหนด", "ความเป็นส่วนตัว"],
225
+ tr: ["Geri Yükle", "Koşullar", "Gizlilik"],
226
+ uk: ["Відновити", "Умови", "Конфіденційність"],
227
+ ur: ["بحال کریں", "شرائط", "رازداری"],
228
+ vi: ["Khôi phục", "Điều khoản", "Quyền riêng tư"],
229
+ zh: ["恢复购买", "条款", "隐私"],
230
+ "zh-Hant": ["恢復購買", "條款", "隱私"],
231
+ };
232
+ /** The three built-in paywall footer labels (restore/terms/privacy), in the
233
+ * language `locale` resolves to. Falls back to English for any language not
234
+ * in the 43-entry table. Mandarin picks traditional script for Taiwan, Hong
235
+ * Kong and Macau unless the tag is explicitly simplified. */
236
+ export function linkLabels(locale) {
237
+ const [restore, terms, privacy] = LINK_LABELS[resolveLinkLabelTag(locale)];
238
+ return { restore, terms, privacy };
239
+ }
240
+ function resolveLinkLabelTag(locale) {
241
+ const tag = normalizeLocale(locale);
242
+ if (tag === undefined)
243
+ return "en";
244
+ const lang = baseLanguage(tag);
245
+ if (lang === "zh") {
246
+ const parts = tag.split("-").slice(1);
247
+ const hasHant = parts.includes("Hant");
248
+ const hasHans = parts.includes("Hans");
249
+ const region = parts.find((part) => /^[A-Z]{2}$/.test(part));
250
+ const isTraditionalRegion = region !== undefined && ["TW", "HK", "MO"].includes(region);
251
+ return hasHant || (!hasHans && isTraditionalRegion) ? "zh-Hant" : "zh";
252
+ }
253
+ const base = LINK_LABEL_ALIASES[lang] ?? lang;
254
+ return base in LINK_LABELS ? base : "en";
255
+ }
@@ -0,0 +1,100 @@
1
+ // REV-262: the selection half of the designed-paywall render contract v2
2
+ // (revnix/cto-review/2026-09-03-paywall-render-contract-v2.md), kept pure so
3
+ // the wire fixture can be walked without React Native.
4
+ //
5
+ // Mirrors revnix-app's src/lib/paywall-blocks/selection.ts: the dashboard
6
+ // preview and this renderer must agree on which package is "selected", what
7
+ // a block looks like in that state and whether it is drawn at all.
8
+ import { DESIGN_CANVAS } from "./types.js";
9
+ import { resolveTags } from "./tags.js";
10
+ /** The package a designed paywall is showing as chosen: the host's choice,
11
+ * then the renderer's own after a tap, then the design's highlight, then the
12
+ * first package — each only when the offering actually carries it. */
13
+ export function selectedPackageId(packages, ...candidates) {
14
+ for (const id of candidates) {
15
+ if (id !== undefined && packages.some((p) => p.packageId === id))
16
+ return id;
17
+ }
18
+ return packages[0]?.packageId;
19
+ }
20
+ /** The context a package-bearing card opens for itself and its subtree. */
21
+ export function packageContext(pkg, selectedId) {
22
+ if (pkg === undefined)
23
+ return undefined;
24
+ return pkg.packageId === selectedId ? "selected" : "unselected";
25
+ }
26
+ /** `selectedStyle` merged over `style` while selected; `style` otherwise. */
27
+ export function effectiveStyle(block, context) {
28
+ if (context === "selected" && block.selectedStyle) {
29
+ return { ...block.style, ...block.selectedStyle };
30
+ }
31
+ return block.style;
32
+ }
33
+ /** Whether a block is drawn. Outside any plan card every block is drawn,
34
+ * whatever `visibility` says — a root block can never disappear. */
35
+ export function isVisible(block, context) {
36
+ if (block.visibility === undefined || context === undefined)
37
+ return true;
38
+ return (block.visibility === "selected") === (context === "selected");
39
+ }
40
+ /** Canvas designs are authored at 393×852 and scaled by width, capped so a
41
+ * tablet or a landscape phone shows a centred phone-sized design instead of
42
+ * a blown-up one. */
43
+ export const CANVAS_MAX_WIDTH = 480;
44
+ export function canvasMetrics(viewportWidth, viewportHeight) {
45
+ const scale = Math.min(viewportWidth, CANVAS_MAX_WIDTH) / DESIGN_CANVAS.width;
46
+ const left = (viewportWidth - DESIGN_CANVAS.width * scale) / 2;
47
+ const designHeight = viewportHeight === null
48
+ ? DESIGN_CANVAS.height
49
+ : Math.max(DESIGN_CANVAS.height, viewportHeight / scale);
50
+ const scaledHeight = designHeight * scale;
51
+ const overflows = viewportHeight !== null && DESIGN_CANVAS.height * scale > viewportHeight + 1;
52
+ return { scale, left, designHeight, scaledHeight, overflows };
53
+ }
54
+ /** Walk a document exactly the way the renderer does — pinned cards judge
55
+ * their own context, repeat cards yield one entry per package (keyed
56
+ * `id@packageId`), hidden subtrees are not entered — and report what every
57
+ * block resolves to. The wire fixture test reads this. */
58
+ export function resolveBlocks(doc, packages, selectedId) {
59
+ const out = new Map();
60
+ const root = packages.find((p) => p.packageId === selectedId) ?? packages[0];
61
+ const visit = (block, inherited, pkg, key) => {
62
+ if (block.type === "card" && block.repeat === "packages") {
63
+ const list = packages.length ? packages : [undefined];
64
+ for (const p of list) {
65
+ visitCard(block, packageContext(p, selectedId) ?? inherited, p ?? pkg, `${key}@${p?.packageId ?? "-"}`);
66
+ }
67
+ return;
68
+ }
69
+ if (block.type === "card" && block.packageIndex !== undefined) {
70
+ const own = packages[block.packageIndex];
71
+ if (own === undefined)
72
+ return; // dropped: the offering does not reach it
73
+ visitCard(block, packageContext(own, selectedId), own, key);
74
+ return;
75
+ }
76
+ const context = inherited;
77
+ const visible = isVisible(block, context);
78
+ const entry = { block, context, visible, style: effectiveStyle(block, context) };
79
+ if (block.type === "text")
80
+ entry.text = resolveTags(block.text, pkg, packages);
81
+ if (block.type === "button")
82
+ entry.text = resolveTags(block.label, pkg, packages);
83
+ out.set(key, entry);
84
+ if (visible && block.type === "card") {
85
+ for (const child of block.children)
86
+ visit(child, context, pkg, child.id);
87
+ }
88
+ };
89
+ const visitCard = (block, context, pkg, key) => {
90
+ const visible = isVisible(block, context);
91
+ out.set(key, { block, context, visible, style: effectiveStyle(block, context) });
92
+ if (!visible)
93
+ return;
94
+ for (const child of block.children)
95
+ visit(child, context, pkg, child.id);
96
+ };
97
+ for (const b of doc.blocks)
98
+ visit(b, undefined, root, b.id);
99
+ return out;
100
+ }