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,411 @@
|
|
|
1
|
+
// BlockStyle → React Native styles.
|
|
2
|
+
//
|
|
3
|
+
// The dashboard renderer maps a BlockStyle onto CSS; this maps the same style
|
|
4
|
+
// onto RN's subset. Where RN has no equivalent the property is DROPPED rather
|
|
5
|
+
// than approximated badly, and the omissions are documented on BlockStyle so
|
|
6
|
+
// a designer can see what will not survive the trip:
|
|
7
|
+
//
|
|
8
|
+
// backdrop blur RN has no backdrop-filter. The translucent `fill` a glass
|
|
9
|
+
// card already carries reads as frosted on its own.
|
|
10
|
+
// filter no equivalent; ignored.
|
|
11
|
+
// textWrap no equivalent (RN breaks lines its own way); ignored.
|
|
12
|
+
// fillSize RN paints `fill` as a flat colour, so a tiled wash has
|
|
13
|
+
// no size to honour; ignored.
|
|
14
|
+
// clipPath no equivalent; the shape renders as its uncut box.
|
|
15
|
+
// % translate RN transforms are points, not percentages. A percentage
|
|
16
|
+
// translate is ignored (a px one is applied), so a badge
|
|
17
|
+
// centred by `left:50%` + `translate:"-50% 0"` sits a half
|
|
18
|
+
// width to the right of where the web shows it.
|
|
19
|
+
//
|
|
20
|
+
// Two CSS units need converting rather than copying: `letterSpacing` is em in
|
|
21
|
+
// the document and points in RN, and `lineHeight` is a unitless multiplier in
|
|
22
|
+
// the document and points in RN. Both are resolved against the block's own
|
|
23
|
+
// font size, falling back to the inherited size the caller passes in.
|
|
24
|
+
|
|
25
|
+
import type { TextStyle, ViewStyle } from "react-native";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
flatColor,
|
|
29
|
+
gradientFallbackColor,
|
|
30
|
+
isGradient,
|
|
31
|
+
parseGradient,
|
|
32
|
+
resolveColor,
|
|
33
|
+
withAlpha,
|
|
34
|
+
} from "./color.js";
|
|
35
|
+
import type { BlockStyle, PaywallBlockDoc } from "./types.js";
|
|
36
|
+
|
|
37
|
+
const JUSTIFY = {
|
|
38
|
+
start: "flex-start",
|
|
39
|
+
center: "center",
|
|
40
|
+
end: "flex-end",
|
|
41
|
+
between: "space-between",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const ITEMS = {
|
|
45
|
+
start: "flex-start",
|
|
46
|
+
center: "center",
|
|
47
|
+
end: "flex-end",
|
|
48
|
+
baseline: "baseline",
|
|
49
|
+
stretch: "stretch",
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a design font maps onto in the host app.
|
|
54
|
+
*
|
|
55
|
+
* On the web one family name covers every weight and the browser picks the
|
|
56
|
+
* face. React Native has no such indirection: each weight is a separately
|
|
57
|
+
* registered family (`PlusJakartaSans_700Bold`). So a mapping is either one
|
|
58
|
+
* family for everything, or a weight → family table, from which the nearest
|
|
59
|
+
* registered weight is chosen.
|
|
60
|
+
*/
|
|
61
|
+
export type FontMapping = string | Record<number, string> | undefined;
|
|
62
|
+
|
|
63
|
+
export type FontFamilyMap = Record<string, FontMapping>;
|
|
64
|
+
|
|
65
|
+
export interface StyleContext {
|
|
66
|
+
doc: PaywallBlockDoc;
|
|
67
|
+
/** Whether the renderer will paint gradient layers over this style's
|
|
68
|
+
* background — true when the host passed a `gradient` component.
|
|
69
|
+
*
|
|
70
|
+
* It decides whether a gradient `fill` degrades to its ground stop or is
|
|
71
|
+
* left transparent for the real layers. Painting the stop UNDER a gradient
|
|
72
|
+
* that will actually be drawn makes the box opaque, and 83 of the library's
|
|
73
|
+
* 139 gradient fills fade through a translucent stop — they sit over the
|
|
74
|
+
* screen's photo precisely so it shows through. */
|
|
75
|
+
paintsGradients?: boolean;
|
|
76
|
+
/** Where a paint string this build cannot read is reported. Local only —
|
|
77
|
+
* the style still resolves, to a colour from the design rather than to
|
|
78
|
+
* black, so this is the only way a host learns it is approximating. */
|
|
79
|
+
onDiagnostic?: (message: string) => void;
|
|
80
|
+
/** Font size inherited from an ancestor, for em/multiplier conversions. */
|
|
81
|
+
fontSize: number;
|
|
82
|
+
/** Maps a design font family onto one the host app has registered. A name
|
|
83
|
+
* that is absent, or maps to undefined, renders in the system font. */
|
|
84
|
+
fontFamilyMap?: FontFamilyMap;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The browser's default, which is what the dashboard preview inherits — a
|
|
88
|
+
* block that sets no size of its own must come out the same size in both. */
|
|
89
|
+
export const DEFAULT_FONT_SIZE = 16;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Typography a container passes down to its subtree.
|
|
93
|
+
*
|
|
94
|
+
* CSS inherits `color`, `font-*`, `letter-spacing`, `line-height`,
|
|
95
|
+
* `text-align` and `text-transform` — a card that sets `textColor` restyles
|
|
96
|
+
* every descendant's text without touching them. React Native inherits none
|
|
97
|
+
* of that through Views, so the tree has to carry it explicitly or a design
|
|
98
|
+
* renders in the wrong color and size the moment a container sets the style
|
|
99
|
+
* instead of each leaf.
|
|
100
|
+
*
|
|
101
|
+
* `lineHeight` rides along as the CSS multiplier rather than points, because
|
|
102
|
+
* an unitless line-height is resolved against EACH element's own font size.
|
|
103
|
+
*/
|
|
104
|
+
export interface InheritedText {
|
|
105
|
+
color: string;
|
|
106
|
+
fontSize: number;
|
|
107
|
+
/** The DESIGN's family name, resolved to a registered one only once the
|
|
108
|
+
* weight in force is known — RN selects a face by family name, so family
|
|
109
|
+
* and weight cannot be decided independently. */
|
|
110
|
+
fontFamilyName?: string;
|
|
111
|
+
fontWeight?: TextStyle["fontWeight"];
|
|
112
|
+
fontStyle?: TextStyle["fontStyle"];
|
|
113
|
+
textAlign?: TextStyle["textAlign"];
|
|
114
|
+
textTransform?: TextStyle["textTransform"];
|
|
115
|
+
/** em, like the document; resolved per element. */
|
|
116
|
+
letterSpacing?: number;
|
|
117
|
+
/** Unitless multiplier, like the document; resolved per element. */
|
|
118
|
+
lineHeight?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The inheritable half of a block's style, layered over what it inherited. */
|
|
122
|
+
export function inherit(
|
|
123
|
+
base: InheritedText,
|
|
124
|
+
s: BlockStyle | undefined,
|
|
125
|
+
ctx: StyleContext,
|
|
126
|
+
): InheritedText {
|
|
127
|
+
if (!s) return base;
|
|
128
|
+
const next: InheritedText = { ...base };
|
|
129
|
+
const color = resolveColor(s.textColor, ctx.doc);
|
|
130
|
+
if (color !== undefined) next.color = color;
|
|
131
|
+
if (s.fontSize !== undefined) next.fontSize = s.fontSize;
|
|
132
|
+
if (s.fontFamily !== undefined) next.fontFamilyName = s.fontFamily;
|
|
133
|
+
if (s.fontWeight !== undefined) {
|
|
134
|
+
next.fontWeight = String(s.fontWeight) as TextStyle["fontWeight"];
|
|
135
|
+
}
|
|
136
|
+
if (s.fontStyle !== undefined) next.fontStyle = s.fontStyle;
|
|
137
|
+
if (s.align !== undefined) next.textAlign = s.align;
|
|
138
|
+
if (s.textTransform !== undefined) next.textTransform = s.textTransform;
|
|
139
|
+
if (s.letterSpacing !== undefined) next.letterSpacing = s.letterSpacing;
|
|
140
|
+
if (s.lineHeight !== undefined) next.lineHeight = s.lineHeight;
|
|
141
|
+
return next;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Inherited typography as an RN style, with the em/multiplier units resolved
|
|
145
|
+
* against the size that actually applies here, and the family resolved
|
|
146
|
+
* against the weight in force. */
|
|
147
|
+
export function inheritedTextStyle(
|
|
148
|
+
t: InheritedText,
|
|
149
|
+
ctx: StyleContext,
|
|
150
|
+
): TextStyle {
|
|
151
|
+
const out: TextStyle = { color: t.color, fontSize: t.fontSize };
|
|
152
|
+
const family = resolveFontFamily(t.fontFamilyName, ctx, t.fontWeight);
|
|
153
|
+
if (family !== undefined) out.fontFamily = family;
|
|
154
|
+
if (t.fontWeight !== undefined) out.fontWeight = t.fontWeight;
|
|
155
|
+
if (t.fontStyle !== undefined) out.fontStyle = t.fontStyle;
|
|
156
|
+
if (t.textAlign !== undefined) out.textAlign = t.textAlign;
|
|
157
|
+
if (t.textTransform !== undefined) out.textTransform = t.textTransform;
|
|
158
|
+
if (t.letterSpacing !== undefined) {
|
|
159
|
+
out.letterSpacing = t.letterSpacing * t.fontSize;
|
|
160
|
+
}
|
|
161
|
+
if (t.lineHeight !== undefined) out.lineHeight = t.lineHeight * t.fontSize;
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Border shorthand ("1.5px solid @text/12") → width + color. */
|
|
166
|
+
function parseBorder(
|
|
167
|
+
value: string,
|
|
168
|
+
doc: PaywallBlockDoc,
|
|
169
|
+
onDiagnostic?: (message: string) => void,
|
|
170
|
+
): { width: number; color?: string } {
|
|
171
|
+
const width = value.match(/(-?[\d.]+)px/);
|
|
172
|
+
// The color is whatever remains once the width and the style keyword go.
|
|
173
|
+
const color = value
|
|
174
|
+
.replace(/(-?[\d.]+)px/, "")
|
|
175
|
+
.replace(/\b(solid|dashed|dotted|none|hidden)\b/i, "")
|
|
176
|
+
.trim();
|
|
177
|
+
return {
|
|
178
|
+
width: width ? parseFloat(width[1]!) : 1,
|
|
179
|
+
color: color ? flatColor(color, doc, onDiagnostic) : undefined,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseAspectRatio(value: number | string): number | undefined {
|
|
184
|
+
if (typeof value === "number") return value;
|
|
185
|
+
const ratio = value.match(/^\s*([\d.]+)\s*\/\s*([\d.]+)\s*$/);
|
|
186
|
+
if (ratio) {
|
|
187
|
+
const h = parseFloat(ratio[2]!);
|
|
188
|
+
return h ? parseFloat(ratio[1]!) / h : undefined;
|
|
189
|
+
}
|
|
190
|
+
const n = parseFloat(value);
|
|
191
|
+
return Number.isNaN(n) ? undefined : n;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A rough RN shadow for a CSS box-shadow. Only the presence and color of a
|
|
195
|
+
* shadow survive — RN cannot express layered glows, so one soft shadow is
|
|
196
|
+
* drawn rather than none (a card designed to lift should still lift). */
|
|
197
|
+
function parseShadow(
|
|
198
|
+
value: string,
|
|
199
|
+
doc: PaywallBlockDoc,
|
|
200
|
+
onDiagnostic?: (message: string) => void,
|
|
201
|
+
): ViewStyle {
|
|
202
|
+
const lengths = [...value.matchAll(/(-?[\d.]+)px/g)].map((m) =>
|
|
203
|
+
parseFloat(m[1]!),
|
|
204
|
+
);
|
|
205
|
+
const [offsetX = 0, offsetY = 0, blur = 0] = lengths;
|
|
206
|
+
const colorMatch = value.match(/(rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}|@[\w/]+)/);
|
|
207
|
+
const color = colorMatch ? flatColor(colorMatch[1]!, doc, onDiagnostic) : undefined;
|
|
208
|
+
return {
|
|
209
|
+
shadowColor: color ?? "#000000",
|
|
210
|
+
shadowOffset: { width: offsetX, height: offsetY },
|
|
211
|
+
shadowOpacity: color ? 1 : 0.25,
|
|
212
|
+
shadowRadius: blur / 2,
|
|
213
|
+
// Android has no per-shadow control; elevation is the only lever.
|
|
214
|
+
elevation: Math.round(Math.max(0, blur) / 4),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The layout/paint half of a BlockStyle — everything a <View> understands. */
|
|
219
|
+
export function toViewStyle(
|
|
220
|
+
s: BlockStyle | undefined,
|
|
221
|
+
ctx: StyleContext,
|
|
222
|
+
): ViewStyle {
|
|
223
|
+
if (!s) return {};
|
|
224
|
+
const { doc } = ctx;
|
|
225
|
+
const out: ViewStyle = {};
|
|
226
|
+
if (s.fill !== undefined) {
|
|
227
|
+
// A gradient fill reaches RN as an unparseable string and paints nothing,
|
|
228
|
+
// so it degrades to its ground color here — UNLESS the renderer is about to
|
|
229
|
+
// paint the real layers over this box, in which case the ground color would
|
|
230
|
+
// sit under them as an opaque backing and flatten every translucent stop.
|
|
231
|
+
const fill = resolveColor(s.fill, doc);
|
|
232
|
+
if (fill && isGradient(fill)) {
|
|
233
|
+
out.backgroundColor =
|
|
234
|
+
ctx.paintsGradients && parseGradient(fill).length > 0
|
|
235
|
+
? "transparent"
|
|
236
|
+
: gradientFallbackColor(fill);
|
|
237
|
+
} else {
|
|
238
|
+
out.backgroundColor = fill;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (s.opacity !== undefined) out.opacity = s.opacity / 100;
|
|
242
|
+
if (s.borderColor !== undefined || s.borderWidth !== undefined) {
|
|
243
|
+
out.borderWidth = s.borderWidth ?? 1;
|
|
244
|
+
// A gradient here reached RN as a color string it cannot parse; it is
|
|
245
|
+
// collapsed to the color it stands for so the stroke still draws.
|
|
246
|
+
out.borderColor = flatColor(s.borderColor, doc, ctx.onDiagnostic) ?? doc.textColor;
|
|
247
|
+
}
|
|
248
|
+
for (const [prop, side] of [
|
|
249
|
+
["borderTop", "Top"],
|
|
250
|
+
["borderRight", "Right"],
|
|
251
|
+
["borderBottom", "Bottom"],
|
|
252
|
+
["borderLeft", "Left"],
|
|
253
|
+
] as const) {
|
|
254
|
+
const raw = s[prop];
|
|
255
|
+
if (raw === undefined) continue;
|
|
256
|
+
const { width, color } = parseBorder(raw, doc, ctx.onDiagnostic);
|
|
257
|
+
(out as Record<string, unknown>)[`border${side}Width`] = width;
|
|
258
|
+
if (color) (out as Record<string, unknown>)[`border${side}Color`] = color;
|
|
259
|
+
}
|
|
260
|
+
if (s.radius !== undefined) out.borderRadius = s.radius;
|
|
261
|
+
if (s.padding !== undefined) out.padding = s.padding;
|
|
262
|
+
if (s.paddingX !== undefined) out.paddingHorizontal = s.paddingX;
|
|
263
|
+
if (s.paddingY !== undefined) out.paddingVertical = s.paddingY;
|
|
264
|
+
if (s.paddingTop !== undefined) out.paddingTop = s.paddingTop;
|
|
265
|
+
if (s.paddingRight !== undefined) out.paddingRight = s.paddingRight;
|
|
266
|
+
if (s.paddingBottom !== undefined) out.paddingBottom = s.paddingBottom;
|
|
267
|
+
if (s.paddingLeft !== undefined) out.paddingLeft = s.paddingLeft;
|
|
268
|
+
if (s.margin !== undefined) out.margin = s.margin;
|
|
269
|
+
if (s.marginTop !== undefined) out.marginTop = s.marginTop;
|
|
270
|
+
if (s.marginRight !== undefined) out.marginRight = s.marginRight;
|
|
271
|
+
if (s.marginBottom !== undefined) out.marginBottom = s.marginBottom;
|
|
272
|
+
if (s.marginLeft !== undefined) out.marginLeft = s.marginLeft;
|
|
273
|
+
if (s.gap !== undefined) out.gap = s.gap;
|
|
274
|
+
if (s.height !== undefined) out.height = s.height as ViewStyle["height"];
|
|
275
|
+
if (s.minHeight !== undefined) out.minHeight = s.minHeight;
|
|
276
|
+
if (s.width !== undefined) out.width = s.width as ViewStyle["width"];
|
|
277
|
+
if (s.maxWidth !== undefined) out.maxWidth = s.maxWidth as ViewStyle["maxWidth"];
|
|
278
|
+
if (s.aspectRatio !== undefined) {
|
|
279
|
+
const ratio = parseAspectRatio(s.aspectRatio);
|
|
280
|
+
if (ratio !== undefined) out.aspectRatio = ratio;
|
|
281
|
+
}
|
|
282
|
+
if (s.flex !== undefined) out.flexGrow = s.flex;
|
|
283
|
+
if (s.shrink !== undefined) out.flexShrink = s.shrink;
|
|
284
|
+
if (s.basis !== undefined) out.flexBasis = s.basis;
|
|
285
|
+
if (s.wrap) out.flexWrap = "wrap";
|
|
286
|
+
if (s.justify !== undefined) out.justifyContent = JUSTIFY[s.justify];
|
|
287
|
+
if (s.items !== undefined) out.alignItems = ITEMS[s.items];
|
|
288
|
+
if (s.selfAlign !== undefined) out.alignSelf = ITEMS[s.selfAlign];
|
|
289
|
+
if (s.shadow !== undefined) Object.assign(out, parseShadow(s.shadow, doc, ctx.onDiagnostic));
|
|
290
|
+
// RN types the prop as a readonly array OR a raw CSS string, so the element
|
|
291
|
+
// type is taken from the array arm and built up mutably before assignment.
|
|
292
|
+
type Transform = Exclude<
|
|
293
|
+
NonNullable<ViewStyle["transform"]>,
|
|
294
|
+
string
|
|
295
|
+
>[number];
|
|
296
|
+
const transform: Transform[] = [];
|
|
297
|
+
if (s.rotate !== undefined) transform.push({ rotate: `${s.rotate}deg` });
|
|
298
|
+
if (s.translate !== undefined) {
|
|
299
|
+
// Percentages have no RN equivalent and are dropped rather than guessed
|
|
300
|
+
// at; a px pair translates directly.
|
|
301
|
+
const [x, y] = s.translate.trim().split(/\s+/);
|
|
302
|
+
const px = (v: string | undefined) => {
|
|
303
|
+
if (!v) return 0;
|
|
304
|
+
const n = Number(v.endsWith("px") ? v.slice(0, -2) : v);
|
|
305
|
+
return Number.isFinite(n) ? n : 0;
|
|
306
|
+
};
|
|
307
|
+
// A zero component is a no-op; leaving it out keeps the list to the
|
|
308
|
+
// moves the design actually makes.
|
|
309
|
+
if (x !== undefined && !x.endsWith("%") && px(x) !== 0) {
|
|
310
|
+
transform.push({ translateX: px(x) });
|
|
311
|
+
}
|
|
312
|
+
if (y !== undefined && !y.endsWith("%") && px(y) !== 0) {
|
|
313
|
+
transform.push({ translateY: px(y) });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (transform.length) out.transform = transform;
|
|
317
|
+
if (s.zIndex !== undefined) out.zIndex = s.zIndex;
|
|
318
|
+
if (s.overflow !== undefined) out.overflow = s.overflow;
|
|
319
|
+
// Stack placement: `inset` fills the parent, individual offsets pin edges.
|
|
320
|
+
// Both imply absolute positioning, which is inert outside a stack parent.
|
|
321
|
+
if (s.inset) {
|
|
322
|
+
out.position = "absolute";
|
|
323
|
+
out.top = 0;
|
|
324
|
+
out.right = 0;
|
|
325
|
+
out.bottom = 0;
|
|
326
|
+
out.left = 0;
|
|
327
|
+
} else if (
|
|
328
|
+
s.top !== undefined ||
|
|
329
|
+
s.right !== undefined ||
|
|
330
|
+
s.bottom !== undefined ||
|
|
331
|
+
s.left !== undefined
|
|
332
|
+
) {
|
|
333
|
+
out.position = "absolute";
|
|
334
|
+
if (s.top !== undefined) out.top = s.top as ViewStyle["top"];
|
|
335
|
+
if (s.right !== undefined) out.right = s.right as ViewStyle["right"];
|
|
336
|
+
if (s.bottom !== undefined) out.bottom = s.bottom as ViewStyle["bottom"];
|
|
337
|
+
if (s.left !== undefined) out.left = s.left as ViewStyle["left"];
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The typography half — everything a <Text> understands. */
|
|
343
|
+
export function toTextStyle(
|
|
344
|
+
s: BlockStyle | undefined,
|
|
345
|
+
ctx: StyleContext,
|
|
346
|
+
): TextStyle {
|
|
347
|
+
if (!s) return {};
|
|
348
|
+
const { doc } = ctx;
|
|
349
|
+
const out: TextStyle = {};
|
|
350
|
+
const fontSize = s.fontSize ?? ctx.fontSize;
|
|
351
|
+
if (s.textColor !== undefined) out.color = flatColor(s.textColor, doc, ctx.onDiagnostic);
|
|
352
|
+
if (s.fontSize !== undefined) out.fontSize = s.fontSize;
|
|
353
|
+
if (s.fontWeight !== undefined)
|
|
354
|
+
out.fontWeight = String(s.fontWeight) as TextStyle["fontWeight"];
|
|
355
|
+
if (s.fontFamily !== undefined) {
|
|
356
|
+
const family = resolveFontFamily(s.fontFamily, ctx);
|
|
357
|
+
if (family) out.fontFamily = family;
|
|
358
|
+
}
|
|
359
|
+
if (s.fontStyle !== undefined) out.fontStyle = s.fontStyle;
|
|
360
|
+
if (s.align !== undefined) out.textAlign = s.align;
|
|
361
|
+
// em → points, against this block's own size.
|
|
362
|
+
if (s.letterSpacing !== undefined)
|
|
363
|
+
out.letterSpacing = s.letterSpacing * fontSize;
|
|
364
|
+
// Unitless multiplier → points.
|
|
365
|
+
if (s.lineHeight !== undefined) out.lineHeight = s.lineHeight * fontSize;
|
|
366
|
+
if (s.textTransform !== undefined) out.textTransform = s.textTransform;
|
|
367
|
+
if (s.decoration !== undefined && s.decoration !== "none")
|
|
368
|
+
out.textDecorationLine = s.decoration;
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A design font renders only when the host app has registered it — RN cannot
|
|
374
|
+
* load a family by name at runtime. `fontFamilyMap` lets the app point a
|
|
375
|
+
* design's family at whatever it bundled (and drop the ones it did not), so an
|
|
376
|
+
* unregistered face falls back to the system font instead of, on Android,
|
|
377
|
+
* failing to render.
|
|
378
|
+
*
|
|
379
|
+
* The weight matters because RN picks a face by family NAME, not by
|
|
380
|
+
* `fontWeight`: asking for weight 800 of a family registered only at 400 gets
|
|
381
|
+
* a synthesised bold at best. Given a weight table, the nearest registered
|
|
382
|
+
* weight wins.
|
|
383
|
+
*/
|
|
384
|
+
export function resolveFontFamily(
|
|
385
|
+
family: string | undefined,
|
|
386
|
+
ctx: StyleContext,
|
|
387
|
+
weight?: TextStyle["fontWeight"],
|
|
388
|
+
): string | undefined {
|
|
389
|
+
if (!family) return undefined;
|
|
390
|
+
const mapping = ctx.fontFamilyMap?.[family];
|
|
391
|
+
if (mapping === undefined) return undefined;
|
|
392
|
+
if (typeof mapping === "string") return mapping;
|
|
393
|
+
const want = Number(weight ?? 400);
|
|
394
|
+
const weights = Object.keys(mapping)
|
|
395
|
+
.map(Number)
|
|
396
|
+
.filter((w) => Number.isFinite(w))
|
|
397
|
+
.sort((a, b) => a - b);
|
|
398
|
+
if (weights.length === 0) return undefined;
|
|
399
|
+
const target = Number.isFinite(want) ? want : 400;
|
|
400
|
+
const nearest = weights.reduce((best, w) =>
|
|
401
|
+
Math.abs(w - target) < Math.abs(best - target) ? w : best,
|
|
402
|
+
);
|
|
403
|
+
return mapping[nearest];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A `line` block is drawn from the current color at low alpha — the web
|
|
407
|
+
* renderer uses `currentColor` with opacity, which RN has no notion of, so
|
|
408
|
+
* the caller passes the color that is in force at that point in the tree. */
|
|
409
|
+
export function hairlineColor(currentColor: string): string {
|
|
410
|
+
return withAlpha(currentColor, 0.16);
|
|
411
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Tag variables — the vocabulary a designed paywall's copy uses to talk about
|
|
2
|
+
// the packages attached to it.
|
|
3
|
+
//
|
|
4
|
+
// A designed paywall must never hardcode a price: the merchant's price differs
|
|
5
|
+
// from the design's sample, and the store charges a different amount (and
|
|
6
|
+
// currency) in every territory. So any text or button in a block tree can
|
|
7
|
+
// write `{price}` and have the live package fill it in.
|
|
8
|
+
//
|
|
9
|
+
// Mirrors revnix-app's src/lib/paywall-blocks/tags.ts. Tags that cannot be
|
|
10
|
+
// answered from real package data are left in place, visible, rather than
|
|
11
|
+
// approximated — a wrong price must never reach a customer silently.
|
|
12
|
+
|
|
13
|
+
import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
|
|
14
|
+
|
|
15
|
+
/** Renewal cycles, in months. Lifetime and one-time products have no cycle. */
|
|
16
|
+
const MONTHS: Record<string, number | undefined> = {
|
|
17
|
+
weekly: 1 / 4.345,
|
|
18
|
+
monthly: 1,
|
|
19
|
+
two_months: 2,
|
|
20
|
+
three_months: 3,
|
|
21
|
+
six_months: 6,
|
|
22
|
+
annual: 12,
|
|
23
|
+
lifetime: undefined,
|
|
24
|
+
non_subscription: undefined,
|
|
25
|
+
consumable: undefined,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PERIOD_WORD: Record<string, string> = {
|
|
29
|
+
weekly: "week",
|
|
30
|
+
monthly: "month",
|
|
31
|
+
two_months: "2 months",
|
|
32
|
+
three_months: "3 months",
|
|
33
|
+
six_months: "6 months",
|
|
34
|
+
annual: "year",
|
|
35
|
+
lifetime: "lifetime",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const PERIOD_SHORT: Record<string, string> = {
|
|
39
|
+
weekly: "wk",
|
|
40
|
+
monthly: "mo",
|
|
41
|
+
two_months: "2mo",
|
|
42
|
+
three_months: "3mo",
|
|
43
|
+
six_months: "6mo",
|
|
44
|
+
annual: "yr",
|
|
45
|
+
lifetime: "once",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Minor units per major unit. Not every currency is a hundredth: JPY and KRW
|
|
49
|
+
* have no minor unit at all, so dividing by 100 would understate a price by
|
|
50
|
+
* 100×. Intl knows each currency's exponent — ask it rather than assume.
|
|
51
|
+
*
|
|
52
|
+
* Exported because a host app usually holds the store's price in MAJOR units
|
|
53
|
+
* and has to convert before filling in `RevnixPaywallPackage.amountMinor`;
|
|
54
|
+
* hardcoding 100 there would reintroduce the same bug one layer up. */
|
|
55
|
+
export function minorUnitsForCurrency(currency: string): number {
|
|
56
|
+
try {
|
|
57
|
+
const digits = new Intl.NumberFormat(undefined, {
|
|
58
|
+
style: "currency",
|
|
59
|
+
currency,
|
|
60
|
+
}).resolvedOptions().maximumFractionDigits;
|
|
61
|
+
return 10 ** (digits ?? 2);
|
|
62
|
+
} catch {
|
|
63
|
+
return 100;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function money(amountMinor: number, currency: string): string {
|
|
68
|
+
const per = minorUnitsForCurrency(currency);
|
|
69
|
+
const major = amountMinor / per;
|
|
70
|
+
try {
|
|
71
|
+
return new Intl.NumberFormat(undefined, {
|
|
72
|
+
style: "currency",
|
|
73
|
+
currency,
|
|
74
|
+
// A whole amount reads better without ".00"; a fractional one must keep
|
|
75
|
+
// its digits.
|
|
76
|
+
maximumFractionDigits: amountMinor % per === 0 ? 0 : undefined,
|
|
77
|
+
}).format(major);
|
|
78
|
+
} catch {
|
|
79
|
+
// Hermes without full ICU, or an unknown currency code.
|
|
80
|
+
return `${major.toFixed(per === 1 ? 0 : 2)} ${currency}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function perMonthMinor(pkg: RevnixPaywallPackage): number | undefined {
|
|
85
|
+
const months = pkg.period ? MONTHS[pkg.period] : undefined;
|
|
86
|
+
if (!months || pkg.amountMinor === undefined) return undefined;
|
|
87
|
+
return pkg.amountMinor / months;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fills a copy template from one package. `all` is the rest of the offering,
|
|
91
|
+
* which {save_percent} needs to have something to compare against. */
|
|
92
|
+
export function resolveTags(
|
|
93
|
+
text: string,
|
|
94
|
+
pkg: RevnixPaywallPackage | undefined,
|
|
95
|
+
all: RevnixPaywallPackage[] = [],
|
|
96
|
+
): string {
|
|
97
|
+
if (!pkg || !text.includes("{")) return text;
|
|
98
|
+
return text.replace(/\{(\w+)\}/g, (raw, name: string) => {
|
|
99
|
+
switch (name) {
|
|
100
|
+
case "title":
|
|
101
|
+
return pkg.title;
|
|
102
|
+
case "price":
|
|
103
|
+
return pkg.priceLabel;
|
|
104
|
+
case "period":
|
|
105
|
+
return pkg.period ? (PERIOD_WORD[pkg.period] ?? raw) : raw;
|
|
106
|
+
case "period_short":
|
|
107
|
+
return pkg.period ? (PERIOD_SHORT[pkg.period] ?? raw) : raw;
|
|
108
|
+
case "price_per_month": {
|
|
109
|
+
const per = perMonthMinor(pkg);
|
|
110
|
+
return per !== undefined && pkg.currency
|
|
111
|
+
? money(Math.round(per), pkg.currency)
|
|
112
|
+
: raw;
|
|
113
|
+
}
|
|
114
|
+
case "save_percent": {
|
|
115
|
+
const mine = perMonthMinor(pkg);
|
|
116
|
+
const dearest = Math.max(...all.map((p) => perMonthMinor(p) ?? 0), 0);
|
|
117
|
+
if (!mine || !dearest || dearest <= mine) return raw;
|
|
118
|
+
return `${Math.round((1 - mine / dearest) * 100)}%`;
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
// Unknown tag: leave it visible rather than guess.
|
|
122
|
+
return raw;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|