react-native-revnix 1.4.0

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