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,42 @@
1
+ import type { PaywallBlockDoc } from "./types.js";
2
+ /** BCP-47, hyphenated: "es_MX" and "es-mx" both normalize to "es-MX". Applied
3
+ * to authored tags and to the device's reported locale alike, so the two can
4
+ * never miss each other over punctuation or case. */
5
+ export declare function normalizeLocale(tag: string | undefined): string | undefined;
6
+ /** Which tables to consult, most specific first.
7
+ *
8
+ * "es-MX" on a paywall translated into "es" reads the "es" table: a regional
9
+ * variant that was never authored falls back to its language rather than to
10
+ * the source, which is the difference between a Mexican user reading Spanish
11
+ * and reading English. The authored tree is the last resort and is NOT in
12
+ * this chain — the lookup falls through to it. */
13
+ export declare function localeChain(available: readonly string[], locale: string | undefined, defaultLocale: string | undefined): string[];
14
+ /** Forces the paywall language regardless of the device's, for a host whose
15
+ * in-app language picker differs from the OS locale. `undefined` clears it.
16
+ * Only paywalls MOUNTED AFTER this call pick it up — `detectLocale()` is
17
+ * read once per doc/locale in a `useMemo`. */
18
+ export declare function setLocale(tag?: string): void;
19
+ /** The device's language, for hosts that do not pass one. Hermes without the
20
+ * full ICU build has no `Intl`, and a React Native `navigator.language` is
21
+ * the JS engine's, not the device's — so this is best effort and a host that
22
+ * cares passes `locale` explicitly. */
23
+ export declare function detectLocale(): string | undefined;
24
+ /** Returns the document with every string swapped for `locale`'s.
25
+ *
26
+ * Applied ONCE before rendering rather than at each text node: the renderer
27
+ * then needs no localization awareness at all, and the six SDKs cannot drift
28
+ * on which fields are translatable. Returns the input untouched when nothing
29
+ * applies, so an untranslated paywall allocates nothing.
30
+ *
31
+ * `{price}` and the other copy tags survive, because they are resolved AFTER
32
+ * this on the localized string — "Solo {price} al mes" works. */
33
+ export declare function localizeBlockDoc(doc: PaywallBlockDoc, locale: string | undefined): PaywallBlockDoc;
34
+ /** The three built-in paywall footer labels (restore/terms/privacy), in the
35
+ * language `locale` resolves to. Falls back to English for any language not
36
+ * in the 43-entry table. Mandarin picks traditional script for Taiwan, Hong
37
+ * Kong and Macau unless the tag is explicitly simplified. */
38
+ export declare function linkLabels(locale: string | undefined): {
39
+ restore: string;
40
+ terms: string;
41
+ privacy: string;
42
+ };
@@ -0,0 +1,263 @@
1
+ "use strict";
2
+ // REV-271: paywall localization, SDK half. A designed paywall carries ONE
3
+ // tree plus a side table of translated strings — never one tree per language
4
+ // — so styles, layout and block ids are shared and only words differ.
5
+ //
6
+ // Selection happens HERE, at render, rather than server-side at resolve. The
7
+ // resolution is cached on the device (REV-157), so a locale chosen by the
8
+ // server would pin a cached paywall to whatever language it was fetched in:
9
+ // change the phone's language and the old copy would keep rendering until the
10
+ // cache expired, and offline it would never change at all. Choosing on the
11
+ // device also keeps the server's fleet-wide placement cache one entry per
12
+ // placement instead of one per language.
13
+ //
14
+ // Mirrored from the dashboard's src/lib/paywall-blocks/locale.ts and into the
15
+ // five native SDKs. The key scheme, the fallback chain and the field list
16
+ // must stay identical on every side — a paywall translated in the dashboard
17
+ // has to resolve the same way on all six.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.normalizeLocale = normalizeLocale;
20
+ exports.localeChain = localeChain;
21
+ exports.setLocale = setLocale;
22
+ exports.detectLocale = detectLocale;
23
+ exports.localizeBlockDoc = localizeBlockDoc;
24
+ exports.linkLabels = linkLabels;
25
+ /** BCP-47, hyphenated: "es_MX" and "es-mx" both normalize to "es-MX". Applied
26
+ * to authored tags and to the device's reported locale alike, so the two can
27
+ * never miss each other over punctuation or case. */
28
+ function normalizeLocale(tag) {
29
+ if (typeof tag !== "string")
30
+ return undefined;
31
+ const parts = tag.trim().replace(/_/g, "-").split("-").filter(Boolean);
32
+ const language = parts[0]?.toLowerCase();
33
+ if (language === undefined || !/^[a-z]{2,3}$/.test(language))
34
+ return undefined;
35
+ const rest = [];
36
+ for (const part of parts.slice(1)) {
37
+ // Region subtags are uppercase ("MX", "419"), scripts title case ("Hans").
38
+ // Anything else is a variant and stays lowercase. A subtag outside
39
+ // BCP-47's shapes makes the WHOLE tag junk rather than passing through —
40
+ // otherwise a device reporting "es-!!" would look up a language.
41
+ if (/^[A-Za-z]{2}$/.test(part) || /^\d{3}$/.test(part)) {
42
+ rest.push(part.toUpperCase());
43
+ }
44
+ else if (/^[A-Za-z]{4}$/.test(part)) {
45
+ rest.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase());
46
+ }
47
+ else if (/^[A-Za-z0-9]{5,8}$/.test(part)) {
48
+ rest.push(part.toLowerCase());
49
+ }
50
+ else {
51
+ return undefined;
52
+ }
53
+ }
54
+ return [language, ...rest].join("-");
55
+ }
56
+ function baseLanguage(tag) {
57
+ return tag.split("-")[0] ?? tag;
58
+ }
59
+ /** Which tables to consult, most specific first.
60
+ *
61
+ * "es-MX" on a paywall translated into "es" reads the "es" table: a regional
62
+ * variant that was never authored falls back to its language rather than to
63
+ * the source, which is the difference between a Mexican user reading Spanish
64
+ * and reading English. The authored tree is the last resort and is NOT in
65
+ * this chain — the lookup falls through to it. */
66
+ function localeChain(available, locale, defaultLocale) {
67
+ const chain = [];
68
+ const push = (tag) => {
69
+ if (tag !== undefined && available.includes(tag) && !chain.includes(tag)) {
70
+ chain.push(tag);
71
+ }
72
+ };
73
+ const wanted = normalizeLocale(locale);
74
+ if (wanted !== undefined) {
75
+ push(wanted);
76
+ push(baseLanguage(wanted));
77
+ // "es" asked for, only "es-MX" authored: one regional table beats the
78
+ // source language, and the first sorted match keeps the choice
79
+ // deterministic across devices rather than dependent on key order.
80
+ if (chain.length === 0) {
81
+ const language = baseLanguage(wanted);
82
+ const regional = available
83
+ .filter((tag) => baseLanguage(tag) === language)
84
+ .sort();
85
+ push(regional[0]);
86
+ }
87
+ }
88
+ push(normalizeLocale(defaultLocale));
89
+ return chain;
90
+ }
91
+ let localeOverride;
92
+ /** Forces the paywall language regardless of the device's, for a host whose
93
+ * in-app language picker differs from the OS locale. `undefined` clears it.
94
+ * Only paywalls MOUNTED AFTER this call pick it up — `detectLocale()` is
95
+ * read once per doc/locale in a `useMemo`. */
96
+ function setLocale(tag) {
97
+ localeOverride = tag || undefined;
98
+ }
99
+ /** The device's language, for hosts that do not pass one. Hermes without the
100
+ * full ICU build has no `Intl`, and a React Native `navigator.language` is
101
+ * the JS engine's, not the device's — so this is best effort and a host that
102
+ * cares passes `locale` explicitly. */
103
+ function detectLocale() {
104
+ if (localeOverride !== undefined)
105
+ return localeOverride;
106
+ try {
107
+ const resolved = new Intl.DateTimeFormat().resolvedOptions().locale;
108
+ if (typeof resolved === "string" && resolved.length > 0)
109
+ return resolved;
110
+ }
111
+ catch {
112
+ // Falls through to navigator below.
113
+ }
114
+ const nav = globalThis.navigator;
115
+ return typeof nav?.language === "string" && nav.language.length > 0
116
+ ? nav.language
117
+ : undefined;
118
+ }
119
+ function localizeBlock(block, lookup) {
120
+ const at = (path, authored) => lookup(`${block.id}.${path}`, authored);
121
+ const opt = (path, authored) => authored === undefined ? undefined : at(path, authored);
122
+ switch (block.type) {
123
+ case "text":
124
+ return { ...block, text: at("text", block.text) };
125
+ case "button":
126
+ return { ...block, label: at("label", block.label) };
127
+ case "list":
128
+ return {
129
+ ...block,
130
+ items: block.items.map((item, i) => ({
131
+ ...item,
132
+ title: at(`items.${i}.title`, item.title),
133
+ description: opt(`items.${i}.description`, item.description),
134
+ })),
135
+ };
136
+ case "products":
137
+ return {
138
+ ...block,
139
+ titleTpl: opt("titleTpl", block.titleTpl),
140
+ priceTpl: opt("priceTpl", block.priceTpl),
141
+ highlightSub: opt("highlightSub", block.highlightSub),
142
+ badgeText: opt("badgeText", block.badgeText),
143
+ };
144
+ case "card":
145
+ return {
146
+ ...block,
147
+ children: block.children.map((child) => localizeBlock(child, lookup)),
148
+ };
149
+ default:
150
+ return block;
151
+ }
152
+ }
153
+ /** Returns the document with every string swapped for `locale`'s.
154
+ *
155
+ * Applied ONCE before rendering rather than at each text node: the renderer
156
+ * then needs no localization awareness at all, and the six SDKs cannot drift
157
+ * on which fields are translatable. Returns the input untouched when nothing
158
+ * applies, so an untranslated paywall allocates nothing.
159
+ *
160
+ * `{price}` and the other copy tags survive, because they are resolved AFTER
161
+ * this on the localized string — "Solo {price} al mes" works. */
162
+ function localizeBlockDoc(doc, locale) {
163
+ const tables = doc.locales;
164
+ if (tables === undefined)
165
+ return doc;
166
+ const available = Object.keys(tables);
167
+ if (available.length === 0)
168
+ return doc;
169
+ const chain = localeChain(available, locale, doc.defaultLocale);
170
+ if (chain.length === 0)
171
+ return doc;
172
+ const lookup = (key, authored) => {
173
+ for (const tag of chain) {
174
+ const value = tables[tag]?.[key];
175
+ // An empty translation means "not translated", never "render nothing":
176
+ // a blank CTA is a dead paywall, and an export/import round-trip leaves
177
+ // empty cells behind for every untouched row.
178
+ if (typeof value === "string" && value !== "")
179
+ return value;
180
+ }
181
+ return authored;
182
+ };
183
+ return {
184
+ ...doc,
185
+ defaultLocale: chain[0],
186
+ blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
187
+ };
188
+ }
189
+ const LINK_LABEL_ALIASES = {
190
+ iw: "he",
191
+ in: "id",
192
+ no: "nb",
193
+ tl: "fil",
194
+ };
195
+ const LINK_LABELS = {
196
+ ar: ["استعادة", "الشروط", "الخصوصية"],
197
+ bg: ["Възстановяване", "Условия", "Поверителност"],
198
+ bn: ["পুনরুদ্ধার", "শর্তাবলী", "গোপনীয়তা"],
199
+ ca: ["Restaura", "Condicions", "Privadesa"],
200
+ cs: ["Obnovit", "Podmínky", "Soukromí"],
201
+ da: ["Gendan", "Vilkår", "Privatliv"],
202
+ de: ["Wiederherstellen", "AGB", "Datenschutz"],
203
+ el: ["Επαναφορά", "Όροι", "Απόρρητο"],
204
+ en: ["Restore", "Terms", "Privacy"],
205
+ es: ["Restaurar", "Términos", "Privacidad"],
206
+ et: ["Taasta", "Tingimused", "Privaatsus"],
207
+ fa: ["بازیابی", "شرایط", "حریم خصوصی"],
208
+ fi: ["Palauta", "Ehdot", "Tietosuoja"],
209
+ fil: ["I-restore", "Mga Tuntunin", "Privacy"],
210
+ fr: ["Restaurer", "Conditions", "Confidentialité"],
211
+ he: ["שחזור", "תנאים", "פרטיות"],
212
+ hi: ["पुनर्स्थापित करें", "शर्तें", "गोपनीयता"],
213
+ hr: ["Vrati", "Uvjeti", "Privatnost"],
214
+ hu: ["Visszaállítás", "Feltételek", "Adatvédelem"],
215
+ id: ["Pulihkan", "Ketentuan", "Privasi"],
216
+ it: ["Ripristina", "Termini", "Privacy"],
217
+ ja: ["購入を復元", "利用規約", "プライバシー"],
218
+ ko: ["구매 복원", "이용약관", "개인정보"],
219
+ lt: ["Atkurti", "Sąlygos", "Privatumas"],
220
+ lv: ["Atjaunot", "Noteikumi", "Privātums"],
221
+ ms: ["Pulihkan", "Terma", "Privasi"],
222
+ nb: ["Gjenopprett", "Vilkår", "Personvern"],
223
+ nl: ["Herstellen", "Voorwaarden", "Privacy"],
224
+ pl: ["Przywróć", "Regulamin", "Prywatność"],
225
+ pt: ["Restaurar", "Termos", "Privacidade"],
226
+ ro: ["Restaurează", "Termeni", "Confidențialitate"],
227
+ ru: ["Восстановить", "Условия", "Конфиденциальность"],
228
+ sk: ["Obnoviť", "Podmienky", "Súkromie"],
229
+ sl: ["Obnovi", "Pogoji", "Zasebnost"],
230
+ sr: ["Врати", "Услови", "Приватност"],
231
+ sv: ["Återställ", "Villkor", "Integritet"],
232
+ th: ["กู้คืน", "ข้อกำหนด", "ความเป็นส่วนตัว"],
233
+ tr: ["Geri Yükle", "Koşullar", "Gizlilik"],
234
+ uk: ["Відновити", "Умови", "Конфіденційність"],
235
+ ur: ["بحال کریں", "شرائط", "رازداری"],
236
+ vi: ["Khôi phục", "Điều khoản", "Quyền riêng tư"],
237
+ zh: ["恢复购买", "条款", "隐私"],
238
+ "zh-Hant": ["恢復購買", "條款", "隱私"],
239
+ };
240
+ /** The three built-in paywall footer labels (restore/terms/privacy), in the
241
+ * language `locale` resolves to. Falls back to English for any language not
242
+ * in the 43-entry table. Mandarin picks traditional script for Taiwan, Hong
243
+ * Kong and Macau unless the tag is explicitly simplified. */
244
+ function linkLabels(locale) {
245
+ const [restore, terms, privacy] = LINK_LABELS[resolveLinkLabelTag(locale)];
246
+ return { restore, terms, privacy };
247
+ }
248
+ function resolveLinkLabelTag(locale) {
249
+ const tag = normalizeLocale(locale);
250
+ if (tag === undefined)
251
+ return "en";
252
+ const lang = baseLanguage(tag);
253
+ if (lang === "zh") {
254
+ const parts = tag.split("-").slice(1);
255
+ const hasHant = parts.includes("Hant");
256
+ const hasHans = parts.includes("Hans");
257
+ const region = parts.find((part) => /^[A-Z]{2}$/.test(part));
258
+ const isTraditionalRegion = region !== undefined && ["TW", "HK", "MO"].includes(region);
259
+ return hasHant || (!hasHans && isTraditionalRegion) ? "zh-Hant" : "zh";
260
+ }
261
+ const base = LINK_LABEL_ALIASES[lang] ?? lang;
262
+ return base in LINK_LABELS ? base : "en";
263
+ }
@@ -0,0 +1,49 @@
1
+ import type { BlockStyle, PaywallBlock, PaywallBlockDoc } from "./types.js";
2
+ import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
3
+ /** Where a block sits: inside the selected plan card, inside another plan
4
+ * card, or outside any plan card (undefined). */
5
+ export type SelectionContext = "selected" | "unselected" | undefined;
6
+ /** The package a designed paywall is showing as chosen: the host's choice,
7
+ * then the renderer's own after a tap, then the design's highlight, then the
8
+ * first package — each only when the offering actually carries it. */
9
+ export declare function selectedPackageId(packages: ReadonlyArray<{
10
+ packageId: string;
11
+ }>, ...candidates: Array<string | undefined>): string | undefined;
12
+ /** The context a package-bearing card opens for itself and its subtree. */
13
+ export declare function packageContext(pkg: {
14
+ packageId: string;
15
+ } | undefined, selectedId: string | undefined): SelectionContext;
16
+ /** `selectedStyle` merged over `style` while selected; `style` otherwise. */
17
+ export declare function effectiveStyle(block: PaywallBlock, context: SelectionContext): BlockStyle | undefined;
18
+ /** Whether a block is drawn. Outside any plan card every block is drawn,
19
+ * whatever `visibility` says — a root block can never disappear. */
20
+ export declare function isVisible(block: PaywallBlock, context: SelectionContext): boolean;
21
+ /** Canvas designs are authored at 393×852 and scaled by width, capped so a
22
+ * tablet or a landscape phone shows a centred phone-sized design instead of
23
+ * a blown-up one. */
24
+ export declare const CANVAS_MAX_WIDTH = 480;
25
+ export interface CanvasMetrics {
26
+ scale: number;
27
+ /** Horizontal offset of the scaled design inside the viewport. */
28
+ left: number;
29
+ /** Height of the laid-out design in DESIGN units. */
30
+ designHeight: number;
31
+ /** Height of the scaled design in viewport points. */
32
+ scaledHeight: number;
33
+ /** Whether the design is taller than the viewport and must scroll. */
34
+ overflows: boolean;
35
+ }
36
+ export declare function canvasMetrics(viewportWidth: number, viewportHeight: number | null): CanvasMetrics;
37
+ export interface ResolvedBlock {
38
+ block: PaywallBlock;
39
+ context: SelectionContext;
40
+ visible: boolean;
41
+ style: BlockStyle | undefined;
42
+ /** Resolved copy for text and button blocks. */
43
+ text?: string;
44
+ }
45
+ /** Walk a document exactly the way the renderer does — pinned cards judge
46
+ * their own context, repeat cards yield one entry per package (keyed
47
+ * `id@packageId`), hidden subtrees are not entered — and report what every
48
+ * block resolves to. The wire fixture test reads this. */
49
+ export declare function resolveBlocks(doc: PaywallBlockDoc, packages: RevnixPaywallPackage[], selectedId: string | undefined): Map<string, ResolvedBlock>;
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ // REV-262: the selection half of the designed-paywall render contract v2
3
+ // (revnix/cto-review/2026-09-03-paywall-render-contract-v2.md), kept pure so
4
+ // the wire fixture can be walked without React Native.
5
+ //
6
+ // Mirrors revnix-app's src/lib/paywall-blocks/selection.ts: the dashboard
7
+ // preview and this renderer must agree on which package is "selected", what
8
+ // a block looks like in that state and whether it is drawn at all.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.CANVAS_MAX_WIDTH = void 0;
11
+ exports.selectedPackageId = selectedPackageId;
12
+ exports.packageContext = packageContext;
13
+ exports.effectiveStyle = effectiveStyle;
14
+ exports.isVisible = isVisible;
15
+ exports.canvasMetrics = canvasMetrics;
16
+ exports.resolveBlocks = resolveBlocks;
17
+ const types_js_1 = require("./types.js");
18
+ const tags_js_1 = require("./tags.js");
19
+ /** The package a designed paywall is showing as chosen: the host's choice,
20
+ * then the renderer's own after a tap, then the design's highlight, then the
21
+ * first package — each only when the offering actually carries it. */
22
+ function selectedPackageId(packages, ...candidates) {
23
+ for (const id of candidates) {
24
+ if (id !== undefined && packages.some((p) => p.packageId === id))
25
+ return id;
26
+ }
27
+ return packages[0]?.packageId;
28
+ }
29
+ /** The context a package-bearing card opens for itself and its subtree. */
30
+ function packageContext(pkg, selectedId) {
31
+ if (pkg === undefined)
32
+ return undefined;
33
+ return pkg.packageId === selectedId ? "selected" : "unselected";
34
+ }
35
+ /** `selectedStyle` merged over `style` while selected; `style` otherwise. */
36
+ function effectiveStyle(block, context) {
37
+ if (context === "selected" && block.selectedStyle) {
38
+ return { ...block.style, ...block.selectedStyle };
39
+ }
40
+ return block.style;
41
+ }
42
+ /** Whether a block is drawn. Outside any plan card every block is drawn,
43
+ * whatever `visibility` says — a root block can never disappear. */
44
+ function isVisible(block, context) {
45
+ if (block.visibility === undefined || context === undefined)
46
+ return true;
47
+ return (block.visibility === "selected") === (context === "selected");
48
+ }
49
+ /** Canvas designs are authored at 393×852 and scaled by width, capped so a
50
+ * tablet or a landscape phone shows a centred phone-sized design instead of
51
+ * a blown-up one. */
52
+ exports.CANVAS_MAX_WIDTH = 480;
53
+ function canvasMetrics(viewportWidth, viewportHeight) {
54
+ const scale = Math.min(viewportWidth, exports.CANVAS_MAX_WIDTH) / types_js_1.DESIGN_CANVAS.width;
55
+ const left = (viewportWidth - types_js_1.DESIGN_CANVAS.width * scale) / 2;
56
+ const designHeight = viewportHeight === null
57
+ ? types_js_1.DESIGN_CANVAS.height
58
+ : Math.max(types_js_1.DESIGN_CANVAS.height, viewportHeight / scale);
59
+ const scaledHeight = designHeight * scale;
60
+ const overflows = viewportHeight !== null && types_js_1.DESIGN_CANVAS.height * scale > viewportHeight + 1;
61
+ return { scale, left, designHeight, scaledHeight, overflows };
62
+ }
63
+ /** Walk a document exactly the way the renderer does — pinned cards judge
64
+ * their own context, repeat cards yield one entry per package (keyed
65
+ * `id@packageId`), hidden subtrees are not entered — and report what every
66
+ * block resolves to. The wire fixture test reads this. */
67
+ function resolveBlocks(doc, packages, selectedId) {
68
+ const out = new Map();
69
+ const root = packages.find((p) => p.packageId === selectedId) ?? packages[0];
70
+ const visit = (block, inherited, pkg, key) => {
71
+ if (block.type === "card" && block.repeat === "packages") {
72
+ const list = packages.length ? packages : [undefined];
73
+ for (const p of list) {
74
+ visitCard(block, packageContext(p, selectedId) ?? inherited, p ?? pkg, `${key}@${p?.packageId ?? "-"}`);
75
+ }
76
+ return;
77
+ }
78
+ if (block.type === "card" && block.packageIndex !== undefined) {
79
+ const own = packages[block.packageIndex];
80
+ if (own === undefined)
81
+ return; // dropped: the offering does not reach it
82
+ visitCard(block, packageContext(own, selectedId), own, key);
83
+ return;
84
+ }
85
+ const context = inherited;
86
+ const visible = isVisible(block, context);
87
+ const entry = { block, context, visible, style: effectiveStyle(block, context) };
88
+ if (block.type === "text")
89
+ entry.text = (0, tags_js_1.resolveTags)(block.text, pkg, packages);
90
+ if (block.type === "button")
91
+ entry.text = (0, tags_js_1.resolveTags)(block.label, pkg, packages);
92
+ out.set(key, entry);
93
+ if (visible && block.type === "card") {
94
+ for (const child of block.children)
95
+ visit(child, context, pkg, child.id);
96
+ }
97
+ };
98
+ const visitCard = (block, context, pkg, key) => {
99
+ const visible = isVisible(block, context);
100
+ out.set(key, { block, context, visible, style: effectiveStyle(block, context) });
101
+ if (!visible)
102
+ return;
103
+ for (const child of block.children)
104
+ visit(child, context, pkg, child.id);
105
+ };
106
+ for (const b of doc.blocks)
107
+ visit(b, undefined, root, b.id);
108
+ return out;
109
+ }
@@ -0,0 +1,93 @@
1
+ import type { TextStyle, ViewStyle } from "react-native";
2
+ import type { BlockStyle, PaywallBlockDoc } from "./types.js";
3
+ /**
4
+ * What a design font maps onto in the host app.
5
+ *
6
+ * On the web one family name covers every weight and the browser picks the
7
+ * face. React Native has no such indirection: each weight is a separately
8
+ * registered family (`PlusJakartaSans_700Bold`). So a mapping is either one
9
+ * family for everything, or a weight → family table, from which the nearest
10
+ * registered weight is chosen.
11
+ */
12
+ export type FontMapping = string | Record<number, string> | undefined;
13
+ export type FontFamilyMap = Record<string, FontMapping>;
14
+ export interface StyleContext {
15
+ doc: PaywallBlockDoc;
16
+ /** Whether the renderer will paint gradient layers over this style's
17
+ * background — true when the host passed a `gradient` component.
18
+ *
19
+ * It decides whether a gradient `fill` degrades to its ground stop or is
20
+ * left transparent for the real layers. Painting the stop UNDER a gradient
21
+ * that will actually be drawn makes the box opaque, and 83 of the library's
22
+ * 139 gradient fills fade through a translucent stop — they sit over the
23
+ * screen's photo precisely so it shows through. */
24
+ paintsGradients?: boolean;
25
+ /** Where a paint string this build cannot read is reported. Local only —
26
+ * the style still resolves, to a colour from the design rather than to
27
+ * black, so this is the only way a host learns it is approximating. */
28
+ onDiagnostic?: (message: string) => void;
29
+ /** Font size inherited from an ancestor, for em/multiplier conversions. */
30
+ fontSize: number;
31
+ /** Maps a design font family onto one the host app has registered. A name
32
+ * that is absent, or maps to undefined, renders in the system font. */
33
+ fontFamilyMap?: FontFamilyMap;
34
+ }
35
+ /** The browser's default, which is what the dashboard preview inherits — a
36
+ * block that sets no size of its own must come out the same size in both. */
37
+ export declare const DEFAULT_FONT_SIZE = 16;
38
+ /**
39
+ * Typography a container passes down to its subtree.
40
+ *
41
+ * CSS inherits `color`, `font-*`, `letter-spacing`, `line-height`,
42
+ * `text-align` and `text-transform` — a card that sets `textColor` restyles
43
+ * every descendant's text without touching them. React Native inherits none
44
+ * of that through Views, so the tree has to carry it explicitly or a design
45
+ * renders in the wrong color and size the moment a container sets the style
46
+ * instead of each leaf.
47
+ *
48
+ * `lineHeight` rides along as the CSS multiplier rather than points, because
49
+ * an unitless line-height is resolved against EACH element's own font size.
50
+ */
51
+ export interface InheritedText {
52
+ color: string;
53
+ fontSize: number;
54
+ /** The DESIGN's family name, resolved to a registered one only once the
55
+ * weight in force is known — RN selects a face by family name, so family
56
+ * and weight cannot be decided independently. */
57
+ fontFamilyName?: string;
58
+ fontWeight?: TextStyle["fontWeight"];
59
+ fontStyle?: TextStyle["fontStyle"];
60
+ textAlign?: TextStyle["textAlign"];
61
+ textTransform?: TextStyle["textTransform"];
62
+ /** em, like the document; resolved per element. */
63
+ letterSpacing?: number;
64
+ /** Unitless multiplier, like the document; resolved per element. */
65
+ lineHeight?: number;
66
+ }
67
+ /** The inheritable half of a block's style, layered over what it inherited. */
68
+ export declare function inherit(base: InheritedText, s: BlockStyle | undefined, ctx: StyleContext): InheritedText;
69
+ /** Inherited typography as an RN style, with the em/multiplier units resolved
70
+ * against the size that actually applies here, and the family resolved
71
+ * against the weight in force. */
72
+ export declare function inheritedTextStyle(t: InheritedText, ctx: StyleContext): TextStyle;
73
+ /** The layout/paint half of a BlockStyle — everything a <View> understands. */
74
+ export declare function toViewStyle(s: BlockStyle | undefined, ctx: StyleContext): ViewStyle;
75
+ /** The typography half — everything a <Text> understands. */
76
+ export declare function toTextStyle(s: BlockStyle | undefined, ctx: StyleContext): TextStyle;
77
+ /**
78
+ * A design font renders only when the host app has registered it — RN cannot
79
+ * load a family by name at runtime. `fontFamilyMap` lets the app point a
80
+ * design's family at whatever it bundled (and drop the ones it did not), so an
81
+ * unregistered face falls back to the system font instead of, on Android,
82
+ * failing to render.
83
+ *
84
+ * The weight matters because RN picks a face by family NAME, not by
85
+ * `fontWeight`: asking for weight 800 of a family registered only at 400 gets
86
+ * a synthesised bold at best. Given a weight table, the nearest registered
87
+ * weight wins.
88
+ */
89
+ export declare function resolveFontFamily(family: string | undefined, ctx: StyleContext, weight?: TextStyle["fontWeight"]): string | undefined;
90
+ /** A `line` block is drawn from the current color at low alpha — the web
91
+ * renderer uses `currentColor` with opacity, which RN has no notion of, so
92
+ * the caller passes the color that is in force at that point in the tree. */
93
+ export declare function hairlineColor(currentColor: string): string;