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.
- package/LICENSE +21 -0
- package/README.md +766 -0
- package/dist/blocks/background.d.ts +101 -0
- package/dist/blocks/background.js +165 -0
- package/dist/blocks/color.d.ts +88 -0
- package/dist/blocks/color.js +329 -0
- package/dist/blocks/locale.d.ts +42 -0
- package/dist/blocks/locale.js +263 -0
- package/dist/blocks/selection.d.ts +49 -0
- package/dist/blocks/selection.js +109 -0
- package/dist/blocks/style.d.ts +93 -0
- package/dist/blocks/style.js +365 -0
- package/dist/blocks/tags.d.ts +12 -0
- package/dist/blocks/tags.js +121 -0
- package/dist/blocks/types.d.ts +301 -0
- package/dist/blocks/types.js +44 -0
- package/dist/client.d.ts +402 -0
- package/dist/client.js +1653 -0
- package/dist/device.d.ts +29 -0
- package/dist/device.js +137 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +68 -0
- package/dist/esm/blocks/background.js +160 -0
- package/dist/esm/blocks/color.js +318 -0
- package/dist/esm/blocks/locale.js +255 -0
- package/dist/esm/blocks/selection.js +100 -0
- package/dist/esm/blocks/style.js +356 -0
- package/dist/esm/blocks/tags.js +117 -0
- package/dist/esm/blocks/types.js +40 -0
- package/dist/esm/client.js +1649 -0
- package/dist/esm/device.js +130 -0
- package/dist/esm/errors.js +59 -0
- package/dist/esm/iap.js +101 -0
- package/dist/esm/implicit.js +63 -0
- package/dist/esm/index.js +29 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/storage.js +78 -0
- package/dist/esm/types.js +4 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
- package/dist/esm/ui/RevnixPaywall.js +890 -0
- package/dist/esm/ui/index.js +6 -0
- package/dist/esm/version.js +4 -0
- package/dist/iap.d.ts +20 -0
- package/dist/iap.js +104 -0
- package/dist/implicit.d.ts +69 -0
- package/dist/implicit.js +67 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +50 -0
- package/dist/storage.d.ts +14 -0
- package/dist/storage.js +85 -0
- package/dist/types.d.ts +463 -0
- package/dist/types.js +5 -0
- package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
- package/dist/ui/RevnixBlockPaywall.js +742 -0
- package/dist/ui/RevnixPaywall.d.ts +148 -0
- package/dist/ui/RevnixPaywall.js +927 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +25 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/package.json +79 -0
- package/src/blocks/background.ts +232 -0
- package/src/blocks/color.ts +352 -0
- package/src/blocks/locale.ts +271 -0
- package/src/blocks/paywall-background-wire.json +12 -0
- package/src/blocks/paywall-selection-wire.json +149 -0
- package/src/blocks/selection.ts +161 -0
- package/src/blocks/style.ts +411 -0
- package/src/blocks/tags.ts +125 -0
- package/src/blocks/types.ts +352 -0
- package/src/client.ts +1993 -0
- package/src/device.ts +146 -0
- package/src/errors.ts +69 -0
- package/src/iap.ts +137 -0
- package/src/implicit.ts +129 -0
- package/src/index.ts +82 -0
- package/src/storage.ts +92 -0
- package/src/types.ts +528 -0
- package/src/ui/RevnixBlockPaywall.tsx +1423 -0
- package/src/ui/RevnixPaywall.tsx +1542 -0
- package/src/ui/index.ts +43 -0
- package/src/version.ts +4 -0
- package/ui.d.ts +1 -0
- 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
|
+
}
|