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