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,890 @@
|
|
|
1
|
+
// RevnixPaywall — renders a published PaywallConfig exactly as the dashboard
|
|
2
|
+
// paywall-builder previews it (PaywallPhonePreview.tsx in revnix-app is the
|
|
3
|
+
// reference renderer; keep the two in lockstep). The config decides template,
|
|
4
|
+
// copy, accent, badge, highlight, and hero image; the app supplies package
|
|
5
|
+
// titles/prices (from StoreKit / Play Billing) and the purchase handlers, so
|
|
6
|
+
// the display never disagrees with the charge.
|
|
7
|
+
//
|
|
8
|
+
// `template` is a LAYOUT id. "focus" | "feature-list" | "minimal" are the
|
|
9
|
+
// original three and render byte-identically to earlier SDK versions; the
|
|
10
|
+
// newer layouts (hero, timeline, plans, feature-grid, offer, reveal) are
|
|
11
|
+
// distinct screen structures the dashboard's template gallery presets over.
|
|
12
|
+
// An unrecognized template (config published by a newer dashboard) falls
|
|
13
|
+
// back to the classic structure instead of rendering nothing.
|
|
14
|
+
//
|
|
15
|
+
// Import from "revnix-react/ui" — the core entrypoint stays free of
|
|
16
|
+
// react / react-native so non-RN consumers are unaffected.
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
import { ActivityIndicator, Alert, Image, Linking, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native";
|
|
19
|
+
import { PREVIEW_PLACEMENT_KEY } from "../implicit.js";
|
|
20
|
+
import { withAlpha } from "../blocks/color.js";
|
|
21
|
+
import { RevnixBlockPaywall, } from "./RevnixBlockPaywall.js";
|
|
22
|
+
/** `config.blocks` is opaque on the core config (the non-UI entrypoint must
|
|
23
|
+
* not carry renderer types), and it arrives straight off the wire — so it is
|
|
24
|
+
* checked here rather than trusted. A malformed doc falls through to the
|
|
25
|
+
* classic layouts instead of rendering a broken screen. */
|
|
26
|
+
function asBlockDoc(value) {
|
|
27
|
+
if (typeof value !== "object" || value === null)
|
|
28
|
+
return null;
|
|
29
|
+
const doc = value;
|
|
30
|
+
return Array.isArray(doc.blocks) &&
|
|
31
|
+
// A background is the legacy CSS string OR the layered spec (ground /
|
|
32
|
+
// photo / scrim). Both are valid documents; only a missing or wrongly
|
|
33
|
+
// typed one falls through to the classic layouts.
|
|
34
|
+
(typeof doc.background === "string" ||
|
|
35
|
+
(typeof doc.background === "object" && doc.background !== null)) &&
|
|
36
|
+
typeof doc.textColor === "string" &&
|
|
37
|
+
typeof doc.accent === "string" &&
|
|
38
|
+
typeof doc.accentInk === "string"
|
|
39
|
+
? doc
|
|
40
|
+
: null;
|
|
41
|
+
}
|
|
42
|
+
export const DEFAULT_PAYWALL_THEME = {
|
|
43
|
+
background: "#0f1116",
|
|
44
|
+
textPrimary: "#ffffff",
|
|
45
|
+
textSecondary: "#9aa0a8",
|
|
46
|
+
textFaint: "#6b7078",
|
|
47
|
+
border: "#2a2e36",
|
|
48
|
+
accentInk: "#0a0b0d",
|
|
49
|
+
};
|
|
50
|
+
/** Base theme when the dashboard config sets `mode: "light"`. Keep in
|
|
51
|
+
* lockstep with the builder preview (PaywallPhonePreview SCREEN_PALETTES). */
|
|
52
|
+
export const LIGHT_PAYWALL_THEME = {
|
|
53
|
+
background: "#ffffff",
|
|
54
|
+
textPrimary: "#16181d",
|
|
55
|
+
textSecondary: "#5b6068",
|
|
56
|
+
textFaint: "#9aa0a8",
|
|
57
|
+
border: "#e2e5ea",
|
|
58
|
+
accentInk: "#ffffff",
|
|
59
|
+
};
|
|
60
|
+
const DEFAULT_ACCENT = "#6478ff";
|
|
61
|
+
// Currency symbols the anchor-price guard recognizes (majors; a symbol-less
|
|
62
|
+
// anchor can't be judged and renders as entered).
|
|
63
|
+
const CURRENCY_SYMBOL = /[$€£¥₹₩₽₺₫₪฿₴₦₱]/;
|
|
64
|
+
// Soft card surface used by the feature-grid / reveal / review blocks. Not
|
|
65
|
+
// part of the public theme — derived from the config's mode, in lockstep
|
|
66
|
+
// with the dashboard preview's SCREEN_PALETTES.card.
|
|
67
|
+
const CARD_BG = { dark: "#181b22", light: "#f4f5f7" };
|
|
68
|
+
export function RevnixPaywall(props) {
|
|
69
|
+
// One view per mount: remounting (a re-shown paywall) is a genuine new
|
|
70
|
+
// display; re-renders are not.
|
|
71
|
+
const { client, placementKey, paywallId, disableViewTracking } = props;
|
|
72
|
+
// Held in a ref, not state: the close report reads it, and re-rendering the
|
|
73
|
+
// paywall the instant its view is recorded would be a pointless frame.
|
|
74
|
+
const viewIdRef = React.useRef(undefined);
|
|
75
|
+
React.useEffect(() => {
|
|
76
|
+
if (!client || disableViewTracking)
|
|
77
|
+
return;
|
|
78
|
+
const id = client.logPaywallShown({ placementKey, paywallId });
|
|
79
|
+
// Pre-REV-252 clients return void; the close then still fires the host
|
|
80
|
+
// callback, it just cannot be paired with this display.
|
|
81
|
+
if (typeof id === "string")
|
|
82
|
+
viewIdRef.current = id;
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, []);
|
|
85
|
+
// ——— REV-263: the interaction vocabulary ———
|
|
86
|
+
//
|
|
87
|
+
// Everything below reports what this component genuinely OBSERVES and
|
|
88
|
+
// nothing it would have to guess. It sees the selection change, the CTA
|
|
89
|
+
// press, the restore press, and an offering that arrived with no packages
|
|
90
|
+
// to sell. It does NOT see the store sheet — the host owns that call — so
|
|
91
|
+
// the purchase outcome is only reported when the host hands it back from
|
|
92
|
+
// `onPurchase`.
|
|
93
|
+
//
|
|
94
|
+
// Every report is wrapped: a telemetry failure must never break the paywall
|
|
95
|
+
// the customer is standing on, the same rule REV-252 set for the close.
|
|
96
|
+
const report = React.useCallback((event, options) => {
|
|
97
|
+
const viewId = viewIdRef.current;
|
|
98
|
+
if (!client?.logPaywallEvent || !viewId || disableViewTracking)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
client.logPaywallEvent(event, viewId, {
|
|
102
|
+
placementKey,
|
|
103
|
+
paywallId,
|
|
104
|
+
...options,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Best-effort, exactly like the close beacon.
|
|
109
|
+
}
|
|
110
|
+
}, [client, placementKey, paywallId, disableViewTracking]);
|
|
111
|
+
// packageId → productId, so a report names the plan the way the rest of the
|
|
112
|
+
// ledger does. A package whose product is unknown reports no productId
|
|
113
|
+
// rather than the package id, which would look like a product that does not
|
|
114
|
+
// exist.
|
|
115
|
+
const { packages } = props;
|
|
116
|
+
const productOf = React.useCallback((packageId) => packages.find((p) => p.packageId === packageId)?.productId, [packages]);
|
|
117
|
+
// An offering with nothing to sell is the one failure the component can see
|
|
118
|
+
// by itself, and the one most worth knowing about: the paywall painted, the
|
|
119
|
+
// customer could not buy. Reported once per display, after the view report
|
|
120
|
+
// so the two arrive in order.
|
|
121
|
+
const emptyOnMount = props.packages.length === 0;
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
if (emptyOnMount) {
|
|
124
|
+
report("error", {
|
|
125
|
+
code: "no_products",
|
|
126
|
+
message: "paywall displayed with no packages",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
130
|
+
}, []);
|
|
131
|
+
const { onSelectPackage } = props;
|
|
132
|
+
const handleSelect = React.useMemo(() => (packageId) => {
|
|
133
|
+
report("selected", {
|
|
134
|
+
productId: productOf(packageId),
|
|
135
|
+
// One `selected` per (display, package): a customer toggling
|
|
136
|
+
// monthly → yearly → monthly considered two plans, not three, and the
|
|
137
|
+
// server's default (key on viewId alone) would have recorded only the
|
|
138
|
+
// first. Analytics counts stages per display anyway, so this is about
|
|
139
|
+
// WHICH plans were weighed.
|
|
140
|
+
eventId: `${viewIdRef.current ?? ""}:sel:${packageId}`,
|
|
141
|
+
});
|
|
142
|
+
onSelectPackage?.(packageId);
|
|
143
|
+
}, [onSelectPackage, report, productOf]);
|
|
144
|
+
const { onPurchase } = props;
|
|
145
|
+
// Rises per attempt so a retry after a failure is its own occurrence rather
|
|
146
|
+
// than being absorbed as a duplicate of the first try.
|
|
147
|
+
const attemptRef = React.useRef(0);
|
|
148
|
+
const handlePurchase = React.useMemo(() => (packageId) => {
|
|
149
|
+
if (placementKey === PREVIEW_PLACEMENT_KEY) {
|
|
150
|
+
Alert.alert("Preview", "Purchases are disabled in preview.");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const attempt = ++attemptRef.current;
|
|
154
|
+
const productId = productOf(packageId);
|
|
155
|
+
const occurrence = `${viewIdRef.current ?? ""}:buy:${attempt}`;
|
|
156
|
+
report("purchase_started", { productId, eventId: occurrence });
|
|
157
|
+
const settle = (outcome) => {
|
|
158
|
+
// `completed` lands as purchase.completed through registerPurchase —
|
|
159
|
+
// reporting it here too would double-count the sale.
|
|
160
|
+
if (!outcome || outcome.status === "completed")
|
|
161
|
+
return;
|
|
162
|
+
report(outcome.status === "cancelled"
|
|
163
|
+
? "purchase_abandoned"
|
|
164
|
+
: "purchase_failed", {
|
|
165
|
+
productId,
|
|
166
|
+
code: outcome.code,
|
|
167
|
+
message: outcome.message,
|
|
168
|
+
eventId: occurrence,
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
let returned;
|
|
172
|
+
try {
|
|
173
|
+
returned = onPurchase(packageId);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
// A host that throws synchronously has failed the purchase as surely
|
|
177
|
+
// as one whose promise rejects.
|
|
178
|
+
settle({ status: "failed", message: messageOf(err) });
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
if (returned && typeof returned.then === "function") {
|
|
182
|
+
void returned.then(settle, (err) => settle({ status: "failed", message: messageOf(err) }));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
settle(returned);
|
|
186
|
+
}, [onPurchase, report, productOf, placementKey]);
|
|
187
|
+
const { onRestore } = props;
|
|
188
|
+
const handleRestore = React.useMemo(() => onRestore
|
|
189
|
+
? () => {
|
|
190
|
+
report("restore");
|
|
191
|
+
onRestore();
|
|
192
|
+
}
|
|
193
|
+
: undefined, [onRestore, report]);
|
|
194
|
+
// The host's dismissal runs whatever happens to the beacon — an analytics
|
|
195
|
+
// failure must never trap the customer on the screen (REV-252).
|
|
196
|
+
const { onClose } = props;
|
|
197
|
+
const handleClose = React.useMemo(() => onClose
|
|
198
|
+
? () => {
|
|
199
|
+
const viewId = viewIdRef.current;
|
|
200
|
+
if (client?.logPaywallClosed && viewId && !disableViewTracking) {
|
|
201
|
+
try {
|
|
202
|
+
client.logPaywallClosed(viewId, { placementKey, paywallId });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Reporting is best-effort; dismissal is not.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
onClose();
|
|
209
|
+
}
|
|
210
|
+
: undefined, [onClose, client, placementKey, paywallId, disableViewTracking]);
|
|
211
|
+
// A designed paywall wins over the classic layouts — same precedence the
|
|
212
|
+
// dashboard preview applies, so what was approved is what ships. The view
|
|
213
|
+
// report above still runs, so analytics does not care which renderer won.
|
|
214
|
+
//
|
|
215
|
+
// Each renderer is its OWN component, so this switch mounts and unmounts
|
|
216
|
+
// rather than changing one component's hook count: a paywall whose config
|
|
217
|
+
// arrives after a placeholder (or is swapped by an A/B assignment) would
|
|
218
|
+
// otherwise crash on "rendered more hooks than during the previous render".
|
|
219
|
+
const { config } = props;
|
|
220
|
+
const blocks = asBlockDoc(config.blocks);
|
|
221
|
+
// Both sinks fire when both are wired; nil when neither is, which is what
|
|
222
|
+
// stops the renderer building a message nobody will read.
|
|
223
|
+
const renderDiagnostic = client?.reportRenderDiagnostic || props.onDiagnostic
|
|
224
|
+
? (message) => {
|
|
225
|
+
client?.reportRenderDiagnostic?.(message);
|
|
226
|
+
props.onDiagnostic?.(message);
|
|
227
|
+
}
|
|
228
|
+
: undefined;
|
|
229
|
+
if (blocks) {
|
|
230
|
+
return (React.createElement(RevnixBlockPaywall, { doc: blocks, packages: props.packages, onPurchase: handlePurchase, selectedPackageId: props.selectedPackageId, onSelectPackage: handleSelect, highlightPackageId: config.highlightPackageId, heroImageUrl: config.heroImageUrl, onRestore: handleRestore, onTerms: props.onTerms, onPrivacy: props.onPrivacy, onClose: handleClose, footer: config.footer, gradient: props.gradient, blur: props.blur, fontFamilyMap: props.fontFamilyMap, locale: props.locale, onDiagnostic: renderDiagnostic, style: props.style, loading: props.loading }));
|
|
231
|
+
}
|
|
232
|
+
return (React.createElement(ClassicPaywall, { ...props, onPurchase: handlePurchase, onSelectPackage: handleSelect, onRestore: handleRestore, onClose: handleClose }));
|
|
233
|
+
}
|
|
234
|
+
/** REV-263: the text an interaction report carries for a thrown error. Kept
|
|
235
|
+
* short — the server bounds `message` at 1024 chars and nothing groups by
|
|
236
|
+
* it; the CODE is the field that gets aggregated. */
|
|
237
|
+
function messageOf(err) {
|
|
238
|
+
if (err instanceof Error)
|
|
239
|
+
return err.message;
|
|
240
|
+
if (typeof err === "string")
|
|
241
|
+
return err;
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
/** The dismiss affordance the classic layouts get (REV-252).
|
|
245
|
+
*
|
|
246
|
+
* The nine `template` layouts have the same problem the designed ones had —
|
|
247
|
+
* nothing on the screen closes them — and `onClose` is a prop of the shared
|
|
248
|
+
* RevnixPaywall, so a host that wires it must get a close on either path
|
|
249
|
+
* rather than silently nothing. Classic layouts author no elements of their
|
|
250
|
+
* own, so there is never a design chip to suppress: the rule reduces to
|
|
251
|
+
* "draw it whenever the host wired a handler".
|
|
252
|
+
*
|
|
253
|
+
* Tinted from the theme's own ink so it works in the light scheme too. */
|
|
254
|
+
function ClassicClose({ theme, onPress, }) {
|
|
255
|
+
return (React.createElement(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close", hitSlop: 10, onPress: onPress, style: {
|
|
256
|
+
position: "absolute",
|
|
257
|
+
top: 14,
|
|
258
|
+
right: 14,
|
|
259
|
+
zIndex: 50,
|
|
260
|
+
width: 30,
|
|
261
|
+
height: 30,
|
|
262
|
+
borderRadius: 15,
|
|
263
|
+
alignItems: "center",
|
|
264
|
+
justifyContent: "center",
|
|
265
|
+
backgroundColor: withAlpha(theme.textPrimary, 0.14),
|
|
266
|
+
} },
|
|
267
|
+
React.createElement(Text, { style: { color: theme.textPrimary, fontSize: 17, lineHeight: 20 } }, "\u00D7")));
|
|
268
|
+
}
|
|
269
|
+
/** The nine `template` layouts — every paywall published before the block
|
|
270
|
+
* builder, and the fallback for any config that carries no block tree. */
|
|
271
|
+
function ClassicPaywall(props) {
|
|
272
|
+
const { config, packages, loading } = props;
|
|
273
|
+
// Base scheme comes from the dashboard config (mode: dark|light, absent =
|
|
274
|
+
// dark for legacy configs); the host app's explicit theme prop wins on top.
|
|
275
|
+
const baseTheme = config.mode === "light" ? LIGHT_PAYWALL_THEME : DEFAULT_PAYWALL_THEME;
|
|
276
|
+
const theme = { ...baseTheme, ...props.theme };
|
|
277
|
+
const accent = config.accent ?? DEFAULT_ACCENT;
|
|
278
|
+
const cardBg = CARD_BG[config.mode === "light" ? "light" : "dark"];
|
|
279
|
+
const layout = config.template;
|
|
280
|
+
// Same template semantics as the dashboard preview: "minimal" shows only
|
|
281
|
+
// the highlighted package; feature bullets render on the feature layouts.
|
|
282
|
+
const shown = layout === "minimal" && config.highlightPackageId
|
|
283
|
+
? packages.filter((p) => p.packageId === config.highlightPackageId)
|
|
284
|
+
: packages;
|
|
285
|
+
const [internalSelected, setInternalSelected] = React.useState(undefined);
|
|
286
|
+
const fallbackSelected = shown.find((p) => p.packageId === config.highlightPackageId)?.packageId ??
|
|
287
|
+
shown[0]?.packageId;
|
|
288
|
+
const selectedId = props.selectedPackageId ??
|
|
289
|
+
(internalSelected !== undefined &&
|
|
290
|
+
shown.some((p) => p.packageId === internalSelected)
|
|
291
|
+
? internalSelected
|
|
292
|
+
: fallbackSelected);
|
|
293
|
+
const select = (packageId) => {
|
|
294
|
+
setInternalSelected(packageId);
|
|
295
|
+
props.onSelectPackage?.(packageId);
|
|
296
|
+
};
|
|
297
|
+
// Footer links are dashboard-configured (config.footer); a legacy config
|
|
298
|
+
// without the field keeps the original always-on footer. Explicit host
|
|
299
|
+
// handlers win over config URLs — the app knows best how to open its own
|
|
300
|
+
// legal pages (in-app browser etc.); the URL is the no-handler fallback.
|
|
301
|
+
const footer = config.footer;
|
|
302
|
+
const openUrl = (url) => url ? () => void Linking.openURL(url) : undefined;
|
|
303
|
+
const footerItems = [
|
|
304
|
+
{
|
|
305
|
+
show: footer?.showRestore ?? true,
|
|
306
|
+
label: "Restore",
|
|
307
|
+
onPress: props.onRestore,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
show: footer?.showTerms ?? true,
|
|
311
|
+
label: "Terms",
|
|
312
|
+
onPress: props.onTerms ?? openUrl(footer?.termsUrl),
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
show: footer?.showPrivacy ?? true,
|
|
316
|
+
label: "Privacy",
|
|
317
|
+
onPress: props.onPrivacy ?? openUrl(footer?.privacyUrl),
|
|
318
|
+
},
|
|
319
|
+
].filter((item) => item.show);
|
|
320
|
+
// ——— shared blocks (each layout composes a subset; keep every block in
|
|
321
|
+
// lockstep with the same-named block in PaywallPhonePreview.tsx) ———
|
|
322
|
+
// Classic header: hero image card (or accent icon tile) + centered
|
|
323
|
+
// headline/subheadline. Byte-identical to the pre-layout renderer.
|
|
324
|
+
const heroOrIcon = config.heroImageUrl ? (React.createElement(Image, { source: { uri: config.heroImageUrl }, style: styles.heroImage, resizeMode: "cover", accessibilityIgnoresInvertColors: true })) : (React.createElement(View, { style: [styles.iconTile, { backgroundColor: `${accent}26` }] },
|
|
325
|
+
React.createElement(Text, { style: [styles.iconGlyph, { color: accent }] }, "\u25C6")));
|
|
326
|
+
const headlineBlock = (React.createElement(React.Fragment, null,
|
|
327
|
+
React.createElement(Text, { style: [styles.headline, { color: theme.textPrimary }] }, config.headline),
|
|
328
|
+
config.subheadline ? (React.createElement(Text, { style: [styles.subheadline, { color: theme.textSecondary }] }, config.subheadline)) : null));
|
|
329
|
+
// Hero layout banner: full-width media with a content-safe scrim overlay
|
|
330
|
+
// carrying the headline/subheadline (always light-on-scrim). Falls back to
|
|
331
|
+
// an accent field with the brand glyph when no hero image is set.
|
|
332
|
+
const heroBanner = (React.createElement(View, { style: styles.heroBanner },
|
|
333
|
+
config.heroImageUrl ? (React.createElement(Image, { source: { uri: config.heroImageUrl }, style: StyleSheet.absoluteFill, resizeMode: "cover", accessibilityIgnoresInvertColors: true })) : (React.createElement(View, { style: [StyleSheet.absoluteFill, { backgroundColor: accent }] })),
|
|
334
|
+
!config.heroImageUrl ? (React.createElement(View, { style: styles.heroBannerGlyphWrap, pointerEvents: "none" },
|
|
335
|
+
React.createElement(Text, { style: styles.heroBannerGlyph }, "\u25C6"))) : null,
|
|
336
|
+
React.createElement(View, { style: styles.heroBannerScrim },
|
|
337
|
+
React.createElement(Text, { style: styles.heroBannerHeadline }, config.headline),
|
|
338
|
+
config.subheadline ? (React.createElement(Text, { style: styles.heroBannerSub }, config.subheadline)) : null)));
|
|
339
|
+
// Legacy feature bullets (feature-list layout).
|
|
340
|
+
const featureBullets = config.features.length > 0 ? (React.createElement(View, { style: styles.features }, config.features.map((f, i) => (React.createElement(View, { key: i, style: styles.featureRow },
|
|
341
|
+
React.createElement(Text, { style: [styles.featureIcon, { color: accent }] }, f.icon || "✓"),
|
|
342
|
+
React.createElement(View, { style: styles.featureBody },
|
|
343
|
+
React.createElement(Text, { style: [styles.featureTitle, { color: theme.textPrimary }] }, f.title),
|
|
344
|
+
f.description ? (React.createElement(Text, { style: [
|
|
345
|
+
styles.featureDescription,
|
|
346
|
+
{ color: theme.textSecondary },
|
|
347
|
+
] }, f.description)) : null)))))) : null;
|
|
348
|
+
// Compact single-line checks (hero banner body, plans checklist).
|
|
349
|
+
const featureChecks = config.features.length > 0 ? (React.createElement(View, { style: styles.checks }, config.features.map((f, i) => (React.createElement(View, { key: i, style: styles.checkRow },
|
|
350
|
+
React.createElement(Text, { style: [styles.checkIcon, { color: accent }] }, f.icon || "✓"),
|
|
351
|
+
React.createElement(Text, { style: [styles.checkTitle, { color: theme.textPrimary }] }, f.title)))))) : null;
|
|
352
|
+
// Trial timeline: icon dots joined by an accent rail; the first step is
|
|
353
|
+
// filled solid ("you are here"), later steps are tinted.
|
|
354
|
+
const timelineBlock = config.features.length > 0 ? (React.createElement(View, { style: styles.timeline }, config.features.map((f, i) => {
|
|
355
|
+
const last = i === config.features.length - 1;
|
|
356
|
+
return (React.createElement(View, { key: i, style: styles.timelineRow },
|
|
357
|
+
React.createElement(View, { style: styles.timelineRail },
|
|
358
|
+
React.createElement(View, { style: [
|
|
359
|
+
styles.timelineDot,
|
|
360
|
+
i === 0
|
|
361
|
+
? { backgroundColor: accent }
|
|
362
|
+
: { backgroundColor: `${accent}26` },
|
|
363
|
+
] },
|
|
364
|
+
React.createElement(Text, { style: [
|
|
365
|
+
styles.timelineDotIcon,
|
|
366
|
+
{ color: i === 0 ? theme.accentInk : accent },
|
|
367
|
+
] }, f.icon || "✓")),
|
|
368
|
+
!last ? (React.createElement(View, { style: [
|
|
369
|
+
styles.timelineLine,
|
|
370
|
+
{ backgroundColor: `${accent}40` },
|
|
371
|
+
] })) : null),
|
|
372
|
+
React.createElement(View, { style: [styles.timelineBody, !last && styles.timelineGap] },
|
|
373
|
+
React.createElement(Text, { style: [styles.featureTitle, { color: theme.textPrimary }] }, f.title),
|
|
374
|
+
f.description ? (React.createElement(Text, { style: [
|
|
375
|
+
styles.featureDescription,
|
|
376
|
+
{ color: theme.textSecondary },
|
|
377
|
+
] }, f.description)) : null)));
|
|
378
|
+
}))) : null;
|
|
379
|
+
// Feature grid: two-column soft cards with an accent icon tile each.
|
|
380
|
+
const featureGrid = config.features.length > 0 ? (React.createElement(View, { style: styles.grid }, config.features.map((f, i) => (React.createElement(View, { key: i, style: [styles.gridCard, { backgroundColor: cardBg }] },
|
|
381
|
+
React.createElement(View, { style: [styles.gridIconTile, { backgroundColor: `${accent}26` }] },
|
|
382
|
+
React.createElement(Text, { style: [styles.gridIcon, { color: accent }] }, f.icon || "✓")),
|
|
383
|
+
React.createElement(Text, { style: [styles.gridTitle, { color: theme.textPrimary }] }, f.title),
|
|
384
|
+
f.description ? (React.createElement(Text, { style: [styles.gridDescription, { color: theme.textSecondary }] }, f.description)) : null))))) : null;
|
|
385
|
+
// Reveal: onboarding-style numbered benefit cards + progress dots.
|
|
386
|
+
const progressDots = (React.createElement(View, { style: styles.dots }, [0, 1, 2].map((i) => (React.createElement(View, { key: i, style: [
|
|
387
|
+
styles.dot,
|
|
388
|
+
{ backgroundColor: i === 0 ? accent : `${accent}40` },
|
|
389
|
+
] })))));
|
|
390
|
+
const revealCards = config.features.length > 0 ? (React.createElement(View, { style: styles.reveal }, config.features.map((f, i) => (React.createElement(View, { key: i, style: [styles.revealCard, { backgroundColor: cardBg }] },
|
|
391
|
+
React.createElement(View, { style: [styles.revealChip, { backgroundColor: `${accent}26` }] },
|
|
392
|
+
React.createElement(Text, { style: [styles.revealNumber, { color: accent }] }, i + 1)),
|
|
393
|
+
React.createElement(View, { style: styles.featureBody },
|
|
394
|
+
React.createElement(Text, { style: [styles.revealTitle, { color: theme.textPrimary }] }, f.title),
|
|
395
|
+
f.description ? (React.createElement(Text, { style: [
|
|
396
|
+
styles.featureDescription,
|
|
397
|
+
{ color: theme.textSecondary },
|
|
398
|
+
] }, f.description)) : null)))))) : null;
|
|
399
|
+
// Social proof card: star row (+ numeric rating), quote, attribution.
|
|
400
|
+
const review = config.review;
|
|
401
|
+
const hasReviewCard = review !== undefined &&
|
|
402
|
+
(review.rating !== undefined || Boolean(review.quote));
|
|
403
|
+
const reviewCard = hasReviewCard ? (React.createElement(View, { style: [styles.reviewCard, { backgroundColor: cardBg }] },
|
|
404
|
+
review?.rating !== undefined ? (React.createElement(View, { style: styles.starsRow },
|
|
405
|
+
[1, 2, 3, 4, 5].map((n) => (React.createElement(Text, { key: n, style: [
|
|
406
|
+
styles.star,
|
|
407
|
+
{
|
|
408
|
+
color: n <= Math.round(review.rating ?? 0)
|
|
409
|
+
? accent
|
|
410
|
+
: theme.border,
|
|
411
|
+
},
|
|
412
|
+
] }, "\u2605"))),
|
|
413
|
+
React.createElement(Text, { style: [styles.starRating, { color: theme.textPrimary }] }, String(review.rating)))) : null,
|
|
414
|
+
review?.quote ? (React.createElement(Text, { style: [styles.reviewQuote, { color: theme.textPrimary }] },
|
|
415
|
+
"\u201C",
|
|
416
|
+
review.quote,
|
|
417
|
+
"\u201D")) : null,
|
|
418
|
+
review?.author ? (React.createElement(Text, { style: [styles.reviewAuthor, { color: theme.textSecondary }] }, review.author)) : null)) : null;
|
|
419
|
+
const countLine = review?.count ? (React.createElement(Text, { style: [styles.countLine, { color: theme.textSecondary }] }, review.count)) : null;
|
|
420
|
+
// Offer bits: urgency line above the CTA; anchor price struck through on
|
|
421
|
+
// the highlighted package.
|
|
422
|
+
const urgencyLine = config.offer?.urgencyText ? (React.createElement(Text, { style: [styles.urgency, { color: accent }] }, config.offer.urgencyText)) : null;
|
|
423
|
+
// The anchor is dashboard free text while priceLabel is the store's
|
|
424
|
+
// localized price — never pair them when their currency symbols disagree,
|
|
425
|
+
// or a EUR customer would see a struck-through USD anchor next to the real
|
|
426
|
+
// charge (this file's "display never disagrees with the charge" rule;
|
|
427
|
+
// kept in lockstep with the preview's anchorPriceFor). Symbol-less
|
|
428
|
+
// anchors can't be judged and pass through.
|
|
429
|
+
const anchorPriceFor = (priceLabel) => {
|
|
430
|
+
const anchor = config.offer?.strikethroughPrice;
|
|
431
|
+
if (!anchor)
|
|
432
|
+
return undefined;
|
|
433
|
+
const symbol = anchor.match(CURRENCY_SYMBOL)?.[0];
|
|
434
|
+
return symbol === undefined || priceLabel.includes(symbol)
|
|
435
|
+
? anchor
|
|
436
|
+
: undefined;
|
|
437
|
+
};
|
|
438
|
+
const priceLine = (pkg, highlighted) => {
|
|
439
|
+
const anchorPrice = highlighted
|
|
440
|
+
? anchorPriceFor(pkg.priceLabel)
|
|
441
|
+
: undefined;
|
|
442
|
+
return anchorPrice ? (React.createElement(View, { style: styles.priceRow },
|
|
443
|
+
React.createElement(Text, { style: [styles.anchorPrice, { color: theme.textFaint }] }, anchorPrice),
|
|
444
|
+
React.createElement(Text, { style: [styles.packagePrice, { color: theme.textSecondary }] }, pkg.priceLabel))) : (React.createElement(Text, { style: [styles.packagePrice, { color: theme.textSecondary }] }, pkg.priceLabel));
|
|
445
|
+
};
|
|
446
|
+
// Standard package rows (all layouts except plans columns / offer
|
|
447
|
+
// spotlight). `withBadge=false` suppresses the row badge where the layout
|
|
448
|
+
// already presents the badge elsewhere (offer pill).
|
|
449
|
+
const packageRows = (list, withBadge = true) => (React.createElement(View, { style: styles.packages }, list.map((pkg) => {
|
|
450
|
+
const selected = pkg.packageId === selectedId;
|
|
451
|
+
const highlighted = pkg.packageId === config.highlightPackageId;
|
|
452
|
+
const badged = withBadge && highlighted && Boolean(config.badgeText);
|
|
453
|
+
return (React.createElement(Pressable, { key: pkg.packageId, onPress: () => select(pkg.packageId), accessibilityRole: "radio", accessibilityState: { selected }, style: [
|
|
454
|
+
styles.packageRow,
|
|
455
|
+
selected
|
|
456
|
+
? { borderColor: accent, borderWidth: 2 }
|
|
457
|
+
: { borderColor: theme.border, borderWidth: 1 },
|
|
458
|
+
] },
|
|
459
|
+
badged ? (React.createElement(View, { style: [styles.badge, { backgroundColor: accent }] },
|
|
460
|
+
React.createElement(Text, { numberOfLines: 1, style: [styles.badgeText, { color: theme.accentInk }] }, config.badgeText))) : null,
|
|
461
|
+
React.createElement(Text, { style: [styles.packageTitle, { color: theme.textPrimary }] }, pkg.title),
|
|
462
|
+
priceLine(pkg, highlighted)));
|
|
463
|
+
})));
|
|
464
|
+
// Plans layout: packages side by side as tier columns; the highlighted
|
|
465
|
+
// tier carries the badge pill inside the column. Columns stay readable up
|
|
466
|
+
// to 3 — beyond that the layout falls back to stacked rows (never drop a
|
|
467
|
+
// purchasable package), same rule as the dashboard preview.
|
|
468
|
+
const usePlanColumns = shown.length <= 3;
|
|
469
|
+
const planColumns = (React.createElement(View, { style: styles.planRow }, shown.map((pkg) => {
|
|
470
|
+
const selected = pkg.packageId === selectedId;
|
|
471
|
+
const highlighted = pkg.packageId === config.highlightPackageId;
|
|
472
|
+
const anchorPrice = highlighted
|
|
473
|
+
? anchorPriceFor(pkg.priceLabel)
|
|
474
|
+
: undefined;
|
|
475
|
+
return (React.createElement(Pressable, { key: pkg.packageId, onPress: () => select(pkg.packageId), accessibilityRole: "radio", accessibilityState: { selected }, style: [
|
|
476
|
+
styles.planColumn,
|
|
477
|
+
selected
|
|
478
|
+
? { borderColor: accent, borderWidth: 2 }
|
|
479
|
+
: { borderColor: theme.border, borderWidth: 1 },
|
|
480
|
+
] },
|
|
481
|
+
highlighted && config.badgeText ? (React.createElement(View, { style: [styles.planBadge, { backgroundColor: accent }] },
|
|
482
|
+
React.createElement(Text, { numberOfLines: 1, style: [styles.badgeText, { color: theme.accentInk }] }, config.badgeText))) : null,
|
|
483
|
+
React.createElement(Text, { style: [styles.planTitle, { color: theme.textPrimary }] }, pkg.title),
|
|
484
|
+
anchorPrice ? (React.createElement(Text, { style: [styles.anchorPrice, { color: theme.textFaint }] }, anchorPrice)) : null,
|
|
485
|
+
React.createElement(Text, { style: [styles.planPrice, { color: theme.textPrimary }] }, pkg.priceLabel)));
|
|
486
|
+
})));
|
|
487
|
+
// Offer layout: the badge becomes a large centered pill; the highlighted
|
|
488
|
+
// (else first) package renders as a spotlight card with the anchor price.
|
|
489
|
+
const spotlightPkg = shown.find((p) => p.packageId === config.highlightPackageId) ?? shown[0];
|
|
490
|
+
const offerPill = config.badgeText ? (React.createElement(View, { style: [styles.offerPill, { backgroundColor: accent }] },
|
|
491
|
+
React.createElement(Text, { numberOfLines: 1, style: [styles.offerPillText, { color: theme.accentInk }] }, config.badgeText))) : null;
|
|
492
|
+
const spotlightAnchor = spotlightPkg
|
|
493
|
+
? anchorPriceFor(spotlightPkg.priceLabel)
|
|
494
|
+
: undefined;
|
|
495
|
+
const offerSpotlight = spotlightPkg ? (React.createElement(React.Fragment, null,
|
|
496
|
+
React.createElement(Pressable, { onPress: () => select(spotlightPkg.packageId), accessibilityRole: "radio", accessibilityState: {
|
|
497
|
+
selected: spotlightPkg.packageId === selectedId,
|
|
498
|
+
}, style: [
|
|
499
|
+
styles.spotlight,
|
|
500
|
+
{
|
|
501
|
+
borderColor: spotlightPkg.packageId === selectedId ? accent : theme.border,
|
|
502
|
+
},
|
|
503
|
+
] },
|
|
504
|
+
React.createElement(Text, { style: [styles.spotlightTitle, { color: theme.textPrimary }] }, spotlightPkg.title),
|
|
505
|
+
React.createElement(View, { style: styles.priceRowCenter },
|
|
506
|
+
spotlightAnchor ? (React.createElement(Text, { style: [styles.spotlightAnchor, { color: theme.textFaint }] }, spotlightAnchor)) : null,
|
|
507
|
+
React.createElement(Text, { style: [styles.spotlightPrice, { color: theme.textPrimary }] }, spotlightPkg.priceLabel))),
|
|
508
|
+
shown.length > 1
|
|
509
|
+
? packageRows(shown.filter((p) => p.packageId !== spotlightPkg.packageId), false)
|
|
510
|
+
: null)) : null;
|
|
511
|
+
const cta = (React.createElement(Pressable, { onPress: () => {
|
|
512
|
+
if (!loading && selectedId)
|
|
513
|
+
props.onPurchase(selectedId);
|
|
514
|
+
}, disabled: loading || !selectedId, accessibilityRole: "button", style: [styles.cta, { backgroundColor: accent }] }, loading ? (React.createElement(ActivityIndicator, { color: theme.accentInk })) : (React.createElement(Text, { style: [styles.ctaLabel, { color: theme.accentInk }] }, config.ctaLabel))));
|
|
515
|
+
const footerBlock = footerItems.length > 0 ? (React.createElement(View, { style: styles.footer }, footerItems.map((item, i) => (React.createElement(React.Fragment, { key: item.label },
|
|
516
|
+
i > 0 ? (React.createElement(Text, { style: [styles.footerDot, { color: theme.textFaint }] }, " · ")) : null,
|
|
517
|
+
React.createElement(Text, { onPress: item.onPress, style: [styles.footerLink, { color: theme.textFaint }] }, item.label)))))) : null;
|
|
518
|
+
// Tail shared by every layout: urgency → CTA → count → footer.
|
|
519
|
+
const tail = (React.createElement(React.Fragment, null,
|
|
520
|
+
urgencyLine,
|
|
521
|
+
cta,
|
|
522
|
+
countLine,
|
|
523
|
+
footerBlock));
|
|
524
|
+
let body;
|
|
525
|
+
switch (layout) {
|
|
526
|
+
case "hero":
|
|
527
|
+
body = (React.createElement(React.Fragment, null,
|
|
528
|
+
heroBanner,
|
|
529
|
+
featureChecks,
|
|
530
|
+
reviewCard,
|
|
531
|
+
packageRows(shown),
|
|
532
|
+
tail));
|
|
533
|
+
break;
|
|
534
|
+
case "timeline":
|
|
535
|
+
body = (React.createElement(React.Fragment, null,
|
|
536
|
+
heroOrIcon,
|
|
537
|
+
headlineBlock,
|
|
538
|
+
timelineBlock,
|
|
539
|
+
reviewCard,
|
|
540
|
+
packageRows(shown),
|
|
541
|
+
tail));
|
|
542
|
+
break;
|
|
543
|
+
case "plans":
|
|
544
|
+
body = (React.createElement(React.Fragment, null,
|
|
545
|
+
heroOrIcon,
|
|
546
|
+
headlineBlock,
|
|
547
|
+
usePlanColumns ? planColumns : packageRows(shown),
|
|
548
|
+
featureChecks,
|
|
549
|
+
reviewCard,
|
|
550
|
+
tail));
|
|
551
|
+
break;
|
|
552
|
+
case "feature-grid":
|
|
553
|
+
body = (React.createElement(React.Fragment, null,
|
|
554
|
+
heroOrIcon,
|
|
555
|
+
headlineBlock,
|
|
556
|
+
featureGrid,
|
|
557
|
+
reviewCard,
|
|
558
|
+
packageRows(shown),
|
|
559
|
+
tail));
|
|
560
|
+
break;
|
|
561
|
+
case "offer":
|
|
562
|
+
body = (React.createElement(React.Fragment, null,
|
|
563
|
+
heroOrIcon,
|
|
564
|
+
offerPill,
|
|
565
|
+
headlineBlock,
|
|
566
|
+
offerSpotlight,
|
|
567
|
+
reviewCard,
|
|
568
|
+
tail));
|
|
569
|
+
break;
|
|
570
|
+
case "reveal":
|
|
571
|
+
body = (React.createElement(React.Fragment, null,
|
|
572
|
+
progressDots,
|
|
573
|
+
heroOrIcon,
|
|
574
|
+
headlineBlock,
|
|
575
|
+
revealCards,
|
|
576
|
+
packageRows(shown),
|
|
577
|
+
tail));
|
|
578
|
+
break;
|
|
579
|
+
default:
|
|
580
|
+
// focus | feature-list | minimal — the original structure, unchanged
|
|
581
|
+
// for legacy configs (review/offer blocks only exist when configured).
|
|
582
|
+
body = (React.createElement(React.Fragment, null,
|
|
583
|
+
heroOrIcon,
|
|
584
|
+
headlineBlock,
|
|
585
|
+
layout === "feature-list" ? featureBullets : null,
|
|
586
|
+
reviewCard,
|
|
587
|
+
packageRows(shown),
|
|
588
|
+
tail));
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
return (React.createElement(View, { style: [styles.root, { backgroundColor: theme.background }, props.style] },
|
|
592
|
+
React.createElement(ScrollView, { contentContainerStyle: styles.scrollContent, showsVerticalScrollIndicator: false, contentInsetAdjustmentBehavior: "automatic" },
|
|
593
|
+
React.createElement(View, { style: styles.content }, body)),
|
|
594
|
+
props.onClose ? (React.createElement(ClassicClose, { theme: theme, onPress: props.onClose })) : null));
|
|
595
|
+
}
|
|
596
|
+
// PROPORTIONS mirror PaywallPhonePreview.tsx (the dashboard's miniature phone
|
|
597
|
+
// mock); SIZES are scaled up to native-device typography — the preview is a
|
|
598
|
+
// scaled-down mock, so copying its pixel values verbatim rendered a paywall
|
|
599
|
+
// that read ~30% too small on a real screen.
|
|
600
|
+
//
|
|
601
|
+
// Responsive rules:
|
|
602
|
+
// · scrollContent flexGrow+center — content sits vertically centered on tall
|
|
603
|
+
// screens (no dead bottom half) and scrolls normally when it overflows.
|
|
604
|
+
// · content maxWidth — on tablets/wide screens the column stays a readable
|
|
605
|
+
// width, horizontally centered, instead of stretching edge to edge.
|
|
606
|
+
const styles = StyleSheet.create({
|
|
607
|
+
root: { flex: 1 },
|
|
608
|
+
scrollContent: {
|
|
609
|
+
flexGrow: 1,
|
|
610
|
+
justifyContent: "center",
|
|
611
|
+
paddingHorizontal: 24,
|
|
612
|
+
paddingTop: 28,
|
|
613
|
+
paddingBottom: 32,
|
|
614
|
+
},
|
|
615
|
+
content: {
|
|
616
|
+
width: "100%",
|
|
617
|
+
maxWidth: 440,
|
|
618
|
+
alignSelf: "center",
|
|
619
|
+
},
|
|
620
|
+
heroImage: {
|
|
621
|
+
alignSelf: "stretch",
|
|
622
|
+
height: 180,
|
|
623
|
+
borderRadius: 16,
|
|
624
|
+
marginTop: 8,
|
|
625
|
+
marginBottom: 22,
|
|
626
|
+
},
|
|
627
|
+
iconTile: {
|
|
628
|
+
alignSelf: "center",
|
|
629
|
+
width: 64,
|
|
630
|
+
height: 64,
|
|
631
|
+
borderRadius: 16,
|
|
632
|
+
alignItems: "center",
|
|
633
|
+
justifyContent: "center",
|
|
634
|
+
marginTop: 8,
|
|
635
|
+
marginBottom: 22,
|
|
636
|
+
},
|
|
637
|
+
iconGlyph: { fontSize: 27 },
|
|
638
|
+
headline: {
|
|
639
|
+
fontSize: 26,
|
|
640
|
+
fontWeight: "800",
|
|
641
|
+
textAlign: "center",
|
|
642
|
+
marginBottom: 10,
|
|
643
|
+
},
|
|
644
|
+
subheadline: {
|
|
645
|
+
fontSize: 15,
|
|
646
|
+
lineHeight: 21,
|
|
647
|
+
textAlign: "center",
|
|
648
|
+
marginBottom: 26,
|
|
649
|
+
},
|
|
650
|
+
// hero layout banner
|
|
651
|
+
heroBanner: {
|
|
652
|
+
alignSelf: "stretch",
|
|
653
|
+
minHeight: 260,
|
|
654
|
+
borderRadius: 20,
|
|
655
|
+
overflow: "hidden",
|
|
656
|
+
justifyContent: "flex-end",
|
|
657
|
+
marginTop: 8,
|
|
658
|
+
marginBottom: 22,
|
|
659
|
+
},
|
|
660
|
+
heroBannerGlyphWrap: {
|
|
661
|
+
...StyleSheet.absoluteFillObject,
|
|
662
|
+
alignItems: "center",
|
|
663
|
+
justifyContent: "center",
|
|
664
|
+
paddingBottom: 72,
|
|
665
|
+
},
|
|
666
|
+
heroBannerGlyph: { fontSize: 64, color: "rgba(255,255,255,0.35)" },
|
|
667
|
+
heroBannerScrim: {
|
|
668
|
+
backgroundColor: "rgba(0,0,0,0.45)",
|
|
669
|
+
paddingHorizontal: 18,
|
|
670
|
+
paddingVertical: 16,
|
|
671
|
+
},
|
|
672
|
+
heroBannerHeadline: { fontSize: 24, fontWeight: "800", color: "#ffffff" },
|
|
673
|
+
heroBannerSub: {
|
|
674
|
+
fontSize: 13.5,
|
|
675
|
+
lineHeight: 19,
|
|
676
|
+
color: "rgba(255,255,255,0.85)",
|
|
677
|
+
marginTop: 4,
|
|
678
|
+
},
|
|
679
|
+
features: { marginBottom: 26, gap: 14 },
|
|
680
|
+
featureRow: { flexDirection: "row", alignItems: "flex-start", gap: 12 },
|
|
681
|
+
featureIcon: { width: 24, fontSize: 15, lineHeight: 21 },
|
|
682
|
+
featureBody: { flex: 1 },
|
|
683
|
+
featureTitle: { fontSize: 15.5, fontWeight: "600", lineHeight: 21 },
|
|
684
|
+
featureDescription: { fontSize: 13, lineHeight: 18, marginTop: 1 },
|
|
685
|
+
// compact single-line checks
|
|
686
|
+
checks: { marginBottom: 24, gap: 10 },
|
|
687
|
+
checkRow: { flexDirection: "row", alignItems: "center", gap: 10 },
|
|
688
|
+
checkIcon: { width: 20, fontSize: 14 },
|
|
689
|
+
checkTitle: { flex: 1, fontSize: 14.5, fontWeight: "500", lineHeight: 20 },
|
|
690
|
+
// trial timeline
|
|
691
|
+
timeline: { marginBottom: 26 },
|
|
692
|
+
timelineRow: { flexDirection: "row", gap: 12 },
|
|
693
|
+
timelineRail: { width: 34, alignItems: "center" },
|
|
694
|
+
timelineDot: {
|
|
695
|
+
width: 34,
|
|
696
|
+
height: 34,
|
|
697
|
+
borderRadius: 17,
|
|
698
|
+
alignItems: "center",
|
|
699
|
+
justifyContent: "center",
|
|
700
|
+
},
|
|
701
|
+
timelineDotIcon: { fontSize: 14 },
|
|
702
|
+
timelineLine: { flex: 1, width: 2, marginVertical: 4 },
|
|
703
|
+
timelineBody: { flex: 1, paddingTop: 6 },
|
|
704
|
+
timelineGap: { paddingBottom: 22 },
|
|
705
|
+
// feature grid
|
|
706
|
+
grid: {
|
|
707
|
+
flexDirection: "row",
|
|
708
|
+
flexWrap: "wrap",
|
|
709
|
+
gap: 10,
|
|
710
|
+
marginBottom: 24,
|
|
711
|
+
},
|
|
712
|
+
gridCard: {
|
|
713
|
+
width: "47.5%",
|
|
714
|
+
flexGrow: 1,
|
|
715
|
+
borderRadius: 14,
|
|
716
|
+
padding: 13,
|
|
717
|
+
},
|
|
718
|
+
gridIconTile: {
|
|
719
|
+
width: 34,
|
|
720
|
+
height: 34,
|
|
721
|
+
borderRadius: 10,
|
|
722
|
+
alignItems: "center",
|
|
723
|
+
justifyContent: "center",
|
|
724
|
+
marginBottom: 9,
|
|
725
|
+
},
|
|
726
|
+
gridIcon: { fontSize: 15 },
|
|
727
|
+
gridTitle: { fontSize: 13.5, fontWeight: "600", lineHeight: 18 },
|
|
728
|
+
gridDescription: { fontSize: 12, lineHeight: 16, marginTop: 3 },
|
|
729
|
+
// reveal (onboarding-style)
|
|
730
|
+
dots: {
|
|
731
|
+
flexDirection: "row",
|
|
732
|
+
justifyContent: "center",
|
|
733
|
+
gap: 6,
|
|
734
|
+
marginBottom: 18,
|
|
735
|
+
},
|
|
736
|
+
dot: { width: 6, height: 6, borderRadius: 3 },
|
|
737
|
+
reveal: { gap: 10, marginBottom: 24 },
|
|
738
|
+
revealCard: {
|
|
739
|
+
flexDirection: "row",
|
|
740
|
+
alignItems: "flex-start",
|
|
741
|
+
gap: 12,
|
|
742
|
+
borderRadius: 14,
|
|
743
|
+
padding: 14,
|
|
744
|
+
},
|
|
745
|
+
revealChip: {
|
|
746
|
+
width: 26,
|
|
747
|
+
height: 26,
|
|
748
|
+
borderRadius: 13,
|
|
749
|
+
alignItems: "center",
|
|
750
|
+
justifyContent: "center",
|
|
751
|
+
},
|
|
752
|
+
revealNumber: { fontSize: 13, fontWeight: "700" },
|
|
753
|
+
revealTitle: { fontSize: 15, fontWeight: "600", lineHeight: 20 },
|
|
754
|
+
// social proof
|
|
755
|
+
reviewCard: {
|
|
756
|
+
borderRadius: 14,
|
|
757
|
+
padding: 14,
|
|
758
|
+
alignItems: "center",
|
|
759
|
+
marginBottom: 22,
|
|
760
|
+
},
|
|
761
|
+
starsRow: { flexDirection: "row", alignItems: "center" },
|
|
762
|
+
star: { fontSize: 15, marginHorizontal: 1 },
|
|
763
|
+
starRating: { fontSize: 13, fontWeight: "600", marginLeft: 6 },
|
|
764
|
+
reviewQuote: {
|
|
765
|
+
fontSize: 13.5,
|
|
766
|
+
lineHeight: 19,
|
|
767
|
+
fontStyle: "italic",
|
|
768
|
+
textAlign: "center",
|
|
769
|
+
marginTop: 8,
|
|
770
|
+
},
|
|
771
|
+
reviewAuthor: { fontSize: 12, marginTop: 6 },
|
|
772
|
+
countLine: { fontSize: 12.5, textAlign: "center", marginBottom: 14 },
|
|
773
|
+
// offer
|
|
774
|
+
urgency: {
|
|
775
|
+
fontSize: 13,
|
|
776
|
+
fontWeight: "600",
|
|
777
|
+
textAlign: "center",
|
|
778
|
+
marginBottom: 10,
|
|
779
|
+
},
|
|
780
|
+
offerPill: {
|
|
781
|
+
alignSelf: "center",
|
|
782
|
+
borderRadius: 20,
|
|
783
|
+
paddingHorizontal: 12,
|
|
784
|
+
paddingVertical: 5,
|
|
785
|
+
marginBottom: 14,
|
|
786
|
+
maxWidth: "80%",
|
|
787
|
+
},
|
|
788
|
+
offerPillText: { fontSize: 12, fontWeight: "700" },
|
|
789
|
+
spotlight: {
|
|
790
|
+
borderWidth: 2,
|
|
791
|
+
borderRadius: 16,
|
|
792
|
+
paddingHorizontal: 18,
|
|
793
|
+
paddingVertical: 18,
|
|
794
|
+
alignItems: "center",
|
|
795
|
+
marginBottom: 12,
|
|
796
|
+
},
|
|
797
|
+
spotlightTitle: { fontSize: 16, fontWeight: "600" },
|
|
798
|
+
priceRowCenter: {
|
|
799
|
+
flexDirection: "row",
|
|
800
|
+
alignItems: "baseline",
|
|
801
|
+
justifyContent: "center",
|
|
802
|
+
gap: 8,
|
|
803
|
+
marginTop: 6,
|
|
804
|
+
},
|
|
805
|
+
spotlightAnchor: {
|
|
806
|
+
fontSize: 15,
|
|
807
|
+
textDecorationLine: "line-through",
|
|
808
|
+
fontVariant: ["tabular-nums"],
|
|
809
|
+
},
|
|
810
|
+
spotlightPrice: {
|
|
811
|
+
fontSize: 24,
|
|
812
|
+
fontWeight: "800",
|
|
813
|
+
fontVariant: ["tabular-nums"],
|
|
814
|
+
},
|
|
815
|
+
// plans columns
|
|
816
|
+
planRow: {
|
|
817
|
+
flexDirection: "row",
|
|
818
|
+
alignItems: "stretch",
|
|
819
|
+
gap: 8,
|
|
820
|
+
marginBottom: 22,
|
|
821
|
+
},
|
|
822
|
+
planColumn: {
|
|
823
|
+
flex: 1,
|
|
824
|
+
borderRadius: 14,
|
|
825
|
+
paddingHorizontal: 8,
|
|
826
|
+
paddingVertical: 14,
|
|
827
|
+
alignItems: "center",
|
|
828
|
+
},
|
|
829
|
+
planBadge: {
|
|
830
|
+
borderRadius: 20,
|
|
831
|
+
paddingHorizontal: 8,
|
|
832
|
+
paddingVertical: 2,
|
|
833
|
+
marginBottom: 7,
|
|
834
|
+
maxWidth: "100%",
|
|
835
|
+
},
|
|
836
|
+
planTitle: {
|
|
837
|
+
fontSize: 14,
|
|
838
|
+
fontWeight: "600",
|
|
839
|
+
textAlign: "center",
|
|
840
|
+
lineHeight: 19,
|
|
841
|
+
},
|
|
842
|
+
planPrice: {
|
|
843
|
+
fontSize: 15,
|
|
844
|
+
fontWeight: "700",
|
|
845
|
+
fontVariant: ["tabular-nums"],
|
|
846
|
+
marginTop: 4,
|
|
847
|
+
},
|
|
848
|
+
packages: { gap: 12, marginBottom: 22 },
|
|
849
|
+
packageRow: {
|
|
850
|
+
borderRadius: 12,
|
|
851
|
+
paddingHorizontal: 16,
|
|
852
|
+
paddingVertical: 15,
|
|
853
|
+
},
|
|
854
|
+
badge: {
|
|
855
|
+
position: "absolute",
|
|
856
|
+
top: -11,
|
|
857
|
+
right: 14,
|
|
858
|
+
borderRadius: 20,
|
|
859
|
+
paddingHorizontal: 10,
|
|
860
|
+
paddingVertical: 3,
|
|
861
|
+
// Absolute views take no width constraint from the card — a long badge
|
|
862
|
+
// string used to grow past the card edge (kept in lockstep with the
|
|
863
|
+
// dashboard preview, which truncates the same way).
|
|
864
|
+
maxWidth: "80%",
|
|
865
|
+
},
|
|
866
|
+
badgeText: { fontSize: 11, fontWeight: "700" },
|
|
867
|
+
packageTitle: { fontSize: 16, fontWeight: "600" },
|
|
868
|
+
packagePrice: { fontSize: 14, fontVariant: ["tabular-nums"], marginTop: 2 },
|
|
869
|
+
priceRow: {
|
|
870
|
+
flexDirection: "row",
|
|
871
|
+
alignItems: "baseline",
|
|
872
|
+
gap: 6,
|
|
873
|
+
marginTop: 2,
|
|
874
|
+
},
|
|
875
|
+
anchorPrice: {
|
|
876
|
+
fontSize: 13,
|
|
877
|
+
textDecorationLine: "line-through",
|
|
878
|
+
fontVariant: ["tabular-nums"],
|
|
879
|
+
},
|
|
880
|
+
cta: {
|
|
881
|
+
borderRadius: 12,
|
|
882
|
+
padding: 16,
|
|
883
|
+
alignItems: "center",
|
|
884
|
+
marginBottom: 14,
|
|
885
|
+
},
|
|
886
|
+
ctaLabel: { fontSize: 16.5, fontWeight: "700" },
|
|
887
|
+
footer: { flexDirection: "row", justifyContent: "center" },
|
|
888
|
+
footerLink: { fontSize: 13, padding: 4 },
|
|
889
|
+
footerDot: { fontSize: 13, paddingVertical: 4 },
|
|
890
|
+
});
|