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,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;
|