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,1542 @@
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
+
18
+ import * as React from "react";
19
+ import {
20
+ ActivityIndicator,
21
+ Alert,
22
+ Image,
23
+ Linking,
24
+ Pressable,
25
+ ScrollView,
26
+ StyleSheet,
27
+ Text,
28
+ View,
29
+ type StyleProp,
30
+ type ViewStyle,
31
+ } from "react-native";
32
+
33
+ import type {
34
+ PaywallConfig,
35
+ PaywallInteraction,
36
+ PaywallPurchaseOutcome,
37
+ } from "../types.js";
38
+ import { PREVIEW_PLACEMENT_KEY } from "../implicit.js";
39
+ import type { PaywallBlockDoc } from "../blocks/types.js";
40
+ import type { FontFamilyMap } from "../blocks/style.js";
41
+ import { withAlpha } from "../blocks/color.js";
42
+ import {
43
+ RevnixBlockPaywall,
44
+ type RevnixBlurComponent,
45
+ type RevnixGradientComponent,
46
+ } from "./RevnixBlockPaywall.js";
47
+
48
+ /** `config.blocks` is opaque on the core config (the non-UI entrypoint must
49
+ * not carry renderer types), and it arrives straight off the wire — so it is
50
+ * checked here rather than trusted. A malformed doc falls through to the
51
+ * classic layouts instead of rendering a broken screen. */
52
+ function asBlockDoc(value: unknown): PaywallBlockDoc | null {
53
+ if (typeof value !== "object" || value === null) return null;
54
+ const doc = value as Partial<PaywallBlockDoc>;
55
+ return Array.isArray(doc.blocks) &&
56
+ // A background is the legacy CSS string OR the layered spec (ground /
57
+ // photo / scrim). Both are valid documents; only a missing or wrongly
58
+ // typed one falls through to the classic layouts.
59
+ (typeof doc.background === "string" ||
60
+ (typeof doc.background === "object" && doc.background !== null)) &&
61
+ typeof doc.textColor === "string" &&
62
+ typeof doc.accent === "string" &&
63
+ typeof doc.accentInk === "string"
64
+ ? (doc as PaywallBlockDoc)
65
+ : null;
66
+ }
67
+
68
+ /** One purchasable row. `priceLabel` must come from the store (localized). */
69
+ export interface RevnixPaywallPackage {
70
+ packageId: string;
71
+ title: string;
72
+ priceLabel: string;
73
+ /** Renewal cycle ("monthly", "annual", …) from the catalog product. Only
74
+ * designed (block) paywalls read it, to resolve the {period},
75
+ * {period_short}, {price_per_month} and {save_percent} tags — a tag with
76
+ * no cycle behind it renders as written rather than guessed. */
77
+ period?: string;
78
+ /** Price in minor units, for the same per-month and saving maths. */
79
+ amountMinor?: number;
80
+ /** ISO currency of `amountMinor`. */
81
+ currency?: string;
82
+ /** REV-263: the catalog product behind this package. Only telemetry reads
83
+ * it — a `selected` or `purchase_started` report names the plan the way
84
+ * the rest of the ledger does. Optional: without it the interaction is
85
+ * still reported, just with no plan attached (the package id would be
86
+ * worse — it would look like a product that does not exist). */
87
+ productId?: string;
88
+ }
89
+
90
+ /** Colors the paywall renders with. Defaults mirror the dashboard preview
91
+ * chrome (a fixed dark screen) — override to match the host app's theme. */
92
+ export interface RevnixPaywallTheme {
93
+ background: string;
94
+ textPrimary: string;
95
+ textSecondary: string;
96
+ textFaint: string;
97
+ border: string;
98
+ /** Text color on accent-filled surfaces (CTA, badge). */
99
+ accentInk: string;
100
+ }
101
+
102
+ export const DEFAULT_PAYWALL_THEME: RevnixPaywallTheme = {
103
+ background: "#0f1116",
104
+ textPrimary: "#ffffff",
105
+ textSecondary: "#9aa0a8",
106
+ textFaint: "#6b7078",
107
+ border: "#2a2e36",
108
+ accentInk: "#0a0b0d",
109
+ };
110
+
111
+ /** Base theme when the dashboard config sets `mode: "light"`. Keep in
112
+ * lockstep with the builder preview (PaywallPhonePreview SCREEN_PALETTES). */
113
+ export const LIGHT_PAYWALL_THEME: RevnixPaywallTheme = {
114
+ background: "#ffffff",
115
+ textPrimary: "#16181d",
116
+ textSecondary: "#5b6068",
117
+ textFaint: "#9aa0a8",
118
+ border: "#e2e5ea",
119
+ accentInk: "#ffffff",
120
+ };
121
+
122
+ const DEFAULT_ACCENT = "#6478ff";
123
+
124
+ // Currency symbols the anchor-price guard recognizes (majors; a symbol-less
125
+ // anchor can't be judged and renders as entered).
126
+ const CURRENCY_SYMBOL = /[$€£¥₹₩₽₺₫₪฿₴₦₱]/;
127
+
128
+ // Soft card surface used by the feature-grid / reveal / review blocks. Not
129
+ // part of the public theme — derived from the config's mode, in lockstep
130
+ // with the dashboard preview's SCREEN_PALETTES.card.
131
+ const CARD_BG = { dark: "#181b22", light: "#f4f5f7" } as const;
132
+
133
+ export interface RevnixPaywallProps {
134
+ config: PaywallConfig;
135
+ packages: RevnixPaywallPackage[];
136
+ /** Called with the selected packageId when the CTA is pressed.
137
+ *
138
+ * REV-263: passing `client` reports `paywall.purchase_started` the moment
139
+ * this fires. The OUTCOME is yours — only the host performs the store
140
+ * call — so to have the paywall report it too, return it (or a promise of
141
+ * it): `cancelled` becomes `paywall.purchase_abandoned`, `failed` becomes
142
+ * `paywall.purchase_failed`, and a promise that REJECTS is reported as a
143
+ * failure carrying the error's message. Returning nothing keeps today's
144
+ * behavior exactly — the start is reported, the outcome is not, and you
145
+ * can still report it yourself with `client.logPaywallEvent`. */
146
+ onPurchase: (
147
+ packageId: string
148
+ ) => void | PaywallPurchaseOutcome | Promise<PaywallPurchaseOutcome | void>;
149
+ /** Controlled selection; omit to let the paywall manage it (initial
150
+ * selection is the config's highlight package, else the first package). */
151
+ selectedPackageId?: string;
152
+ onSelectPackage?: (packageId: string) => void;
153
+ /** Renders a spinner in the CTA and disables purchasing. */
154
+ loading?: boolean;
155
+ onRestore?: () => void;
156
+ onTerms?: () => void;
157
+ onPrivacy?: () => void;
158
+ /** Called when the customer dismisses the paywall (REV-252) — either by
159
+ * tapping an element the design marks as the close, or by the close button
160
+ * the renderer supplies when the design authors none.
161
+ *
162
+ * Dismissal is the HOST's to perform: the paywall does not unmount itself,
163
+ * because only the host knows whether that means popping a screen, closing
164
+ * a modal, or advancing onboarding. Omit it and no close is drawn at all —
165
+ * a paywall with a dead close button is worse than one without.
166
+ *
167
+ * Passing `client` as well reports `paywall.closed` against this display's
168
+ * own view id, so the close pairs with its paywall.viewed. */
169
+ onClose?: () => void;
170
+ theme?: Partial<RevnixPaywallTheme>;
171
+ style?: StyleProp<ViewStyle>;
172
+ /** When given, the paywall reports one paywall.viewed per mount (REV-094)
173
+ * — the analytics funnel's "Paywall displayed" stage. Structural typing
174
+ * keeps this entrypoint free of a hard client import. */
175
+ client?: {
176
+ logPaywallShown: (options?: {
177
+ placementKey?: string;
178
+ paywallId?: string;
179
+ }) => string | void;
180
+ /** Older clients predate close reporting; the paywall still closes. */
181
+ logPaywallClosed?: (
182
+ viewId: string,
183
+ options?: { placementKey?: string; paywallId?: string }
184
+ ) => void;
185
+ /** Where a designed paywall reports a paint string it could not read.
186
+ * Optional for the same reason: a client that predates it still renders,
187
+ * it just reports nothing. */
188
+ reportRenderDiagnostic?: (message: string) => void;
189
+ /** REV-263: the interaction vocabulary. Optional so a host on an older
190
+ * client still renders and still reports views — it just reports none of
191
+ * the six. */
192
+ logPaywallEvent?: (
193
+ event: PaywallInteraction,
194
+ viewId: string,
195
+ options?: {
196
+ placementKey?: string;
197
+ paywallId?: string;
198
+ productId?: string;
199
+ code?: string;
200
+ message?: string;
201
+ eventId?: string;
202
+ }
203
+ ) => void;
204
+ };
205
+ /** Placement/paywall attribution attached to the view report. */
206
+ placementKey?: string;
207
+ paywallId?: string;
208
+ /** Opt out of the automatic view report while still passing `client`. */
209
+ disableViewTracking?: boolean;
210
+ /** Renders the CSS gradients a designed (block) paywall may use. Pass the
211
+ * app's own `LinearGradient` (expo-linear-gradient or
212
+ * react-native-linear-gradient). Without it a gradient paints as the
213
+ * design's ground color. Ignored by the classic layouts. */
214
+ gradient?: RevnixGradientComponent;
215
+ /** Renders a blurred background photo on a designed paywall. RN has no
216
+ * blur primitive, so pass the app's own (`expo-blur`'s `BlurView`, or
217
+ * `@react-native-community/blur`). Without it a blurred background photo
218
+ * renders sharp. Ignored by the classic layouts. */
219
+ blur?: RevnixBlurComponent;
220
+ /** Design font family → the face(s) the app has registered, for designed
221
+ * paywalls. Either one family for every weight, or a weight → family table.
222
+ * RN cannot load a font by name at runtime, so an unmapped face renders in
223
+ * the system font. */
224
+ fontFamilyMap?: FontFamilyMap;
225
+ /** Reports a paint string the designed renderer could not read, or a
226
+ * gradient it flattened for want of a `gradient` component.
227
+ *
228
+ * Local only: nothing is sent anywhere. Passing `client` routes the same
229
+ * reports to that client's own `onDiagnostic`, so most apps need neither —
230
+ * this is for a host that renders a paywall without a client. */
231
+ onDiagnostic?: (message: string) => void;
232
+ /** REV-271: which language to draw a DESIGNED paywall's copy in. Omitted,
233
+ * the device's own is detected — which is what makes the paywall match the
234
+ * rest of the app on a phone with no in-app language switch. Pass one when
235
+ * the app HAS such a switch, so the paywall follows the app rather than the
236
+ * OS: a user reading the app in Spanish on an English phone must not be
237
+ * handed an English paywall. A paywall with no translations ignores this,
238
+ * and any string the chosen language does not translate falls back to the
239
+ * authored copy rather than rendering blank.
240
+ *
241
+ * Only designed (block) paywalls are localized; the classic template
242
+ * layouts below render `config`'s own copy whatever this says. */
243
+ locale?: string;
244
+ }
245
+
246
+ export function RevnixPaywall(props: RevnixPaywallProps) {
247
+ // One view per mount: remounting (a re-shown paywall) is a genuine new
248
+ // display; re-renders are not.
249
+ const { client, placementKey, paywallId, disableViewTracking } = props;
250
+ // Held in a ref, not state: the close report reads it, and re-rendering the
251
+ // paywall the instant its view is recorded would be a pointless frame.
252
+ const viewIdRef = React.useRef<string | undefined>(undefined);
253
+ React.useEffect(() => {
254
+ if (!client || disableViewTracking) return;
255
+ const id = client.logPaywallShown({ placementKey, paywallId });
256
+ // Pre-REV-252 clients return void; the close then still fires the host
257
+ // callback, it just cannot be paired with this display.
258
+ if (typeof id === "string") viewIdRef.current = id;
259
+ // eslint-disable-next-line react-hooks/exhaustive-deps
260
+ }, []);
261
+
262
+ // ——— REV-263: the interaction vocabulary ———
263
+ //
264
+ // Everything below reports what this component genuinely OBSERVES and
265
+ // nothing it would have to guess. It sees the selection change, the CTA
266
+ // press, the restore press, and an offering that arrived with no packages
267
+ // to sell. It does NOT see the store sheet — the host owns that call — so
268
+ // the purchase outcome is only reported when the host hands it back from
269
+ // `onPurchase`.
270
+ //
271
+ // Every report is wrapped: a telemetry failure must never break the paywall
272
+ // the customer is standing on, the same rule REV-252 set for the close.
273
+ const report = React.useCallback(
274
+ (
275
+ event: PaywallInteraction,
276
+ options?: {
277
+ productId?: string;
278
+ code?: string;
279
+ message?: string;
280
+ eventId?: string;
281
+ }
282
+ ) => {
283
+ const viewId = viewIdRef.current;
284
+ if (!client?.logPaywallEvent || !viewId || disableViewTracking) return;
285
+ try {
286
+ client.logPaywallEvent(event, viewId, {
287
+ placementKey,
288
+ paywallId,
289
+ ...options,
290
+ });
291
+ } catch {
292
+ // Best-effort, exactly like the close beacon.
293
+ }
294
+ },
295
+ [client, placementKey, paywallId, disableViewTracking]
296
+ );
297
+
298
+ // packageId → productId, so a report names the plan the way the rest of the
299
+ // ledger does. A package whose product is unknown reports no productId
300
+ // rather than the package id, which would look like a product that does not
301
+ // exist.
302
+ const { packages } = props;
303
+ const productOf = React.useCallback(
304
+ (packageId: string) =>
305
+ packages.find((p) => p.packageId === packageId)?.productId,
306
+ [packages]
307
+ );
308
+
309
+ // An offering with nothing to sell is the one failure the component can see
310
+ // by itself, and the one most worth knowing about: the paywall painted, the
311
+ // customer could not buy. Reported once per display, after the view report
312
+ // so the two arrive in order.
313
+ const emptyOnMount = props.packages.length === 0;
314
+ React.useEffect(() => {
315
+ if (emptyOnMount) {
316
+ report("error", {
317
+ code: "no_products",
318
+ message: "paywall displayed with no packages",
319
+ });
320
+ }
321
+ // eslint-disable-next-line react-hooks/exhaustive-deps
322
+ }, []);
323
+
324
+ const { onSelectPackage } = props;
325
+ const handleSelect = React.useMemo(
326
+ () => (packageId: string) => {
327
+ report("selected", {
328
+ productId: productOf(packageId),
329
+ // One `selected` per (display, package): a customer toggling
330
+ // monthly → yearly → monthly considered two plans, not three, and the
331
+ // server's default (key on viewId alone) would have recorded only the
332
+ // first. Analytics counts stages per display anyway, so this is about
333
+ // WHICH plans were weighed.
334
+ eventId: `${viewIdRef.current ?? ""}:sel:${packageId}`,
335
+ });
336
+ onSelectPackage?.(packageId);
337
+ },
338
+ [onSelectPackage, report, productOf]
339
+ );
340
+
341
+ const { onPurchase } = props;
342
+ // Rises per attempt so a retry after a failure is its own occurrence rather
343
+ // than being absorbed as a duplicate of the first try.
344
+ const attemptRef = React.useRef(0);
345
+ const handlePurchase = React.useMemo(
346
+ () => (packageId: string) => {
347
+ if (placementKey === PREVIEW_PLACEMENT_KEY) {
348
+ Alert.alert("Preview", "Purchases are disabled in preview.");
349
+ return;
350
+ }
351
+ const attempt = ++attemptRef.current;
352
+ const productId = productOf(packageId);
353
+ const occurrence = `${viewIdRef.current ?? ""}:buy:${attempt}`;
354
+ report("purchase_started", { productId, eventId: occurrence });
355
+ const settle = (outcome: PaywallPurchaseOutcome | void) => {
356
+ // `completed` lands as purchase.completed through registerPurchase —
357
+ // reporting it here too would double-count the sale.
358
+ if (!outcome || outcome.status === "completed") return;
359
+ report(
360
+ outcome.status === "cancelled"
361
+ ? "purchase_abandoned"
362
+ : "purchase_failed",
363
+ {
364
+ productId,
365
+ code: outcome.code,
366
+ message: outcome.message,
367
+ eventId: occurrence,
368
+ }
369
+ );
370
+ };
371
+ let returned: ReturnType<RevnixPaywallProps["onPurchase"]>;
372
+ try {
373
+ returned = onPurchase(packageId);
374
+ } catch (err) {
375
+ // A host that throws synchronously has failed the purchase as surely
376
+ // as one whose promise rejects.
377
+ settle({ status: "failed", message: messageOf(err) });
378
+ throw err;
379
+ }
380
+ if (returned && typeof (returned as Promise<unknown>).then === "function") {
381
+ void (returned as Promise<PaywallPurchaseOutcome | void>).then(
382
+ settle,
383
+ (err: unknown) => settle({ status: "failed", message: messageOf(err) })
384
+ );
385
+ return;
386
+ }
387
+ settle(returned as PaywallPurchaseOutcome | void);
388
+ },
389
+ [onPurchase, report, productOf, placementKey]
390
+ );
391
+
392
+ const { onRestore } = props;
393
+ const handleRestore = React.useMemo(
394
+ () =>
395
+ onRestore
396
+ ? () => {
397
+ report("restore");
398
+ onRestore();
399
+ }
400
+ : undefined,
401
+ [onRestore, report]
402
+ );
403
+
404
+ // The host's dismissal runs whatever happens to the beacon — an analytics
405
+ // failure must never trap the customer on the screen (REV-252).
406
+ const { onClose } = props;
407
+ const handleClose = React.useMemo(
408
+ () =>
409
+ onClose
410
+ ? () => {
411
+ const viewId = viewIdRef.current;
412
+ if (client?.logPaywallClosed && viewId && !disableViewTracking) {
413
+ try {
414
+ client.logPaywallClosed(viewId, { placementKey, paywallId });
415
+ } catch {
416
+ // Reporting is best-effort; dismissal is not.
417
+ }
418
+ }
419
+ onClose();
420
+ }
421
+ : undefined,
422
+ [onClose, client, placementKey, paywallId, disableViewTracking]
423
+ );
424
+
425
+ // A designed paywall wins over the classic layouts — same precedence the
426
+ // dashboard preview applies, so what was approved is what ships. The view
427
+ // report above still runs, so analytics does not care which renderer won.
428
+ //
429
+ // Each renderer is its OWN component, so this switch mounts and unmounts
430
+ // rather than changing one component's hook count: a paywall whose config
431
+ // arrives after a placeholder (or is swapped by an A/B assignment) would
432
+ // otherwise crash on "rendered more hooks than during the previous render".
433
+ const { config } = props;
434
+ const blocks = asBlockDoc(config.blocks);
435
+ // Both sinks fire when both are wired; nil when neither is, which is what
436
+ // stops the renderer building a message nobody will read.
437
+ const renderDiagnostic =
438
+ client?.reportRenderDiagnostic || props.onDiagnostic
439
+ ? (message: string) => {
440
+ client?.reportRenderDiagnostic?.(message);
441
+ props.onDiagnostic?.(message);
442
+ }
443
+ : undefined;
444
+
445
+ if (blocks) {
446
+ return (
447
+ <RevnixBlockPaywall
448
+ doc={blocks}
449
+ packages={props.packages}
450
+ onPurchase={handlePurchase}
451
+ selectedPackageId={props.selectedPackageId}
452
+ onSelectPackage={handleSelect}
453
+ highlightPackageId={config.highlightPackageId}
454
+ heroImageUrl={config.heroImageUrl}
455
+ onRestore={handleRestore}
456
+ onTerms={props.onTerms}
457
+ onPrivacy={props.onPrivacy}
458
+ onClose={handleClose}
459
+ footer={config.footer}
460
+ gradient={props.gradient}
461
+ blur={props.blur}
462
+ fontFamilyMap={props.fontFamilyMap}
463
+ locale={props.locale}
464
+ onDiagnostic={renderDiagnostic}
465
+ style={props.style}
466
+ loading={props.loading}
467
+ />
468
+ );
469
+ }
470
+ return (
471
+ <ClassicPaywall
472
+ {...props}
473
+ onPurchase={handlePurchase}
474
+ onSelectPackage={handleSelect}
475
+ onRestore={handleRestore}
476
+ onClose={handleClose}
477
+ />
478
+ );
479
+ }
480
+
481
+ /** REV-263: the text an interaction report carries for a thrown error. Kept
482
+ * short — the server bounds `message` at 1024 chars and nothing groups by
483
+ * it; the CODE is the field that gets aggregated. */
484
+ function messageOf(err: unknown): string | undefined {
485
+ if (err instanceof Error) return err.message;
486
+ if (typeof err === "string") return err;
487
+ return undefined;
488
+ }
489
+
490
+ /** The dismiss affordance the classic layouts get (REV-252).
491
+ *
492
+ * The nine `template` layouts have the same problem the designed ones had —
493
+ * nothing on the screen closes them — and `onClose` is a prop of the shared
494
+ * RevnixPaywall, so a host that wires it must get a close on either path
495
+ * rather than silently nothing. Classic layouts author no elements of their
496
+ * own, so there is never a design chip to suppress: the rule reduces to
497
+ * "draw it whenever the host wired a handler".
498
+ *
499
+ * Tinted from the theme's own ink so it works in the light scheme too. */
500
+ function ClassicClose({
501
+ theme,
502
+ onPress,
503
+ }: {
504
+ theme: RevnixPaywallTheme;
505
+ onPress: () => void;
506
+ }) {
507
+ return (
508
+ <Pressable
509
+ accessibilityRole="button"
510
+ accessibilityLabel="Close"
511
+ hitSlop={10}
512
+ onPress={onPress}
513
+ style={{
514
+ position: "absolute",
515
+ top: 14,
516
+ right: 14,
517
+ zIndex: 50,
518
+ width: 30,
519
+ height: 30,
520
+ borderRadius: 15,
521
+ alignItems: "center",
522
+ justifyContent: "center",
523
+ backgroundColor: withAlpha(theme.textPrimary, 0.14),
524
+ }}
525
+ >
526
+ <Text style={{ color: theme.textPrimary, fontSize: 17, lineHeight: 20 }}>
527
+ ×
528
+ </Text>
529
+ </Pressable>
530
+ );
531
+ }
532
+
533
+ /** The nine `template` layouts — every paywall published before the block
534
+ * builder, and the fallback for any config that carries no block tree. */
535
+ function ClassicPaywall(props: RevnixPaywallProps) {
536
+ const { config, packages, loading } = props;
537
+ // Base scheme comes from the dashboard config (mode: dark|light, absent =
538
+ // dark for legacy configs); the host app's explicit theme prop wins on top.
539
+ const baseTheme =
540
+ config.mode === "light" ? LIGHT_PAYWALL_THEME : DEFAULT_PAYWALL_THEME;
541
+ const theme = { ...baseTheme, ...props.theme };
542
+ const accent = config.accent ?? DEFAULT_ACCENT;
543
+ const cardBg = CARD_BG[config.mode === "light" ? "light" : "dark"];
544
+ const layout = config.template;
545
+
546
+ // Same template semantics as the dashboard preview: "minimal" shows only
547
+ // the highlighted package; feature bullets render on the feature layouts.
548
+ const shown =
549
+ layout === "minimal" && config.highlightPackageId
550
+ ? packages.filter((p) => p.packageId === config.highlightPackageId)
551
+ : packages;
552
+
553
+ const [internalSelected, setInternalSelected] = React.useState<
554
+ string | undefined
555
+ >(undefined);
556
+ const fallbackSelected =
557
+ shown.find((p) => p.packageId === config.highlightPackageId)?.packageId ??
558
+ shown[0]?.packageId;
559
+ const selectedId =
560
+ props.selectedPackageId ??
561
+ (internalSelected !== undefined &&
562
+ shown.some((p) => p.packageId === internalSelected)
563
+ ? internalSelected
564
+ : fallbackSelected);
565
+
566
+ const select = (packageId: string) => {
567
+ setInternalSelected(packageId);
568
+ props.onSelectPackage?.(packageId);
569
+ };
570
+
571
+ // Footer links are dashboard-configured (config.footer); a legacy config
572
+ // without the field keeps the original always-on footer. Explicit host
573
+ // handlers win over config URLs — the app knows best how to open its own
574
+ // legal pages (in-app browser etc.); the URL is the no-handler fallback.
575
+ const footer = config.footer;
576
+ const openUrl = (url?: string) =>
577
+ url ? () => void Linking.openURL(url) : undefined;
578
+ const footerItems = (
579
+ [
580
+ {
581
+ show: footer?.showRestore ?? true,
582
+ label: "Restore",
583
+ onPress: props.onRestore,
584
+ },
585
+ {
586
+ show: footer?.showTerms ?? true,
587
+ label: "Terms",
588
+ onPress: props.onTerms ?? openUrl(footer?.termsUrl),
589
+ },
590
+ {
591
+ show: footer?.showPrivacy ?? true,
592
+ label: "Privacy",
593
+ onPress: props.onPrivacy ?? openUrl(footer?.privacyUrl),
594
+ },
595
+ ] as const
596
+ ).filter((item) => item.show);
597
+
598
+ // ——— shared blocks (each layout composes a subset; keep every block in
599
+ // lockstep with the same-named block in PaywallPhonePreview.tsx) ———
600
+
601
+ // Classic header: hero image card (or accent icon tile) + centered
602
+ // headline/subheadline. Byte-identical to the pre-layout renderer.
603
+ const heroOrIcon = config.heroImageUrl ? (
604
+ <Image
605
+ source={{ uri: config.heroImageUrl }}
606
+ style={styles.heroImage}
607
+ resizeMode="cover"
608
+ accessibilityIgnoresInvertColors
609
+ />
610
+ ) : (
611
+ <View style={[styles.iconTile, { backgroundColor: `${accent}26` }]}>
612
+ <Text style={[styles.iconGlyph, { color: accent }]}>◆</Text>
613
+ </View>
614
+ );
615
+
616
+ const headlineBlock = (
617
+ <>
618
+ <Text style={[styles.headline, { color: theme.textPrimary }]}>
619
+ {config.headline}
620
+ </Text>
621
+ {config.subheadline ? (
622
+ <Text style={[styles.subheadline, { color: theme.textSecondary }]}>
623
+ {config.subheadline}
624
+ </Text>
625
+ ) : null}
626
+ </>
627
+ );
628
+
629
+ // Hero layout banner: full-width media with a content-safe scrim overlay
630
+ // carrying the headline/subheadline (always light-on-scrim). Falls back to
631
+ // an accent field with the brand glyph when no hero image is set.
632
+ const heroBanner = (
633
+ <View style={styles.heroBanner}>
634
+ {config.heroImageUrl ? (
635
+ <Image
636
+ source={{ uri: config.heroImageUrl }}
637
+ style={StyleSheet.absoluteFill}
638
+ resizeMode="cover"
639
+ accessibilityIgnoresInvertColors
640
+ />
641
+ ) : (
642
+ <View
643
+ style={[StyleSheet.absoluteFill, { backgroundColor: accent }]}
644
+ />
645
+ )}
646
+ {!config.heroImageUrl ? (
647
+ <View style={styles.heroBannerGlyphWrap} pointerEvents="none">
648
+ <Text style={styles.heroBannerGlyph}>◆</Text>
649
+ </View>
650
+ ) : null}
651
+ <View style={styles.heroBannerScrim}>
652
+ <Text style={styles.heroBannerHeadline}>{config.headline}</Text>
653
+ {config.subheadline ? (
654
+ <Text style={styles.heroBannerSub}>{config.subheadline}</Text>
655
+ ) : null}
656
+ </View>
657
+ </View>
658
+ );
659
+
660
+ // Legacy feature bullets (feature-list layout).
661
+ const featureBullets =
662
+ config.features.length > 0 ? (
663
+ <View style={styles.features}>
664
+ {config.features.map((f, i) => (
665
+ <View key={i} style={styles.featureRow}>
666
+ <Text style={[styles.featureIcon, { color: accent }]}>
667
+ {f.icon || "✓"}
668
+ </Text>
669
+ <View style={styles.featureBody}>
670
+ <Text style={[styles.featureTitle, { color: theme.textPrimary }]}>
671
+ {f.title}
672
+ </Text>
673
+ {f.description ? (
674
+ <Text
675
+ style={[
676
+ styles.featureDescription,
677
+ { color: theme.textSecondary },
678
+ ]}
679
+ >
680
+ {f.description}
681
+ </Text>
682
+ ) : null}
683
+ </View>
684
+ </View>
685
+ ))}
686
+ </View>
687
+ ) : null;
688
+
689
+ // Compact single-line checks (hero banner body, plans checklist).
690
+ const featureChecks =
691
+ config.features.length > 0 ? (
692
+ <View style={styles.checks}>
693
+ {config.features.map((f, i) => (
694
+ <View key={i} style={styles.checkRow}>
695
+ <Text style={[styles.checkIcon, { color: accent }]}>
696
+ {f.icon || "✓"}
697
+ </Text>
698
+ <Text
699
+ style={[styles.checkTitle, { color: theme.textPrimary }]}
700
+ >
701
+ {f.title}
702
+ </Text>
703
+ </View>
704
+ ))}
705
+ </View>
706
+ ) : null;
707
+
708
+ // Trial timeline: icon dots joined by an accent rail; the first step is
709
+ // filled solid ("you are here"), later steps are tinted.
710
+ const timelineBlock =
711
+ config.features.length > 0 ? (
712
+ <View style={styles.timeline}>
713
+ {config.features.map((f, i) => {
714
+ const last = i === config.features.length - 1;
715
+ return (
716
+ <View key={i} style={styles.timelineRow}>
717
+ <View style={styles.timelineRail}>
718
+ <View
719
+ style={[
720
+ styles.timelineDot,
721
+ i === 0
722
+ ? { backgroundColor: accent }
723
+ : { backgroundColor: `${accent}26` },
724
+ ]}
725
+ >
726
+ <Text
727
+ style={[
728
+ styles.timelineDotIcon,
729
+ { color: i === 0 ? theme.accentInk : accent },
730
+ ]}
731
+ >
732
+ {f.icon || "✓"}
733
+ </Text>
734
+ </View>
735
+ {!last ? (
736
+ <View
737
+ style={[
738
+ styles.timelineLine,
739
+ { backgroundColor: `${accent}40` },
740
+ ]}
741
+ />
742
+ ) : null}
743
+ </View>
744
+ <View style={[styles.timelineBody, !last && styles.timelineGap]}>
745
+ <Text
746
+ style={[styles.featureTitle, { color: theme.textPrimary }]}
747
+ >
748
+ {f.title}
749
+ </Text>
750
+ {f.description ? (
751
+ <Text
752
+ style={[
753
+ styles.featureDescription,
754
+ { color: theme.textSecondary },
755
+ ]}
756
+ >
757
+ {f.description}
758
+ </Text>
759
+ ) : null}
760
+ </View>
761
+ </View>
762
+ );
763
+ })}
764
+ </View>
765
+ ) : null;
766
+
767
+ // Feature grid: two-column soft cards with an accent icon tile each.
768
+ const featureGrid =
769
+ config.features.length > 0 ? (
770
+ <View style={styles.grid}>
771
+ {config.features.map((f, i) => (
772
+ <View key={i} style={[styles.gridCard, { backgroundColor: cardBg }]}>
773
+ <View
774
+ style={[styles.gridIconTile, { backgroundColor: `${accent}26` }]}
775
+ >
776
+ <Text style={[styles.gridIcon, { color: accent }]}>
777
+ {f.icon || "✓"}
778
+ </Text>
779
+ </View>
780
+ <Text style={[styles.gridTitle, { color: theme.textPrimary }]}>
781
+ {f.title}
782
+ </Text>
783
+ {f.description ? (
784
+ <Text
785
+ style={[styles.gridDescription, { color: theme.textSecondary }]}
786
+ >
787
+ {f.description}
788
+ </Text>
789
+ ) : null}
790
+ </View>
791
+ ))}
792
+ </View>
793
+ ) : null;
794
+
795
+ // Reveal: onboarding-style numbered benefit cards + progress dots.
796
+ const progressDots = (
797
+ <View style={styles.dots}>
798
+ {[0, 1, 2].map((i) => (
799
+ <View
800
+ key={i}
801
+ style={[
802
+ styles.dot,
803
+ { backgroundColor: i === 0 ? accent : `${accent}40` },
804
+ ]}
805
+ />
806
+ ))}
807
+ </View>
808
+ );
809
+ const revealCards =
810
+ config.features.length > 0 ? (
811
+ <View style={styles.reveal}>
812
+ {config.features.map((f, i) => (
813
+ <View
814
+ key={i}
815
+ style={[styles.revealCard, { backgroundColor: cardBg }]}
816
+ >
817
+ <View
818
+ style={[styles.revealChip, { backgroundColor: `${accent}26` }]}
819
+ >
820
+ <Text style={[styles.revealNumber, { color: accent }]}>
821
+ {i + 1}
822
+ </Text>
823
+ </View>
824
+ <View style={styles.featureBody}>
825
+ <Text
826
+ style={[styles.revealTitle, { color: theme.textPrimary }]}
827
+ >
828
+ {f.title}
829
+ </Text>
830
+ {f.description ? (
831
+ <Text
832
+ style={[
833
+ styles.featureDescription,
834
+ { color: theme.textSecondary },
835
+ ]}
836
+ >
837
+ {f.description}
838
+ </Text>
839
+ ) : null}
840
+ </View>
841
+ </View>
842
+ ))}
843
+ </View>
844
+ ) : null;
845
+
846
+ // Social proof card: star row (+ numeric rating), quote, attribution.
847
+ const review = config.review;
848
+ const hasReviewCard =
849
+ review !== undefined &&
850
+ (review.rating !== undefined || Boolean(review.quote));
851
+ const reviewCard = hasReviewCard ? (
852
+ <View style={[styles.reviewCard, { backgroundColor: cardBg }]}>
853
+ {review?.rating !== undefined ? (
854
+ <View style={styles.starsRow}>
855
+ {[1, 2, 3, 4, 5].map((n) => (
856
+ <Text
857
+ key={n}
858
+ style={[
859
+ styles.star,
860
+ {
861
+ color:
862
+ n <= Math.round(review.rating ?? 0)
863
+ ? accent
864
+ : theme.border,
865
+ },
866
+ ]}
867
+ >
868
+ ★
869
+ </Text>
870
+ ))}
871
+ <Text style={[styles.starRating, { color: theme.textPrimary }]}>
872
+ {String(review.rating)}
873
+ </Text>
874
+ </View>
875
+ ) : null}
876
+ {review?.quote ? (
877
+ <Text style={[styles.reviewQuote, { color: theme.textPrimary }]}>
878
+ “{review.quote}”
879
+ </Text>
880
+ ) : null}
881
+ {review?.author ? (
882
+ <Text style={[styles.reviewAuthor, { color: theme.textSecondary }]}>
883
+ {review.author}
884
+ </Text>
885
+ ) : null}
886
+ </View>
887
+ ) : null;
888
+ const countLine = review?.count ? (
889
+ <Text style={[styles.countLine, { color: theme.textSecondary }]}>
890
+ {review.count}
891
+ </Text>
892
+ ) : null;
893
+
894
+ // Offer bits: urgency line above the CTA; anchor price struck through on
895
+ // the highlighted package.
896
+ const urgencyLine = config.offer?.urgencyText ? (
897
+ <Text style={[styles.urgency, { color: accent }]}>
898
+ {config.offer.urgencyText}
899
+ </Text>
900
+ ) : null;
901
+ // The anchor is dashboard free text while priceLabel is the store's
902
+ // localized price — never pair them when their currency symbols disagree,
903
+ // or a EUR customer would see a struck-through USD anchor next to the real
904
+ // charge (this file's "display never disagrees with the charge" rule;
905
+ // kept in lockstep with the preview's anchorPriceFor). Symbol-less
906
+ // anchors can't be judged and pass through.
907
+ const anchorPriceFor = (priceLabel: string): string | undefined => {
908
+ const anchor = config.offer?.strikethroughPrice;
909
+ if (!anchor) return undefined;
910
+ const symbol = anchor.match(CURRENCY_SYMBOL)?.[0];
911
+ return symbol === undefined || priceLabel.includes(symbol)
912
+ ? anchor
913
+ : undefined;
914
+ };
915
+
916
+ const priceLine = (pkg: RevnixPaywallPackage, highlighted: boolean) => {
917
+ const anchorPrice = highlighted
918
+ ? anchorPriceFor(pkg.priceLabel)
919
+ : undefined;
920
+ return anchorPrice ? (
921
+ <View style={styles.priceRow}>
922
+ <Text style={[styles.anchorPrice, { color: theme.textFaint }]}>
923
+ {anchorPrice}
924
+ </Text>
925
+ <Text style={[styles.packagePrice, { color: theme.textSecondary }]}>
926
+ {pkg.priceLabel}
927
+ </Text>
928
+ </View>
929
+ ) : (
930
+ <Text style={[styles.packagePrice, { color: theme.textSecondary }]}>
931
+ {pkg.priceLabel}
932
+ </Text>
933
+ );
934
+ };
935
+
936
+ // Standard package rows (all layouts except plans columns / offer
937
+ // spotlight). `withBadge=false` suppresses the row badge where the layout
938
+ // already presents the badge elsewhere (offer pill).
939
+ const packageRows = (list: RevnixPaywallPackage[], withBadge = true) => (
940
+ <View style={styles.packages}>
941
+ {list.map((pkg) => {
942
+ const selected = pkg.packageId === selectedId;
943
+ const highlighted = pkg.packageId === config.highlightPackageId;
944
+ const badged = withBadge && highlighted && Boolean(config.badgeText);
945
+ return (
946
+ <Pressable
947
+ key={pkg.packageId}
948
+ onPress={() => select(pkg.packageId)}
949
+ accessibilityRole="radio"
950
+ accessibilityState={{ selected }}
951
+ style={[
952
+ styles.packageRow,
953
+ selected
954
+ ? { borderColor: accent, borderWidth: 2 }
955
+ : { borderColor: theme.border, borderWidth: 1 },
956
+ ]}
957
+ >
958
+ {badged ? (
959
+ <View style={[styles.badge, { backgroundColor: accent }]}>
960
+ <Text
961
+ numberOfLines={1}
962
+ style={[styles.badgeText, { color: theme.accentInk }]}
963
+ >
964
+ {config.badgeText}
965
+ </Text>
966
+ </View>
967
+ ) : null}
968
+ <Text style={[styles.packageTitle, { color: theme.textPrimary }]}>
969
+ {pkg.title}
970
+ </Text>
971
+ {priceLine(pkg, highlighted)}
972
+ </Pressable>
973
+ );
974
+ })}
975
+ </View>
976
+ );
977
+
978
+ // Plans layout: packages side by side as tier columns; the highlighted
979
+ // tier carries the badge pill inside the column. Columns stay readable up
980
+ // to 3 — beyond that the layout falls back to stacked rows (never drop a
981
+ // purchasable package), same rule as the dashboard preview.
982
+ const usePlanColumns = shown.length <= 3;
983
+ const planColumns = (
984
+ <View style={styles.planRow}>
985
+ {shown.map((pkg) => {
986
+ const selected = pkg.packageId === selectedId;
987
+ const highlighted = pkg.packageId === config.highlightPackageId;
988
+ const anchorPrice = highlighted
989
+ ? anchorPriceFor(pkg.priceLabel)
990
+ : undefined;
991
+ return (
992
+ <Pressable
993
+ key={pkg.packageId}
994
+ onPress={() => select(pkg.packageId)}
995
+ accessibilityRole="radio"
996
+ accessibilityState={{ selected }}
997
+ style={[
998
+ styles.planColumn,
999
+ selected
1000
+ ? { borderColor: accent, borderWidth: 2 }
1001
+ : { borderColor: theme.border, borderWidth: 1 },
1002
+ ]}
1003
+ >
1004
+ {highlighted && config.badgeText ? (
1005
+ <View style={[styles.planBadge, { backgroundColor: accent }]}>
1006
+ <Text
1007
+ numberOfLines={1}
1008
+ style={[styles.badgeText, { color: theme.accentInk }]}
1009
+ >
1010
+ {config.badgeText}
1011
+ </Text>
1012
+ </View>
1013
+ ) : null}
1014
+ <Text
1015
+ style={[styles.planTitle, { color: theme.textPrimary }]}
1016
+ >
1017
+ {pkg.title}
1018
+ </Text>
1019
+ {anchorPrice ? (
1020
+ <Text style={[styles.anchorPrice, { color: theme.textFaint }]}>
1021
+ {anchorPrice}
1022
+ </Text>
1023
+ ) : null}
1024
+ <Text style={[styles.planPrice, { color: theme.textPrimary }]}>
1025
+ {pkg.priceLabel}
1026
+ </Text>
1027
+ </Pressable>
1028
+ );
1029
+ })}
1030
+ </View>
1031
+ );
1032
+
1033
+ // Offer layout: the badge becomes a large centered pill; the highlighted
1034
+ // (else first) package renders as a spotlight card with the anchor price.
1035
+ const spotlightPkg =
1036
+ shown.find((p) => p.packageId === config.highlightPackageId) ?? shown[0];
1037
+ const offerPill = config.badgeText ? (
1038
+ <View style={[styles.offerPill, { backgroundColor: accent }]}>
1039
+ <Text
1040
+ numberOfLines={1}
1041
+ style={[styles.offerPillText, { color: theme.accentInk }]}
1042
+ >
1043
+ {config.badgeText}
1044
+ </Text>
1045
+ </View>
1046
+ ) : null;
1047
+ const spotlightAnchor = spotlightPkg
1048
+ ? anchorPriceFor(spotlightPkg.priceLabel)
1049
+ : undefined;
1050
+ const offerSpotlight = spotlightPkg ? (
1051
+ <>
1052
+ <Pressable
1053
+ onPress={() => select(spotlightPkg.packageId)}
1054
+ accessibilityRole="radio"
1055
+ accessibilityState={{
1056
+ selected: spotlightPkg.packageId === selectedId,
1057
+ }}
1058
+ style={[
1059
+ styles.spotlight,
1060
+ {
1061
+ borderColor:
1062
+ spotlightPkg.packageId === selectedId ? accent : theme.border,
1063
+ },
1064
+ ]}
1065
+ >
1066
+ <Text style={[styles.spotlightTitle, { color: theme.textPrimary }]}>
1067
+ {spotlightPkg.title}
1068
+ </Text>
1069
+ <View style={styles.priceRowCenter}>
1070
+ {spotlightAnchor ? (
1071
+ <Text
1072
+ style={[styles.spotlightAnchor, { color: theme.textFaint }]}
1073
+ >
1074
+ {spotlightAnchor}
1075
+ </Text>
1076
+ ) : null}
1077
+ <Text
1078
+ style={[styles.spotlightPrice, { color: theme.textPrimary }]}
1079
+ >
1080
+ {spotlightPkg.priceLabel}
1081
+ </Text>
1082
+ </View>
1083
+ </Pressable>
1084
+ {shown.length > 1
1085
+ ? packageRows(
1086
+ shown.filter((p) => p.packageId !== spotlightPkg.packageId),
1087
+ false
1088
+ )
1089
+ : null}
1090
+ </>
1091
+ ) : null;
1092
+
1093
+ const cta = (
1094
+ <Pressable
1095
+ onPress={() => {
1096
+ if (!loading && selectedId) props.onPurchase(selectedId);
1097
+ }}
1098
+ disabled={loading || !selectedId}
1099
+ accessibilityRole="button"
1100
+ style={[styles.cta, { backgroundColor: accent }]}
1101
+ >
1102
+ {loading ? (
1103
+ <ActivityIndicator color={theme.accentInk} />
1104
+ ) : (
1105
+ <Text style={[styles.ctaLabel, { color: theme.accentInk }]}>
1106
+ {config.ctaLabel}
1107
+ </Text>
1108
+ )}
1109
+ </Pressable>
1110
+ );
1111
+
1112
+ const footerBlock =
1113
+ footerItems.length > 0 ? (
1114
+ <View style={styles.footer}>
1115
+ {footerItems.map((item, i) => (
1116
+ <React.Fragment key={item.label}>
1117
+ {i > 0 ? (
1118
+ <Text style={[styles.footerDot, { color: theme.textFaint }]}>
1119
+ {" · "}
1120
+ </Text>
1121
+ ) : null}
1122
+ <Text
1123
+ onPress={item.onPress}
1124
+ style={[styles.footerLink, { color: theme.textFaint }]}
1125
+ >
1126
+ {item.label}
1127
+ </Text>
1128
+ </React.Fragment>
1129
+ ))}
1130
+ </View>
1131
+ ) : null;
1132
+
1133
+ // Tail shared by every layout: urgency → CTA → count → footer.
1134
+ const tail = (
1135
+ <>
1136
+ {urgencyLine}
1137
+ {cta}
1138
+ {countLine}
1139
+ {footerBlock}
1140
+ </>
1141
+ );
1142
+
1143
+ let body: React.ReactNode;
1144
+ switch (layout) {
1145
+ case "hero":
1146
+ body = (
1147
+ <>
1148
+ {heroBanner}
1149
+ {featureChecks}
1150
+ {reviewCard}
1151
+ {packageRows(shown)}
1152
+ {tail}
1153
+ </>
1154
+ );
1155
+ break;
1156
+ case "timeline":
1157
+ body = (
1158
+ <>
1159
+ {heroOrIcon}
1160
+ {headlineBlock}
1161
+ {timelineBlock}
1162
+ {reviewCard}
1163
+ {packageRows(shown)}
1164
+ {tail}
1165
+ </>
1166
+ );
1167
+ break;
1168
+ case "plans":
1169
+ body = (
1170
+ <>
1171
+ {heroOrIcon}
1172
+ {headlineBlock}
1173
+ {usePlanColumns ? planColumns : packageRows(shown)}
1174
+ {featureChecks}
1175
+ {reviewCard}
1176
+ {tail}
1177
+ </>
1178
+ );
1179
+ break;
1180
+ case "feature-grid":
1181
+ body = (
1182
+ <>
1183
+ {heroOrIcon}
1184
+ {headlineBlock}
1185
+ {featureGrid}
1186
+ {reviewCard}
1187
+ {packageRows(shown)}
1188
+ {tail}
1189
+ </>
1190
+ );
1191
+ break;
1192
+ case "offer":
1193
+ body = (
1194
+ <>
1195
+ {heroOrIcon}
1196
+ {offerPill}
1197
+ {headlineBlock}
1198
+ {offerSpotlight}
1199
+ {reviewCard}
1200
+ {tail}
1201
+ </>
1202
+ );
1203
+ break;
1204
+ case "reveal":
1205
+ body = (
1206
+ <>
1207
+ {progressDots}
1208
+ {heroOrIcon}
1209
+ {headlineBlock}
1210
+ {revealCards}
1211
+ {packageRows(shown)}
1212
+ {tail}
1213
+ </>
1214
+ );
1215
+ break;
1216
+ default:
1217
+ // focus | feature-list | minimal — the original structure, unchanged
1218
+ // for legacy configs (review/offer blocks only exist when configured).
1219
+ body = (
1220
+ <>
1221
+ {heroOrIcon}
1222
+ {headlineBlock}
1223
+ {layout === "feature-list" ? featureBullets : null}
1224
+ {reviewCard}
1225
+ {packageRows(shown)}
1226
+ {tail}
1227
+ </>
1228
+ );
1229
+ break;
1230
+ }
1231
+
1232
+ return (
1233
+ <View style={[styles.root, { backgroundColor: theme.background }, props.style]}>
1234
+ <ScrollView
1235
+ contentContainerStyle={styles.scrollContent}
1236
+ showsVerticalScrollIndicator={false}
1237
+ contentInsetAdjustmentBehavior="automatic"
1238
+ >
1239
+ <View style={styles.content}>{body}</View>
1240
+ </ScrollView>
1241
+ {props.onClose ? (
1242
+ <ClassicClose theme={theme} onPress={props.onClose} />
1243
+ ) : null}
1244
+ </View>
1245
+ );
1246
+ }
1247
+
1248
+ // PROPORTIONS mirror PaywallPhonePreview.tsx (the dashboard's miniature phone
1249
+ // mock); SIZES are scaled up to native-device typography — the preview is a
1250
+ // scaled-down mock, so copying its pixel values verbatim rendered a paywall
1251
+ // that read ~30% too small on a real screen.
1252
+ //
1253
+ // Responsive rules:
1254
+ // · scrollContent flexGrow+center — content sits vertically centered on tall
1255
+ // screens (no dead bottom half) and scrolls normally when it overflows.
1256
+ // · content maxWidth — on tablets/wide screens the column stays a readable
1257
+ // width, horizontally centered, instead of stretching edge to edge.
1258
+ const styles = StyleSheet.create({
1259
+ root: { flex: 1 },
1260
+ scrollContent: {
1261
+ flexGrow: 1,
1262
+ justifyContent: "center",
1263
+ paddingHorizontal: 24,
1264
+ paddingTop: 28,
1265
+ paddingBottom: 32,
1266
+ },
1267
+ content: {
1268
+ width: "100%",
1269
+ maxWidth: 440,
1270
+ alignSelf: "center",
1271
+ },
1272
+ heroImage: {
1273
+ alignSelf: "stretch",
1274
+ height: 180,
1275
+ borderRadius: 16,
1276
+ marginTop: 8,
1277
+ marginBottom: 22,
1278
+ },
1279
+ iconTile: {
1280
+ alignSelf: "center",
1281
+ width: 64,
1282
+ height: 64,
1283
+ borderRadius: 16,
1284
+ alignItems: "center",
1285
+ justifyContent: "center",
1286
+ marginTop: 8,
1287
+ marginBottom: 22,
1288
+ },
1289
+ iconGlyph: { fontSize: 27 },
1290
+ headline: {
1291
+ fontSize: 26,
1292
+ fontWeight: "800",
1293
+ textAlign: "center",
1294
+ marginBottom: 10,
1295
+ },
1296
+ subheadline: {
1297
+ fontSize: 15,
1298
+ lineHeight: 21,
1299
+ textAlign: "center",
1300
+ marginBottom: 26,
1301
+ },
1302
+ // hero layout banner
1303
+ heroBanner: {
1304
+ alignSelf: "stretch",
1305
+ minHeight: 260,
1306
+ borderRadius: 20,
1307
+ overflow: "hidden",
1308
+ justifyContent: "flex-end",
1309
+ marginTop: 8,
1310
+ marginBottom: 22,
1311
+ },
1312
+ heroBannerGlyphWrap: {
1313
+ ...StyleSheet.absoluteFillObject,
1314
+ alignItems: "center",
1315
+ justifyContent: "center",
1316
+ paddingBottom: 72,
1317
+ },
1318
+ heroBannerGlyph: { fontSize: 64, color: "rgba(255,255,255,0.35)" },
1319
+ heroBannerScrim: {
1320
+ backgroundColor: "rgba(0,0,0,0.45)",
1321
+ paddingHorizontal: 18,
1322
+ paddingVertical: 16,
1323
+ },
1324
+ heroBannerHeadline: { fontSize: 24, fontWeight: "800", color: "#ffffff" },
1325
+ heroBannerSub: {
1326
+ fontSize: 13.5,
1327
+ lineHeight: 19,
1328
+ color: "rgba(255,255,255,0.85)",
1329
+ marginTop: 4,
1330
+ },
1331
+ features: { marginBottom: 26, gap: 14 },
1332
+ featureRow: { flexDirection: "row", alignItems: "flex-start", gap: 12 },
1333
+ featureIcon: { width: 24, fontSize: 15, lineHeight: 21 },
1334
+ featureBody: { flex: 1 },
1335
+ featureTitle: { fontSize: 15.5, fontWeight: "600", lineHeight: 21 },
1336
+ featureDescription: { fontSize: 13, lineHeight: 18, marginTop: 1 },
1337
+ // compact single-line checks
1338
+ checks: { marginBottom: 24, gap: 10 },
1339
+ checkRow: { flexDirection: "row", alignItems: "center", gap: 10 },
1340
+ checkIcon: { width: 20, fontSize: 14 },
1341
+ checkTitle: { flex: 1, fontSize: 14.5, fontWeight: "500", lineHeight: 20 },
1342
+ // trial timeline
1343
+ timeline: { marginBottom: 26 },
1344
+ timelineRow: { flexDirection: "row", gap: 12 },
1345
+ timelineRail: { width: 34, alignItems: "center" },
1346
+ timelineDot: {
1347
+ width: 34,
1348
+ height: 34,
1349
+ borderRadius: 17,
1350
+ alignItems: "center",
1351
+ justifyContent: "center",
1352
+ },
1353
+ timelineDotIcon: { fontSize: 14 },
1354
+ timelineLine: { flex: 1, width: 2, marginVertical: 4 },
1355
+ timelineBody: { flex: 1, paddingTop: 6 },
1356
+ timelineGap: { paddingBottom: 22 },
1357
+ // feature grid
1358
+ grid: {
1359
+ flexDirection: "row",
1360
+ flexWrap: "wrap",
1361
+ gap: 10,
1362
+ marginBottom: 24,
1363
+ },
1364
+ gridCard: {
1365
+ width: "47.5%",
1366
+ flexGrow: 1,
1367
+ borderRadius: 14,
1368
+ padding: 13,
1369
+ },
1370
+ gridIconTile: {
1371
+ width: 34,
1372
+ height: 34,
1373
+ borderRadius: 10,
1374
+ alignItems: "center",
1375
+ justifyContent: "center",
1376
+ marginBottom: 9,
1377
+ },
1378
+ gridIcon: { fontSize: 15 },
1379
+ gridTitle: { fontSize: 13.5, fontWeight: "600", lineHeight: 18 },
1380
+ gridDescription: { fontSize: 12, lineHeight: 16, marginTop: 3 },
1381
+ // reveal (onboarding-style)
1382
+ dots: {
1383
+ flexDirection: "row",
1384
+ justifyContent: "center",
1385
+ gap: 6,
1386
+ marginBottom: 18,
1387
+ },
1388
+ dot: { width: 6, height: 6, borderRadius: 3 },
1389
+ reveal: { gap: 10, marginBottom: 24 },
1390
+ revealCard: {
1391
+ flexDirection: "row",
1392
+ alignItems: "flex-start",
1393
+ gap: 12,
1394
+ borderRadius: 14,
1395
+ padding: 14,
1396
+ },
1397
+ revealChip: {
1398
+ width: 26,
1399
+ height: 26,
1400
+ borderRadius: 13,
1401
+ alignItems: "center",
1402
+ justifyContent: "center",
1403
+ },
1404
+ revealNumber: { fontSize: 13, fontWeight: "700" },
1405
+ revealTitle: { fontSize: 15, fontWeight: "600", lineHeight: 20 },
1406
+ // social proof
1407
+ reviewCard: {
1408
+ borderRadius: 14,
1409
+ padding: 14,
1410
+ alignItems: "center",
1411
+ marginBottom: 22,
1412
+ },
1413
+ starsRow: { flexDirection: "row", alignItems: "center" },
1414
+ star: { fontSize: 15, marginHorizontal: 1 },
1415
+ starRating: { fontSize: 13, fontWeight: "600", marginLeft: 6 },
1416
+ reviewQuote: {
1417
+ fontSize: 13.5,
1418
+ lineHeight: 19,
1419
+ fontStyle: "italic",
1420
+ textAlign: "center",
1421
+ marginTop: 8,
1422
+ },
1423
+ reviewAuthor: { fontSize: 12, marginTop: 6 },
1424
+ countLine: { fontSize: 12.5, textAlign: "center", marginBottom: 14 },
1425
+ // offer
1426
+ urgency: {
1427
+ fontSize: 13,
1428
+ fontWeight: "600",
1429
+ textAlign: "center",
1430
+ marginBottom: 10,
1431
+ },
1432
+ offerPill: {
1433
+ alignSelf: "center",
1434
+ borderRadius: 20,
1435
+ paddingHorizontal: 12,
1436
+ paddingVertical: 5,
1437
+ marginBottom: 14,
1438
+ maxWidth: "80%",
1439
+ },
1440
+ offerPillText: { fontSize: 12, fontWeight: "700" },
1441
+ spotlight: {
1442
+ borderWidth: 2,
1443
+ borderRadius: 16,
1444
+ paddingHorizontal: 18,
1445
+ paddingVertical: 18,
1446
+ alignItems: "center",
1447
+ marginBottom: 12,
1448
+ },
1449
+ spotlightTitle: { fontSize: 16, fontWeight: "600" },
1450
+ priceRowCenter: {
1451
+ flexDirection: "row",
1452
+ alignItems: "baseline",
1453
+ justifyContent: "center",
1454
+ gap: 8,
1455
+ marginTop: 6,
1456
+ },
1457
+ spotlightAnchor: {
1458
+ fontSize: 15,
1459
+ textDecorationLine: "line-through",
1460
+ fontVariant: ["tabular-nums"],
1461
+ },
1462
+ spotlightPrice: {
1463
+ fontSize: 24,
1464
+ fontWeight: "800",
1465
+ fontVariant: ["tabular-nums"],
1466
+ },
1467
+ // plans columns
1468
+ planRow: {
1469
+ flexDirection: "row",
1470
+ alignItems: "stretch",
1471
+ gap: 8,
1472
+ marginBottom: 22,
1473
+ },
1474
+ planColumn: {
1475
+ flex: 1,
1476
+ borderRadius: 14,
1477
+ paddingHorizontal: 8,
1478
+ paddingVertical: 14,
1479
+ alignItems: "center",
1480
+ },
1481
+ planBadge: {
1482
+ borderRadius: 20,
1483
+ paddingHorizontal: 8,
1484
+ paddingVertical: 2,
1485
+ marginBottom: 7,
1486
+ maxWidth: "100%",
1487
+ },
1488
+ planTitle: {
1489
+ fontSize: 14,
1490
+ fontWeight: "600",
1491
+ textAlign: "center",
1492
+ lineHeight: 19,
1493
+ },
1494
+ planPrice: {
1495
+ fontSize: 15,
1496
+ fontWeight: "700",
1497
+ fontVariant: ["tabular-nums"],
1498
+ marginTop: 4,
1499
+ },
1500
+ packages: { gap: 12, marginBottom: 22 },
1501
+ packageRow: {
1502
+ borderRadius: 12,
1503
+ paddingHorizontal: 16,
1504
+ paddingVertical: 15,
1505
+ },
1506
+ badge: {
1507
+ position: "absolute",
1508
+ top: -11,
1509
+ right: 14,
1510
+ borderRadius: 20,
1511
+ paddingHorizontal: 10,
1512
+ paddingVertical: 3,
1513
+ // Absolute views take no width constraint from the card — a long badge
1514
+ // string used to grow past the card edge (kept in lockstep with the
1515
+ // dashboard preview, which truncates the same way).
1516
+ maxWidth: "80%",
1517
+ },
1518
+ badgeText: { fontSize: 11, fontWeight: "700" },
1519
+ packageTitle: { fontSize: 16, fontWeight: "600" },
1520
+ packagePrice: { fontSize: 14, fontVariant: ["tabular-nums"], marginTop: 2 },
1521
+ priceRow: {
1522
+ flexDirection: "row",
1523
+ alignItems: "baseline",
1524
+ gap: 6,
1525
+ marginTop: 2,
1526
+ },
1527
+ anchorPrice: {
1528
+ fontSize: 13,
1529
+ textDecorationLine: "line-through",
1530
+ fontVariant: ["tabular-nums"],
1531
+ },
1532
+ cta: {
1533
+ borderRadius: 12,
1534
+ padding: 16,
1535
+ alignItems: "center",
1536
+ marginBottom: 14,
1537
+ },
1538
+ ctaLabel: { fontSize: 16.5, fontWeight: "700" },
1539
+ footer: { flexDirection: "row", justifyContent: "center" },
1540
+ footerLink: { fontSize: 13, padding: 4 },
1541
+ footerDot: { fontSize: 13, paddingVertical: 4 },
1542
+ });