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