react-native-revnix 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +766 -0
- package/dist/blocks/background.d.ts +101 -0
- package/dist/blocks/background.js +165 -0
- package/dist/blocks/color.d.ts +88 -0
- package/dist/blocks/color.js +329 -0
- package/dist/blocks/locale.d.ts +42 -0
- package/dist/blocks/locale.js +263 -0
- package/dist/blocks/selection.d.ts +49 -0
- package/dist/blocks/selection.js +109 -0
- package/dist/blocks/style.d.ts +93 -0
- package/dist/blocks/style.js +365 -0
- package/dist/blocks/tags.d.ts +12 -0
- package/dist/blocks/tags.js +121 -0
- package/dist/blocks/types.d.ts +301 -0
- package/dist/blocks/types.js +44 -0
- package/dist/client.d.ts +402 -0
- package/dist/client.js +1653 -0
- package/dist/device.d.ts +29 -0
- package/dist/device.js +137 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +68 -0
- package/dist/esm/blocks/background.js +160 -0
- package/dist/esm/blocks/color.js +318 -0
- package/dist/esm/blocks/locale.js +255 -0
- package/dist/esm/blocks/selection.js +100 -0
- package/dist/esm/blocks/style.js +356 -0
- package/dist/esm/blocks/tags.js +117 -0
- package/dist/esm/blocks/types.js +40 -0
- package/dist/esm/client.js +1649 -0
- package/dist/esm/device.js +130 -0
- package/dist/esm/errors.js +59 -0
- package/dist/esm/iap.js +101 -0
- package/dist/esm/implicit.js +63 -0
- package/dist/esm/index.js +29 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/storage.js +78 -0
- package/dist/esm/types.js +4 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
- package/dist/esm/ui/RevnixPaywall.js +890 -0
- package/dist/esm/ui/index.js +6 -0
- package/dist/esm/version.js +4 -0
- package/dist/iap.d.ts +20 -0
- package/dist/iap.js +104 -0
- package/dist/implicit.d.ts +69 -0
- package/dist/implicit.js +67 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +50 -0
- package/dist/storage.d.ts +14 -0
- package/dist/storage.js +85 -0
- package/dist/types.d.ts +463 -0
- package/dist/types.js +5 -0
- package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
- package/dist/ui/RevnixBlockPaywall.js +742 -0
- package/dist/ui/RevnixPaywall.d.ts +148 -0
- package/dist/ui/RevnixPaywall.js +927 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +25 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/package.json +79 -0
- package/src/blocks/background.ts +232 -0
- package/src/blocks/color.ts +352 -0
- package/src/blocks/locale.ts +271 -0
- package/src/blocks/paywall-background-wire.json +12 -0
- package/src/blocks/paywall-selection-wire.json +149 -0
- package/src/blocks/selection.ts +161 -0
- package/src/blocks/style.ts +411 -0
- package/src/blocks/tags.ts +125 -0
- package/src/blocks/types.ts +352 -0
- package/src/client.ts +1993 -0
- package/src/device.ts +146 -0
- package/src/errors.ts +69 -0
- package/src/iap.ts +137 -0
- package/src/implicit.ts +129 -0
- package/src/index.ts +82 -0
- package/src/storage.ts +92 -0
- package/src/types.ts +528 -0
- package/src/ui/RevnixBlockPaywall.tsx +1423 -0
- package/src/ui/RevnixPaywall.tsx +1542 -0
- package/src/ui/index.ts +43 -0
- package/src/version.ts +4 -0
- package/ui.d.ts +1 -0
- package/ui.js +5 -0
|
@@ -0,0 +1,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
|
+
});
|