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
package/dist/device.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DeviceFacts } from "./types.js";
|
|
2
|
+
/** The header the server reads. Keep in sync with
|
|
3
|
+
* convex/lib/deviceAttributes.ts DEVICE_HEADER. */
|
|
4
|
+
export declare const DEVICE_HEADER = "X-Revnix-Device";
|
|
5
|
+
/** Storage key for the first-launch timestamp. */
|
|
6
|
+
export declare const INSTALLED_AT_KEY = "revnix.installedAt";
|
|
7
|
+
/** What goes on the wire: the app-supplied facts plus the three the SDK owns. */
|
|
8
|
+
export interface DevicePayload extends DeviceFacts {
|
|
9
|
+
sdkVersion: string;
|
|
10
|
+
installedAt?: number;
|
|
11
|
+
firstOpen?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** The facts this runtime can report about itself without help. Browser
|
|
14
|
+
* only in practice — under Node and React Native `navigator` is absent or
|
|
15
|
+
* says nothing useful, and those callers pass `device` in configure(). */
|
|
16
|
+
export declare function detectDeviceFacts(): DeviceFacts;
|
|
17
|
+
/** Best-effort OS version from a browser UA string; undefined when the UA
|
|
18
|
+
* gives nothing recognizable. Only the major desktop/mobile families —
|
|
19
|
+
* this is a targeting hint, not a fingerprinting library. */
|
|
20
|
+
export declare function osVersionFromUserAgent(ua: string): string | undefined;
|
|
21
|
+
/** Drop undefined/empty fields so the encoded header carries only what is
|
|
22
|
+
* known — the server would drop them anyway, but bytes on every resolve
|
|
23
|
+
* are bytes on every resolve. */
|
|
24
|
+
export declare function compactPayload(payload: DevicePayload): Record<string, unknown>;
|
|
25
|
+
/** base64url of the UTF-8 JSON payload. Hand-rolled rather than
|
|
26
|
+
* btoa/TextEncoder: neither is guaranteed on every React Native runtime
|
|
27
|
+
* this SDK supports, and a missing global here would fail every paywall
|
|
28
|
+
* resolve on that platform for the sake of one header. */
|
|
29
|
+
export declare function encodeDeviceHeader(payload: Record<string, unknown>): string;
|
package/dist/device.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// REV-268: the device attribute contract, SDK side. Every SDK sends the same
|
|
3
|
+
// twelve facts about the device on each placement resolve, in the
|
|
4
|
+
// X-Revnix-Device header, and the server stores them as reserved `device.*`
|
|
5
|
+
// customer attributes — the facts a targeting rule ("US only", "app version
|
|
6
|
+
// at least 3", "first open") evaluates against on the very request that
|
|
7
|
+
// serves the paywall.
|
|
8
|
+
//
|
|
9
|
+
// This SDK runs in a browser, in React Native, and under Node, so it cannot
|
|
10
|
+
// read most of the facts itself the way the native SDKs can: there is no
|
|
11
|
+
// StoreKit storefront in a browser and no `navigator` in Node. The split is
|
|
12
|
+
// - what the runtime can tell us safely (detectDeviceFacts): platform,
|
|
13
|
+
// locale, and — in a browser — the OS version from the user agent;
|
|
14
|
+
// - what the app must tell us (RevnixConfig.device): app version, model,
|
|
15
|
+
// storefront, currency, sandbox — a React Native app has
|
|
16
|
+
// react-native-device-info in hand and this SDK should not depend on it;
|
|
17
|
+
// - what the SDK owns outright: sdkVersion, installedAt, firstOpen.
|
|
18
|
+
// Anything unknown is simply omitted; the server treats every field as
|
|
19
|
+
// optional and normalizes what it gets (lib/deviceAttributes.ts in
|
|
20
|
+
// revnix-app), so nothing here needs to be exact — only honest.
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.INSTALLED_AT_KEY = exports.DEVICE_HEADER = void 0;
|
|
23
|
+
exports.detectDeviceFacts = detectDeviceFacts;
|
|
24
|
+
exports.osVersionFromUserAgent = osVersionFromUserAgent;
|
|
25
|
+
exports.compactPayload = compactPayload;
|
|
26
|
+
exports.encodeDeviceHeader = encodeDeviceHeader;
|
|
27
|
+
/** The header the server reads. Keep in sync with
|
|
28
|
+
* convex/lib/deviceAttributes.ts DEVICE_HEADER. */
|
|
29
|
+
exports.DEVICE_HEADER = "X-Revnix-Device";
|
|
30
|
+
/** Storage key for the first-launch timestamp. */
|
|
31
|
+
exports.INSTALLED_AT_KEY = "revnix.installedAt";
|
|
32
|
+
/** The facts this runtime can report about itself without help. Browser
|
|
33
|
+
* only in practice — under Node and React Native `navigator` is absent or
|
|
34
|
+
* says nothing useful, and those callers pass `device` in configure(). */
|
|
35
|
+
function detectDeviceFacts() {
|
|
36
|
+
const nav = globalThis.navigator;
|
|
37
|
+
const facts = {};
|
|
38
|
+
if (!nav)
|
|
39
|
+
return facts;
|
|
40
|
+
// A React Native runtime also exposes `navigator` (product: "ReactNative")
|
|
41
|
+
// but its language/userAgent are not the device's — leave those to the app.
|
|
42
|
+
if (nav.product === "ReactNative")
|
|
43
|
+
return facts;
|
|
44
|
+
if (typeof nav.userAgent === "string" && nav.userAgent.length > 0) {
|
|
45
|
+
facts.platform = "web";
|
|
46
|
+
const os = osVersionFromUserAgent(nav.userAgent);
|
|
47
|
+
if (os !== undefined)
|
|
48
|
+
facts.osVersion = os;
|
|
49
|
+
}
|
|
50
|
+
if (typeof nav.language === "string" && nav.language.length > 0) {
|
|
51
|
+
facts.locale = nav.language;
|
|
52
|
+
}
|
|
53
|
+
return facts;
|
|
54
|
+
}
|
|
55
|
+
/** Best-effort OS version from a browser UA string; undefined when the UA
|
|
56
|
+
* gives nothing recognizable. Only the major desktop/mobile families —
|
|
57
|
+
* this is a targeting hint, not a fingerprinting library. */
|
|
58
|
+
function osVersionFromUserAgent(ua) {
|
|
59
|
+
const ios = /(?:iPhone|iPad|CPU) OS (\d+(?:_\d+)*)/.exec(ua)?.[1];
|
|
60
|
+
if (ios !== undefined)
|
|
61
|
+
return ios.replace(/_/g, ".");
|
|
62
|
+
const android = /Android (\d+(?:\.\d+)*)/.exec(ua)?.[1];
|
|
63
|
+
if (android !== undefined)
|
|
64
|
+
return android;
|
|
65
|
+
const mac = /Mac OS X (\d+(?:[._]\d+)*)/.exec(ua)?.[1];
|
|
66
|
+
if (mac !== undefined)
|
|
67
|
+
return mac.replace(/_/g, ".");
|
|
68
|
+
const windows = /Windows NT (\d+(?:\.\d+)*)/.exec(ua)?.[1];
|
|
69
|
+
if (windows !== undefined)
|
|
70
|
+
return windows;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
/** Drop undefined/empty fields so the encoded header carries only what is
|
|
74
|
+
* known — the server would drop them anyway, but bytes on every resolve
|
|
75
|
+
* are bytes on every resolve. */
|
|
76
|
+
function compactPayload(payload) {
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
79
|
+
if (value === undefined || value === null || value === "")
|
|
80
|
+
continue;
|
|
81
|
+
out[key] = value;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** base64url of the UTF-8 JSON payload. Hand-rolled rather than
|
|
86
|
+
* btoa/TextEncoder: neither is guaranteed on every React Native runtime
|
|
87
|
+
* this SDK supports, and a missing global here would fail every paywall
|
|
88
|
+
* resolve on that platform for the sake of one header. */
|
|
89
|
+
function encodeDeviceHeader(payload) {
|
|
90
|
+
return base64url(utf8Bytes(JSON.stringify(payload)));
|
|
91
|
+
}
|
|
92
|
+
function utf8Bytes(text) {
|
|
93
|
+
const bytes = [];
|
|
94
|
+
for (let i = 0; i < text.length; i++) {
|
|
95
|
+
let code = text.charCodeAt(i);
|
|
96
|
+
// Surrogate pair → one code point.
|
|
97
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
|
98
|
+
const low = text.charCodeAt(i + 1);
|
|
99
|
+
if (low >= 0xdc00 && low <= 0xdfff) {
|
|
100
|
+
code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00);
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (code < 0x80) {
|
|
105
|
+
bytes.push(code);
|
|
106
|
+
}
|
|
107
|
+
else if (code < 0x800) {
|
|
108
|
+
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
|
109
|
+
}
|
|
110
|
+
else if (code < 0x10000) {
|
|
111
|
+
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return bytes;
|
|
118
|
+
}
|
|
119
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
120
|
+
function base64url(bytes) {
|
|
121
|
+
let out = "";
|
|
122
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
123
|
+
const a = bytes[i] ?? 0;
|
|
124
|
+
const b = i + 1 < bytes.length ? bytes[i + 1] : undefined;
|
|
125
|
+
const c = i + 2 < bytes.length ? bytes[i + 2] : undefined;
|
|
126
|
+
out += B64[a >> 2];
|
|
127
|
+
out += B64[((a & 0x03) << 4) | ((b ?? 0) >> 4)];
|
|
128
|
+
if (b === undefined)
|
|
129
|
+
break;
|
|
130
|
+
out += B64[((b & 0x0f) << 2) | ((c ?? 0) >> 6)];
|
|
131
|
+
if (c === undefined)
|
|
132
|
+
break;
|
|
133
|
+
out += B64[c & 0x3f];
|
|
134
|
+
}
|
|
135
|
+
// No padding: base64url as the server decodes it (it re-pads itself).
|
|
136
|
+
return out;
|
|
137
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare class RevnixError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
constructor(status: number, message: string);
|
|
4
|
+
/** Transient failures worth retrying (network, timeout, rate limit, 5xx).
|
|
5
|
+
* 4xx other than 429 are the caller's fault and won't fix themselves. */
|
|
6
|
+
get isRetryable(): boolean;
|
|
7
|
+
}
|
|
8
|
+
/** POST /v1/purchases returned 409: the store token belongs to another
|
|
9
|
+
* customer and the app's transfer behavior is "block". Surface this to the
|
|
10
|
+
* user as "this subscription is linked to a different account". */
|
|
11
|
+
export declare class PurchaseBlockedError extends RevnixError {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
/** C7: the request didn't complete within the configured timeout (default
|
|
15
|
+
* 10s). `fetch` has no default timeout on React Native / browsers, so without
|
|
16
|
+
* this a slow or dead backend would hang the app until the OS TCP timeout. */
|
|
17
|
+
export declare class RevnixTimeoutError extends RevnixError {
|
|
18
|
+
constructor(message?: string);
|
|
19
|
+
}
|
|
20
|
+
/** C7: the request never reached the server (offline, DNS/TLS failure, captive
|
|
21
|
+
* portal). Distinct from an HTTP error so callers can show an offline state
|
|
22
|
+
* instead of treating it as a bug. */
|
|
23
|
+
export declare class RevnixNetworkError extends RevnixError {
|
|
24
|
+
constructor(message?: string);
|
|
25
|
+
}
|
|
26
|
+
/** L4: the API returned 401/403 — the key is missing, revoked, or the wrong
|
|
27
|
+
* kind (e.g. a secret key where a public key is required) — or the SDK
|
|
28
|
+
* refused locally before the network for the same reason (identify()/alias()
|
|
29
|
+
* on a publishable key). Retrying is pointless; the fix is reconfiguration,
|
|
30
|
+
* so surface this to the developer. */
|
|
31
|
+
export declare class RevnixAuthError extends RevnixError {
|
|
32
|
+
constructor(status: number, message: string);
|
|
33
|
+
}
|
|
34
|
+
/** C7: the API returned 429 (rate limited). `retryAfterMs` mirrors the
|
|
35
|
+
* server's Retry-After header when present — back off at least that long. */
|
|
36
|
+
export declare class RevnixRateLimitError extends RevnixError {
|
|
37
|
+
readonly retryAfterMs?: number;
|
|
38
|
+
constructor(message: string, retryAfterMs?: number);
|
|
39
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RevnixRateLimitError = exports.RevnixAuthError = exports.RevnixNetworkError = exports.RevnixTimeoutError = exports.PurchaseBlockedError = exports.RevnixError = void 0;
|
|
4
|
+
class RevnixError extends Error {
|
|
5
|
+
constructor(status, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "RevnixError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
/** Transient failures worth retrying (network, timeout, rate limit, 5xx).
|
|
11
|
+
* 4xx other than 429 are the caller's fault and won't fix themselves. */
|
|
12
|
+
get isRetryable() {
|
|
13
|
+
return this.status === 0 || this.status === 429 || this.status >= 500;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.RevnixError = RevnixError;
|
|
17
|
+
/** POST /v1/purchases returned 409: the store token belongs to another
|
|
18
|
+
* customer and the app's transfer behavior is "block". Surface this to the
|
|
19
|
+
* user as "this subscription is linked to a different account". */
|
|
20
|
+
class PurchaseBlockedError extends RevnixError {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(409, message);
|
|
23
|
+
this.name = "PurchaseBlockedError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.PurchaseBlockedError = PurchaseBlockedError;
|
|
27
|
+
/** C7: the request didn't complete within the configured timeout (default
|
|
28
|
+
* 10s). `fetch` has no default timeout on React Native / browsers, so without
|
|
29
|
+
* this a slow or dead backend would hang the app until the OS TCP timeout. */
|
|
30
|
+
class RevnixTimeoutError extends RevnixError {
|
|
31
|
+
constructor(message = "Revnix request timed out") {
|
|
32
|
+
super(0, message);
|
|
33
|
+
this.name = "RevnixTimeoutError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
exports.RevnixTimeoutError = RevnixTimeoutError;
|
|
37
|
+
/** C7: the request never reached the server (offline, DNS/TLS failure, captive
|
|
38
|
+
* portal). Distinct from an HTTP error so callers can show an offline state
|
|
39
|
+
* instead of treating it as a bug. */
|
|
40
|
+
class RevnixNetworkError extends RevnixError {
|
|
41
|
+
constructor(message = "Revnix request failed to reach the server") {
|
|
42
|
+
super(0, message);
|
|
43
|
+
this.name = "RevnixNetworkError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.RevnixNetworkError = RevnixNetworkError;
|
|
47
|
+
/** L4: the API returned 401/403 — the key is missing, revoked, or the wrong
|
|
48
|
+
* kind (e.g. a secret key where a public key is required) — or the SDK
|
|
49
|
+
* refused locally before the network for the same reason (identify()/alias()
|
|
50
|
+
* on a publishable key). Retrying is pointless; the fix is reconfiguration,
|
|
51
|
+
* so surface this to the developer. */
|
|
52
|
+
class RevnixAuthError extends RevnixError {
|
|
53
|
+
constructor(status, message) {
|
|
54
|
+
super(status, message);
|
|
55
|
+
this.name = "RevnixAuthError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.RevnixAuthError = RevnixAuthError;
|
|
59
|
+
/** C7: the API returned 429 (rate limited). `retryAfterMs` mirrors the
|
|
60
|
+
* server's Retry-After header when present — back off at least that long. */
|
|
61
|
+
class RevnixRateLimitError extends RevnixError {
|
|
62
|
+
constructor(message, retryAfterMs) {
|
|
63
|
+
super(429, message);
|
|
64
|
+
this.name = "RevnixRateLimitError";
|
|
65
|
+
this.retryAfterMs = retryAfterMs;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exports.RevnixRateLimitError = RevnixRateLimitError;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The paywall screen background — the SDK half of the dashboard's model.
|
|
2
|
+
//
|
|
3
|
+
// A background is either the original CSS string (color or gradient) or a
|
|
4
|
+
// layered spec: ground color/gradient, then a photo (fit, focal point,
|
|
5
|
+
// opacity, blur), then a scrim. `backgroundLayers()` resolves either form to
|
|
6
|
+
// one paint list, mirroring the dashboard renderer exactly — the same
|
|
7
|
+
// function name, the same defaults, the same clamping — so a background can
|
|
8
|
+
// never look different on device than in the builder for reasons of
|
|
9
|
+
// interpretation. (Type parity with revnix-app/src/lib/paywall-blocks/
|
|
10
|
+
// background.ts; keep the two in step.)
|
|
11
|
+
const DEFAULT_GROUND = "#000000";
|
|
12
|
+
/**
|
|
13
|
+
* The flat color a ground stands in for — what `@bg` resolves to, and what
|
|
14
|
+
* paints under a gradient this build cannot draw.
|
|
15
|
+
*
|
|
16
|
+
* It reads the LAST comma-separated layer, because in CSS the first-listed
|
|
17
|
+
* layer paints on TOP: taking the first color would answer with the
|
|
18
|
+
* translucent accent glow the library's Spotlight and Corner halo presets
|
|
19
|
+
* stack over their base, not with the base itself. Fully transparent stops are
|
|
20
|
+
* skipped for the same reason — a scrim that fades to `…00` says nothing about
|
|
21
|
+
* the ground under it.
|
|
22
|
+
*
|
|
23
|
+
* Returning the raw gradient here instead is what put this SDK and the
|
|
24
|
+
* dashboard out of step with the other five renderers, which have always
|
|
25
|
+
* collapsed it: `@bg` is fed to `color-mix()` (and, inside a gradient, to
|
|
26
|
+
* another gradient's stop list), and neither can take a gradient — so the raw
|
|
27
|
+
* form resolved to something invalid and painted NOTHING. Of the 250 shipped
|
|
28
|
+
* gallery presets only 7 have a gradient ground and none of those use `@bg`,
|
|
29
|
+
* so no design that ships today changes; what changes is a background a
|
|
30
|
+
* customer edits to a gradient, which used to silently drop every `@bg` tint
|
|
31
|
+
* on the screen.
|
|
32
|
+
*/
|
|
33
|
+
export function backgroundBaseColor(bg) {
|
|
34
|
+
const ground = typeof bg === "string" ? bg : bg?.color;
|
|
35
|
+
if (ground === undefined)
|
|
36
|
+
return DEFAULT_GROUND;
|
|
37
|
+
const layers = splitLayers(ground);
|
|
38
|
+
const bottom = layers.length > 0 ? layers[layers.length - 1] : ground;
|
|
39
|
+
const colors = bottom.match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/gi);
|
|
40
|
+
if (!colors)
|
|
41
|
+
return ground;
|
|
42
|
+
return colors.find((color) => !isFullyTransparent(color)) ?? colors[0];
|
|
43
|
+
}
|
|
44
|
+
/** Whether a color literal is fully transparent. Kept here rather than routed
|
|
45
|
+
* through `color.ts`, which imports THIS module — only the two forms the
|
|
46
|
+
* dashboard emits are recognized, and anything else counts as opaque, which
|
|
47
|
+
* is the safe answer for picking a ground. */
|
|
48
|
+
function isFullyTransparent(color) {
|
|
49
|
+
const s = color.trim();
|
|
50
|
+
if (s.startsWith("#")) {
|
|
51
|
+
const hex = s.slice(1);
|
|
52
|
+
if (hex.length === 8)
|
|
53
|
+
return parseInt(hex.slice(6), 16) === 0;
|
|
54
|
+
if (hex.length === 4)
|
|
55
|
+
return parseInt(hex.slice(3).repeat(2), 16) === 0;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const open = s.indexOf("(");
|
|
59
|
+
const close = s.indexOf(")");
|
|
60
|
+
if (open < 0 || close < 0)
|
|
61
|
+
return false;
|
|
62
|
+
const parts = s
|
|
63
|
+
.slice(open + 1, close)
|
|
64
|
+
.split(/[,/\s]+/)
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
return parts.length >= 4 && parseFloat(parts[3]) === 0;
|
|
67
|
+
}
|
|
68
|
+
/** Splits on top-level commas only, so the commas inside `rgba(...)` and
|
|
69
|
+
* inside a gradient's argument list do not tear a layer in half. */
|
|
70
|
+
function splitLayers(input) {
|
|
71
|
+
const out = [];
|
|
72
|
+
let depth = 0;
|
|
73
|
+
let current = "";
|
|
74
|
+
for (const ch of input) {
|
|
75
|
+
if (ch === "(")
|
|
76
|
+
depth++;
|
|
77
|
+
else if (ch === ")" && depth > 0)
|
|
78
|
+
depth--;
|
|
79
|
+
if (ch === "," && depth === 0) {
|
|
80
|
+
out.push(current.trim());
|
|
81
|
+
current = "";
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
current += ch;
|
|
85
|
+
}
|
|
86
|
+
const tail = current.trim();
|
|
87
|
+
if (tail)
|
|
88
|
+
out.push(tail);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
function pct(value, fallback) {
|
|
92
|
+
if (value === undefined || !Number.isFinite(value))
|
|
93
|
+
return fallback;
|
|
94
|
+
return Math.min(1, Math.max(0, value / 100));
|
|
95
|
+
}
|
|
96
|
+
function coord(value) {
|
|
97
|
+
if (value === undefined || !Number.isFinite(value))
|
|
98
|
+
return 50;
|
|
99
|
+
return Math.min(100, Math.max(0, value));
|
|
100
|
+
}
|
|
101
|
+
/** The paint list, bottom layer first. Layers that would draw nothing (no
|
|
102
|
+
* url, zero opacity) are dropped, so a legacy string resolves to exactly
|
|
103
|
+
* one ground layer. */
|
|
104
|
+
export function backgroundLayers(bg,
|
|
105
|
+
/** Resolves palette tokens in the ground and overlay fills. */
|
|
106
|
+
resolve = (v) => v) {
|
|
107
|
+
const spec = bg === undefined ? {} : typeof bg === "string" ? { color: bg } : bg;
|
|
108
|
+
const layers = {};
|
|
109
|
+
if (spec.color)
|
|
110
|
+
layers.ground = resolve(spec.color);
|
|
111
|
+
if (spec.image?.url) {
|
|
112
|
+
const opacity = pct(spec.image.opacity, 1);
|
|
113
|
+
if (opacity > 0) {
|
|
114
|
+
layers.image = {
|
|
115
|
+
url: spec.image.url,
|
|
116
|
+
fit: spec.image.fit === "contain" ? "contain" : "cover",
|
|
117
|
+
focalX: coord(spec.image.focalX),
|
|
118
|
+
focalY: coord(spec.image.focalY),
|
|
119
|
+
opacity,
|
|
120
|
+
...(spec.image.blur ? { blur: spec.image.blur } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (spec.overlay?.fill) {
|
|
125
|
+
const opacity = pct(spec.overlay.opacity, 1);
|
|
126
|
+
if (opacity > 0) {
|
|
127
|
+
layers.overlay = { fill: resolve(spec.overlay.fill), opacity };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return layers;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Cover-fit geometry with a focal point — the SDK's stand-in for CSS
|
|
134
|
+
* `object-position`, which React Native's <Image> lacks. Given the box and
|
|
135
|
+
* the source dimensions it returns the drawn size and the offset that puts
|
|
136
|
+
* the focal point as close to the box's matching anchor as the image edges
|
|
137
|
+
* allow (exactly CSS's `object-position: X% Y%` rule: the X% point of the
|
|
138
|
+
* image aligns to the X% point of the box, clamped so no edge shows).
|
|
139
|
+
*/
|
|
140
|
+
export function coverPlacement(box, source, focalX, focalY) {
|
|
141
|
+
if (box.width <= 0 || box.height <= 0 || source.width <= 0 || source.height <= 0) {
|
|
142
|
+
return { width: box.width, height: box.height, left: 0, top: 0 };
|
|
143
|
+
}
|
|
144
|
+
const scale = Math.max(box.width / source.width, box.height / source.height);
|
|
145
|
+
const width = source.width * scale;
|
|
146
|
+
const height = source.height * scale;
|
|
147
|
+
const place = (boxLen, drawnLen, pctPoint) => {
|
|
148
|
+
const offset = (boxLen - drawnLen) * (pctPoint / 100);
|
|
149
|
+
// Clamp: never pull an image edge inside the box. `|| 0` normalises the
|
|
150
|
+
// -0 that falls out of the arithmetic at the edges — harmless to RN, but
|
|
151
|
+
// it makes the returned geometry compare cleanly.
|
|
152
|
+
return Math.min(0, Math.max(boxLen - drawnLen, offset)) || 0;
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
left: place(box.width, width, focalX),
|
|
158
|
+
top: place(box.height, height, focalY),
|
|
159
|
+
};
|
|
160
|
+
}
|