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,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
+ });