@onboarding-yak/react-native 0.0.0-stage → 0.6.1

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/src/index.tsx ADDED
@@ -0,0 +1,1087 @@
1
+ import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, ActivityIndicator, Animated, Easing, Image, Keyboard, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useColorScheme, type ImageStyle, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { darkColors, type DarkColors } from './appearance';
4
+ import { CountryPicker, DateWheels, LoaderBlock, NumberEntry, NumberWheel, ProofBlock } from './pickers';
5
+ import type { UnitOption } from './units';
6
+ import { fillTokens, nextVisibleIndex, type MultipleChoice, type ScreenCondition } from './logic';
7
+ import { builtInValues, variableValue, type VariableFormat, type VariableValue } from './variables';
8
+ import { applyVariant, pickFlowVersion, pickVariant, type FlowTest, type ScreenExperiment } from './experiment';
9
+
10
+ export type { VariableValue } from './variables';
11
+ import { SafeAreaView } from 'react-native-safe-area-context';
12
+ import AsyncStorage from '@react-native-async-storage/async-storage';
13
+
14
+ export type EdgeInsets = { top?: number; right?: number; bottom?: number; left?: number };
15
+ export type AxisInsets = { vertical?: number; horizontal?: number };
16
+ export type ScreenTheme = {
17
+ background?: string;
18
+ showImage?: boolean;
19
+ showProgress?: boolean;
20
+ showTitle?: boolean;
21
+ showSubtitle?: boolean;
22
+ showButton?: boolean;
23
+ showSkip?: boolean;
24
+ showBack?: boolean;
25
+ showTerms?: boolean;
26
+ showLoader?: boolean;
27
+ showChecklist?: boolean;
28
+ showCharts?: boolean;
29
+ showProof?: boolean;
30
+ gap?: number;
31
+ margin?: number | AxisInsets;
32
+ headerPadding?: number | EdgeInsets;
33
+ bodyPadding?: number | EdgeInsets;
34
+ footerPadding?: number | EdgeInsets;
35
+ headerMargin?: AxisInsets;
36
+ bodyMargin?: AxisInsets;
37
+ footerMargin?: AxisInsets;
38
+ align?: 'left' | 'center' | 'right';
39
+ text?: string;
40
+ muted?: string;
41
+ primary?: string;
42
+ buttonPadding?: number | EdgeInsets;
43
+ buttonRadius?: number;
44
+ cardPadding?: number | EdgeInsets;
45
+ cardRadius?: number;
46
+ cardBackground?: string;
47
+ imageFit?: 'cover' | 'contain' | 'fill';
48
+ imageWidthMode?: 'fill' | 'fixed';
49
+ imageHeightMode?: 'fill' | 'fixed';
50
+ imageWidth?: number;
51
+ imageHeight?: number;
52
+ imageRadius?: number;
53
+ imagePadding?: number | EdgeInsets;
54
+ imageMargin?: AxisInsets;
55
+ imageBorderWidth?: number;
56
+ imageBorderColor?: string;
57
+ /** 'background' fills the screen behind the content, under a tinted overlay. */
58
+ imagePlacement?: 'inline' | 'background';
59
+ imageMotion?: 'none' | 'float' | 'rock' | 'pulse' | 'drift';
60
+ /** Shows an inline image or video inside a phone mockup. */
61
+ imageFrame?: 'none' | 'iphone' | 'android';
62
+ overlayColor?: string;
63
+ overlayOpacity?: number;
64
+ overlayStyle?: 'solid' | 'gradient';
65
+ /** Dark-mode color overrides; anything left out is derived from the light colors. */
66
+ dark?: DarkColors;
67
+ /** Order of parts in the header and body. Missing parts fall back to their default section. */
68
+ arrangement?: { header: LayoutPart[]; body: LayoutPart[] };
69
+ /** Which zone each section sits in. Defaults: header top, footer bottom, body bottom on full-photo screens and top otherwise. */
70
+ headerAlign?: SectionAlign;
71
+ bodyAlign?: SectionAlign;
72
+ footerAlign?: SectionAlign;
73
+ progress?: 'above' | 'below';
74
+ progressStyle?: 'segments' | 'bar' | 'dots';
75
+ skipLabel?: string;
76
+ backLabel?: string;
77
+ termsLabel?: string;
78
+ termsUrl?: string;
79
+ font?: string;
80
+ bodyFont?: string;
81
+ titleSize?: number;
82
+ bodySize?: number;
83
+ titleWeight?: 400 | 500 | 700;
84
+ motion?: Partial<Record<MotionPart, PartMotion>>;
85
+ };
86
+ export type MotionPart = 'image' | 'progress' | 'title' | 'subtitle' | 'answers' | 'cards' | 'button' | 'skip' | 'back' | 'terms';
87
+ export type PartMotion = { enter: 'fade' | 'rise' | 'slide' | 'pop'; duration?: number; delay?: number };
88
+ export type ScreenChart = { id: string; kind: 'bar' | 'line' | 'donut'; title: string; points: { label: string; value: number }[]; hidden?: boolean; motion?: PartMotion };
89
+ export type LayoutPart = 'back' | 'progress' | 'image' | 'loader' | 'title' | 'subtitle' | 'answers' | 'proof' | 'checklist' | 'charts';
90
+ /** Every action except next and complete reaches onAction so the app can make the native request. */
91
+ export type ScreenAction = 'next' | 'complete' | 'requestNotifications' | 'showPaywall' | 'requestTracking' | 'requestLocation' | 'requestCamera' | 'requestPhotos' | 'requestMicrophone' | 'requestContacts' | 'requestHealth' | 'requestBiometrics' | 'requestReview' | 'signUp' | `custom:${string}`;
92
+ /** What onAction returns: nothing or true moves on, false keeps the person on the screen. A Promise makes the flow wait for it. */
93
+ export type ActionResult = void | boolean;
94
+ export type Testimonial = { quote: string; author: string; detail?: string; stars?: number };
95
+ export type Appearance = 'light' | 'dark' | 'system';
96
+ export type SectionAlign = 'top' | 'center' | 'bottom';
97
+ export type FlowScreen = {
98
+ id: string;
99
+ kind: 'Welcome' | 'Question' | 'Feature' | 'Permission' | 'Paywall';
100
+ title: string;
101
+ subtitle: string;
102
+ button: string;
103
+ accent: string;
104
+ answerType?: 'text' | 'singleChoice';
105
+ options?: string[];
106
+ /** Choice questions: allow several picks. The answer joins them with ", ". */
107
+ multiple?: MultipleChoice;
108
+ /** Saves this answer under a name later copy can use as {name} or {name|fallback}. */
109
+ variable?: string;
110
+ /** How the saved answer is shown when recalled: a currency for money, a style for dates. */
111
+ variableFormat?: VariableFormat;
112
+ /** Show this screen only when an earlier choice question's answer matches. */
113
+ showIf?: ScreenCondition;
114
+ /** Optional emoji aligned with `options`. Missing or blank means that choice is text only. */
115
+ optionIcons?: string[];
116
+ /** Entry hints for written answers, e.g. a number with a unit. */
117
+ /** Entry for written answers. Wheel: min/max/step/initial. Date: min/max are years; the answer is YYYY-MM-DD. Country: the answer is the English name. */
118
+ input?: { format: 'text' | 'number' | 'wheel' | 'date' | 'country'; unit?: string; placeholder?: string; min?: number; max?: number; step?: number; initial?: number; /** Other units people can switch to; answers stay in the base unit. */ units?: UnitOption[] };
119
+ /** A "preparing your plan" moment: a ring fills while each step is checked off. */
120
+ loader?: { steps: string[]; duration?: number; autoAdvance?: boolean; color?: string; trackColor?: string; textColor?: string };
121
+ /** Benefits shown with check marks. */
122
+ checklist?: { items: string[]; color?: string; textColor?: string };
123
+ /** Two or more images shown as an auto-advancing carousel; `illustration` mirrors the first. */
124
+ carousel?: { images: string[]; interval?: number; autoplay?: boolean };
125
+ illustration?: string;
126
+ action?: ScreenAction;
127
+ /** Social proof: a star rating, short testimonials, and a stat line. */
128
+ proof?: { rating?: { score: number; count?: string; label?: string }; testimonials?: Testimonial[]; stat?: string };
129
+ /** A/B test: other versions of this screen's copy or image. Each session sees one version for the whole flow. */
130
+ experiment?: ScreenExperiment;
131
+ buttonStyle?: 'filled' | 'outline' | 'text';
132
+ theme?: ScreenTheme;
133
+ translations?: Record<string, ScreenCopy>;
134
+ charts?: ScreenChart[];
135
+ };
136
+ export type ScreenCopy = {
137
+ title?: string;
138
+ subtitle?: string;
139
+ button?: string;
140
+ options?: string[];
141
+ skipLabel?: string;
142
+ backLabel?: string;
143
+ termsLabel?: string;
144
+ };
145
+ export type OnboardingFlow = { schemaVersion: 1; id: string; version: number; updatedAt: string; appearance?: Appearance; /** For the {app_name} variable. */ appName?: string; screens: FlowScreen[]; /** A whole-onboarding A/B test; `screens` is version A. */ experiment?: FlowTest<FlowScreen> };
146
+ type Props = {
147
+ flowId: string;
148
+ /** The app's publishable key (`oy_pub_…`) from the studio. It's meant to ship inside the app, so it can live in source code. */
149
+ apiKey: string;
150
+ baseUrl: string;
151
+ fallbackFlow: OnboardingFlow;
152
+ /** TestFlight and other release builds should pass `testing` explicitly; development builds default to `development`. */
153
+ analyticsEnvironment?: 'development' | 'testing' | 'production';
154
+ /** Locale key for translated screen copy, e.g. `fr-CA` or `es`. */
155
+ locale?: string;
156
+ /** `details.variables` holds every saved answer as a typed value (raw value, unit or currency, and the text shown), keyed by variable name. */
157
+ onComplete?: (answers: Record<string, string>, details: { variables: Record<string, VariableValue> }) => void;
158
+ /** `values` lists every pick for multiple-choice questions, or the single answer otherwise. */
159
+ onAnswer?: (screen: FlowScreen, answer: string, values: string[], variable?: VariableValue) => void;
160
+ /**
161
+ * Runs for every action except next and complete. Return a Promise to make the flow wait (the button shows a spinner):
162
+ * resolving to false, or rejecting, keeps the person on the screen. `details` holds the answers so far, e.g. to save them to a new account.
163
+ */
164
+ onAction?: (action: NonNullable<FlowScreen['action']>, screen: FlowScreen, details: { answers: Record<string, string>; variables: Record<string, VariableValue> }) => ActionResult | Promise<ActionResult>;
165
+ /** Overrides the flow's appearance. 'system' follows the device setting. */
166
+ appearance?: Appearance;
167
+ /** Saves progress on the device so a person who leaves the app (e.g. to sign up in a browser) comes back to the same screen. Defaults to true. */
168
+ resume?: boolean;
169
+ /**
170
+ * Draws the videos a flow can use in place of images, e.g. with expo-video. It should fill `style`, loop, and stay muted.
171
+ * Without it, a video slot shows the screen's illustration color.
172
+ */
173
+ renderVideo?: (video: OnboardingVideo) => React.ReactNode;
174
+ };
175
+ export type OnboardingVideo = { uri: string; resizeMode: 'cover' | 'contain' | 'stretch'; style: StyleProp<ViewStyle> };
176
+ type Progress = { version: number; step: number; screenId: string; sessionId: string; history: number[]; answers: Record<string, string>; picked: Record<string, string[]>; displays: Record<string, string> };
177
+ const cacheKey = (id: string) => `@onboardingyak/flow/${id}`;
178
+ const INSTALL_KEY = '@onboardingyak/install';
179
+ const KEY_HEADER = 'X-OnboardingYak-Key';
180
+ const progressKey = (id: string) => `@onboardingyak/progress/${id}`;
181
+ const HEX = /^#[\da-f]{6}$/i;
182
+
183
+ function colorOf(value: unknown, fallback: string) {
184
+ return typeof value === 'string' && HEX.test(value) ? value : fallback;
185
+ }
186
+ function rangeOf(value: unknown, fallback: number, min: number, max: number) {
187
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
188
+ }
189
+ function lightBackground(hex: string) {
190
+ const value = Number.parseInt(hex.slice(1), 16);
191
+ if (!Number.isFinite(value)) return true;
192
+ const channel = (part: number) => {
193
+ const scaled = part / 255;
194
+ return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
195
+ };
196
+ const luma = 0.2126 * channel((value >> 16) & 255) + 0.7152 * channel((value >> 8) & 255) + 0.0722 * channel(value & 255);
197
+ return luma > 0.45;
198
+ }
199
+ const FONT_NAMES: Record<string, string> = {
200
+ manrope: 'Manrope',
201
+ 'dm-sans': 'DM Sans',
202
+ outfit: 'Outfit',
203
+ nunito: 'Nunito',
204
+ fraunces: 'Fraunces',
205
+ newsreader: 'Newsreader',
206
+ lora: 'Lora',
207
+ 'libre-baskerville': 'Libre Baskerville',
208
+ };
209
+ function fontName(value: unknown, fallback: string) {
210
+ return typeof value === 'string' && FONT_NAMES[value] ? FONT_NAMES[value] : fallback;
211
+ }
212
+ function fontWeight(value: unknown): '400' | '500' | '700' {
213
+ return value === 400 || value === 500 || value === 700 ? String(value) as '400' | '500' | '700' : '700';
214
+ }
215
+ function termsHref(url: unknown) {
216
+ if (typeof url !== 'string') return null;
217
+ const trimmed = url.trim();
218
+ if (!trimmed) return null;
219
+ return /^https?:\/\//i.test(trimmed) ? trimmed.slice(0, 500) : `https://${trimmed.slice(0, 500)}`;
220
+ }
221
+ type EdgeBox = { top: number; right: number; bottom: number; left: number };
222
+ type AxisBox = { vertical: number; horizontal: number };
223
+ function resolveEdges(value: number | EdgeInsets | undefined, fallback: EdgeBox, legacy: 'top' | 'bottom' | 'uniform' | 'inline' = 'top'): EdgeBox {
224
+ if (typeof value === 'number') {
225
+ if (legacy === 'bottom') {
226
+ const amount = rangeOf(value, fallback.bottom, 0, 64);
227
+ return { top: 0, right: 0, bottom: amount, left: 0 };
228
+ }
229
+ if (legacy === 'uniform') {
230
+ const amount = rangeOf(value, fallback.top, 0, 64);
231
+ return { top: amount, right: amount, bottom: amount, left: amount };
232
+ }
233
+ if (legacy === 'inline') {
234
+ const amount = rangeOf(value, fallback.right, 0, 64);
235
+ return { top: fallback.top, right: amount, bottom: fallback.bottom, left: amount };
236
+ }
237
+ const amount = rangeOf(value, fallback.top, 0, 64);
238
+ return { top: amount, right: 0, bottom: 0, left: 0 };
239
+ }
240
+ return {
241
+ top: rangeOf(value?.top, fallback.top, 0, 64),
242
+ right: rangeOf(value?.right, fallback.right, 0, 64),
243
+ bottom: rangeOf(value?.bottom, fallback.bottom, 0, 64),
244
+ left: rangeOf(value?.left, fallback.left, 0, 64),
245
+ };
246
+ }
247
+ function resolveAxes(value: number | AxisInsets | undefined, fallback: AxisBox): AxisBox {
248
+ if (typeof value === 'number') {
249
+ return { vertical: fallback.vertical, horizontal: rangeOf(value, fallback.horizontal, 0, 64) };
250
+ }
251
+ return {
252
+ vertical: rangeOf(value?.vertical, fallback.vertical, 0, 64),
253
+ horizontal: rangeOf(value?.horizontal, fallback.horizontal, 0, 64),
254
+ };
255
+ }
256
+ function boxStyle(padding: EdgeBox, margin: AxisBox) {
257
+ return {
258
+ paddingTop: padding.top,
259
+ paddingRight: padding.right,
260
+ paddingBottom: padding.bottom,
261
+ paddingLeft: padding.left,
262
+ marginTop: margin.vertical,
263
+ marginBottom: margin.vertical,
264
+ marginLeft: margin.horizontal,
265
+ marginRight: margin.horizontal,
266
+ };
267
+ }
268
+ // Same curve as the studio: a light tint at the top that deepens toward the bottom, where the copy sits.
269
+ function overlayAlphaAt(position: number, opacity: number, style: 'solid' | 'gradient') {
270
+ const full = Math.min(90, Math.max(0, opacity)) / 100;
271
+ if (style !== 'gradient') return full;
272
+ const start = full * 0.25;
273
+ if (position <= 0.3) return start;
274
+ return start + (full - start) * ((position - 0.3) / 0.7) ** 1.3;
275
+ }
276
+
277
+ // Mirrors lib/flow-schema.ts.
278
+ const ALIGN_RANK = { top: 0, center: 1, bottom: 2 } as const;
279
+ /** Each section sits in a top, middle, or bottom zone, keeping header → body → footer order: a later section is never placed above an earlier one. */
280
+ function sectionAligns(theme: { headerAlign?: SectionAlign; bodyAlign?: SectionAlign; footerAlign?: SectionAlign } | undefined, backdrop: boolean) {
281
+ const header: SectionAlign = theme?.headerAlign ?? 'top';
282
+ let body: SectionAlign = theme?.bodyAlign ?? (backdrop ? 'bottom' : 'top');
283
+ let footer: SectionAlign = theme?.footerAlign ?? 'bottom';
284
+ if (ALIGN_RANK[body] < ALIGN_RANK[header]) body = header;
285
+ if (ALIGN_RANK[footer] < ALIGN_RANK[body]) footer = body;
286
+ return { header, body, footer };
287
+ }
288
+
289
+ const OVERLAY_BANDS = 48;
290
+ const LAYOUT_PARTS: LayoutPart[] = ['back', 'progress', 'image', 'loader', 'title', 'subtitle', 'answers', 'proof', 'checklist', 'charts'];
291
+
292
+ // Same rules as the studio: saved order first, then any missing parts in their default section.
293
+ function resolveArrangement(arrangement: { header?: unknown; body?: unknown } | undefined, progress: 'above' | 'below') {
294
+ const header: LayoutPart[] = [];
295
+ const body: LayoutPart[] = [];
296
+ const seen = new Set<LayoutPart>();
297
+ const take = (list: unknown, into: LayoutPart[]) => { if (Array.isArray(list)) for (const part of list) if (LAYOUT_PARTS.includes(part) && !seen.has(part)) { seen.add(part); into.push(part); } };
298
+ take(arrangement?.header, header);
299
+ take(arrangement?.body, body);
300
+ for (const part of (progress === 'above' ? ['back', 'progress', 'image'] : ['back', 'image', 'progress']) as LayoutPart[]) if (!seen.has(part)) header.push(part);
301
+ for (const part of ['loader', 'title', 'subtitle', 'answers', 'proof', 'checklist', 'charts'] as LayoutPart[]) if (!seen.has(part)) body.push(part);
302
+ return { header, body };
303
+ }
304
+
305
+ function carouselImages(screen: FlowScreen) {
306
+ const images = (Array.isArray(screen.carousel?.images) ? screen.carousel.images : []).filter((image) => typeof image === 'string' && image.length > 0).slice(0, 8);
307
+ return images.length >= 2 ? images : screen.illustration ? [screen.illustration] : [];
308
+ }
309
+
310
+ /** Media fields hold images or videos; a video is a data URL or a link to a common video file. */
311
+ function isVideoUri(uri: string) {
312
+ return uri.startsWith('data:video/') || /\.(mp4|m4v|mov|webm)(?:[?#]|$)/i.test(uri);
313
+ }
314
+
315
+ function phoneImageUri(uri: string) {
316
+ if (!uri.includes('images.pexels.com')) return uri;
317
+ try {
318
+ const url = new URL(uri);
319
+ const width = Number(url.searchParams.get('w'));
320
+ if (width > 0 && width < 1800) url.searchParams.set('w', '1800');
321
+ return url.toString();
322
+ } catch { return uri; }
323
+ }
324
+
325
+ const VideoRenderer = createContext<Props['renderVideo']>(undefined);
326
+
327
+ // An image, or a video drawn by the app's renderVideo.
328
+ function Media({ uri, resize, style, label }: { uri: string; resize: 'cover' | 'contain' | 'stretch'; style: StyleProp<ViewStyle>; label?: string }) {
329
+ const renderVideo = useContext(VideoRenderer);
330
+ if (!isVideoUri(uri)) return <Image source={{ uri: phoneImageUri(uri) }} resizeMode={resize} accessibilityLabel={label} accessibilityIgnoresInvertColors style={style as StyleProp<ImageStyle>} />;
331
+ return <View style={style} accessibilityLabel={label}>{renderVideo?.({ uri, resizeMode: resize, style: StyleSheet.absoluteFill })}</View>;
332
+ }
333
+
334
+ // A phone shell around the image, sized by height. Proportions match the studio's CSS mockup.
335
+ function DeviceFrame({ device, children }: { device: 'iphone' | 'android'; children: React.ReactNode }) {
336
+ const [width, setWidth] = useState(0);
337
+ const iphone = device === 'iphone';
338
+ const bezel = width * (iphone ? 0.052 : 0.044);
339
+ const radius = width * (iphone ? 0.15 : 0.1);
340
+ return <View onLayout={(event) => setWidth(event.nativeEvent.layout.width)} style={[styles.deviceFrame, { borderRadius: radius, padding: bezel, backgroundColor: iphone ? '#141416' : '#1d1e22' }]}>
341
+ <View style={[styles.deviceButton, { right: -width * 0.014, top: iphone ? '26%' : '21%', height: iphone ? '11%' : '7%', width: width * 0.016 }]} />
342
+ <View style={[styles.deviceButton, iphone ? { left: -width * 0.014 } : { right: -width * 0.014 }, { top: iphone ? '20%' : '32%', height: iphone ? '13%' : '14%', width: width * 0.016 }]} />
343
+ <View style={[styles.deviceScreen, { borderRadius: Math.max(0, radius - bezel) }]}>
344
+ {children}
345
+ {iphone
346
+ ? <View style={[styles.deviceIsland, { top: width * 0.03, width: width * 0.3, height: width * 0.075, marginLeft: -width * 0.15 }]} />
347
+ : <View style={[styles.deviceIsland, { top: width * 0.03, width: width * 0.045, height: width * 0.045, marginLeft: -width * 0.0225 }]} />}
348
+ </View>
349
+ </View>;
350
+ }
351
+
352
+ type CarouselProps = { images: string[]; resize: 'cover' | 'contain' | 'stretch'; interval: number; autoplay: boolean };
353
+
354
+ // Swipeable slides with dots; advances on a timer unless the person is dragging or prefers reduced motion.
355
+ function InlineCarousel({ images, resize, interval, autoplay }: CarouselProps) {
356
+ const [width, setWidth] = useState(0);
357
+ const [index, setIndex] = useState(0);
358
+ const scroller = useRef<React.ComponentRef<typeof ScrollView>>(null);
359
+ const dragging = useRef(false);
360
+ useEffect(() => {
361
+ if (!autoplay || images.length < 2) return;
362
+ const timer = setInterval(() => { if (!dragging.current) setIndex((current) => (current + 1) % images.length); }, interval);
363
+ return () => clearInterval(timer);
364
+ }, [autoplay, images.length, interval]);
365
+ useEffect(() => { if (width) scroller.current?.scrollTo({ x: index * width, animated: true }); }, [index, width]);
366
+ return <View style={StyleSheet.absoluteFill} onLayout={(event) => setWidth(event.nativeEvent.layout.width)}>
367
+ <ScrollView ref={scroller} horizontal pagingEnabled showsHorizontalScrollIndicator={false} onScrollBeginDrag={() => { dragging.current = true; }} onMomentumScrollEnd={(event) => { dragging.current = false; if (width) setIndex(Math.round(event.nativeEvent.contentOffset.x / width)); }}>
368
+ {images.map((uri, imageIndex) => <Media key={imageIndex} uri={uri} resize={resize} label={`Image ${imageIndex + 1} of ${images.length}`} style={{ width, height: '100%' }} />)}
369
+ </ScrollView>
370
+ <View pointerEvents="none" style={styles.carouselDots}>{images.map((_, dotIndex) => <View key={dotIndex} style={[styles.carouselDot, dotIndex === index && styles.carouselDotActive]} />)}</View>
371
+ </View>;
372
+ }
373
+
374
+ // Full-screen photos crossfade behind the content.
375
+ function BackdropCarousel({ images, resize, interval, autoplay }: CarouselProps) {
376
+ const [index, setIndex] = useState(0);
377
+ const fades = useRef(images.map((_, imageIndex) => new Animated.Value(imageIndex === 0 ? 1 : 0))).current;
378
+ useEffect(() => {
379
+ if (!autoplay || images.length < 2) return;
380
+ const timer = setInterval(() => setIndex((current) => (current + 1) % images.length), interval);
381
+ return () => clearInterval(timer);
382
+ }, [autoplay, images.length, interval]);
383
+ useEffect(() => {
384
+ Animated.parallel(fades.map((fade, imageIndex) => Animated.timing(fade, { toValue: imageIndex === index ? 1 : 0, duration: 900, useNativeDriver: true }))).start();
385
+ }, [fades, index]);
386
+ return <>{images.map((uri, imageIndex) => <Animated.View key={imageIndex} pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: fades[imageIndex] }]}><Media uri={uri} resize={resize} style={StyleSheet.absoluteFill} /></Animated.View>)}</>;
387
+ }
388
+
389
+ // Drawn from thin stacked bands so the SDK needs no native gradient dependency.
390
+ function Overlay({ color, opacity, style }: { color: string; opacity: number; style: 'solid' | 'gradient' }) {
391
+ if (style !== 'gradient') return <View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: color, opacity: opacity / 100 }]} />;
392
+ return <View pointerEvents="none" style={StyleSheet.absoluteFill}>{Array.from({ length: OVERLAY_BANDS }, (_, index) => <View key={index} style={{ flex: 1, backgroundColor: color, opacity: overlayAlphaAt((index + 0.5) / OVERLAY_BANDS, opacity, style) }} />)}</View>;
393
+ }
394
+
395
+ function resolveTheme(screen: FlowScreen, dark = false) {
396
+ const light = resolveLightTheme(screen);
397
+ return dark ? { ...light, ...darkColors(light, screen.theme?.dark) } : light;
398
+ }
399
+
400
+ function resolveLightTheme(screen: FlowScreen) {
401
+ const theme = screen.theme ?? {};
402
+ const align = theme.align === 'left' || theme.align === 'right' ? theme.align : 'center' as const;
403
+ const label = (value: unknown, fallback: string, max = 32) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : fallback;
404
+ return {
405
+ background: colorOf(theme.background, '#ffffff'),
406
+ showImage: theme.showImage !== false,
407
+ showProgress: theme.showProgress !== false,
408
+ showTitle: theme.showTitle !== false,
409
+ showSubtitle: theme.showSubtitle !== false,
410
+ showButton: theme.showButton !== false,
411
+ showSkip: theme.showSkip !== false,
412
+ showBack: theme.showBack === true,
413
+ showTerms: theme.showTerms === true,
414
+ showLoader: theme.showLoader !== false,
415
+ showChecklist: theme.showChecklist !== false,
416
+ showCharts: theme.showCharts !== false,
417
+ showProof: theme.showProof !== false,
418
+ gap: rangeOf(theme.gap, 14, 0, 48),
419
+ margin: resolveAxes(theme.margin, { vertical: 0, horizontal: 18 }),
420
+ headerPadding: resolveEdges(theme.headerPadding, { top: 0, right: 0, bottom: 0, left: 0 }, 'top'),
421
+ bodyPadding: resolveEdges(theme.bodyPadding, { top: 0, right: 0, bottom: 0, left: 0 }, 'top'),
422
+ footerPadding: resolveEdges(theme.footerPadding, { top: 0, right: 0, bottom: 24, left: 0 }, 'bottom'),
423
+ headerMargin: resolveAxes(theme.headerMargin, { vertical: 0, horizontal: 0 }),
424
+ bodyMargin: resolveAxes(theme.bodyMargin, { vertical: 0, horizontal: 0 }),
425
+ footerMargin: resolveAxes(theme.footerMargin, { vertical: 0, horizontal: 0 }),
426
+ align,
427
+ text: colorOf(theme.text, '#252b26'),
428
+ muted: colorOf(theme.muted, '#818a82'),
429
+ primary: colorOf(theme.primary, '#8b1e24'),
430
+ buttonPadding: resolveEdges(theme.buttonPadding, { top: 10, right: 14, bottom: 10, left: 14 }, 'inline'),
431
+ buttonRadius: rangeOf(theme.buttonRadius, 8, 0, 40),
432
+ cardPadding: resolveEdges(theme.cardPadding, { top: 8, right: 8, bottom: 8, left: 8 }, 'uniform'),
433
+ cardRadius: rangeOf(theme.cardRadius, 7, 0, 40),
434
+ cardBackground: colorOf(theme.cardBackground, '#ffffff'),
435
+ imageFit: theme.imageFit === 'contain' || theme.imageFit === 'fill' ? theme.imageFit : 'cover' as const,
436
+ imageWidthMode: theme.imageWidthMode === 'fill' ? 'fill' as const : 'fixed' as const,
437
+ imageHeightMode: theme.imageHeightMode === 'fill' ? 'fill' as const : 'fixed' as const,
438
+ imageWidth: rangeOf(theme.imageWidth, 180, 40, 360),
439
+ imageHeight: rangeOf(theme.imageHeight, 160, 40, 360),
440
+ imageRadius: rangeOf(theme.imageRadius, 24, 0, 80),
441
+ imagePadding: resolveEdges(theme.imagePadding, { top: 0, right: 0, bottom: 0, left: 0 }, 'uniform'),
442
+ imageMargin: resolveAxes(theme.imageMargin, { vertical: 0, horizontal: 0 }),
443
+ imageBorderWidth: rangeOf(theme.imageBorderWidth, 0, 0, 12),
444
+ imageBorderColor: colorOf(theme.imageBorderColor, '#E5E7EB'),
445
+ imagePlacement: theme.imagePlacement === 'background' ? 'background' as const : 'inline' as const,
446
+ imageMotion: theme.imageMotion === 'float' || theme.imageMotion === 'rock' || theme.imageMotion === 'pulse' || theme.imageMotion === 'drift' ? theme.imageMotion : 'none' as const,
447
+ imageFrame: theme.imageFrame === 'iphone' || theme.imageFrame === 'android' ? theme.imageFrame : 'none' as const,
448
+ overlayColor: colorOf(theme.overlayColor, '#000000'),
449
+ overlayOpacity: rangeOf(theme.overlayOpacity, 45, 0, 90),
450
+ overlayStyle: theme.overlayStyle === 'gradient' ? 'gradient' as const : 'solid' as const,
451
+ progress: theme.progress === 'above' ? 'above' as const : 'below' as const,
452
+ progressStyle: theme.progressStyle === 'bar' || theme.progressStyle === 'dots' ? theme.progressStyle : 'segments' as const,
453
+ skipLabel: label(theme.skipLabel, 'Skip'),
454
+ backLabel: label(theme.backLabel, 'Back'),
455
+ termsLabel: label(theme.termsLabel, 'By continuing, you agree to our Terms', 160),
456
+ termsUrl: termsHref(theme.termsUrl),
457
+ font: fontName(theme.font, 'Manrope'),
458
+ bodyFont: fontName(theme.bodyFont, 'DM Sans'),
459
+ titleSize: rangeOf(theme.titleSize, 18, 16, 48),
460
+ bodySize: rangeOf(theme.bodySize, 12, 11, 32),
461
+ titleWeight: fontWeight(theme.titleWeight),
462
+ };
463
+ }
464
+
465
+ function validFlow(value: unknown, expectedId: string): value is OnboardingFlow {
466
+ if (!value || typeof value !== 'object') return false;
467
+ const flow = value as Partial<OnboardingFlow>;
468
+ return flow.schemaVersion === 1 && flow.id === expectedId && Number.isInteger(flow.version) &&
469
+ Array.isArray(flow.screens) && flow.screens.length > 0 && flow.screens.every(validScreen);
470
+ }
471
+
472
+ function validScreen(s: FlowScreen) {
473
+ return !!s && typeof s.id === 'string' && ['Welcome', 'Question', 'Feature', 'Permission', 'Paywall'].includes(s.kind) &&
474
+ typeof s.title === 'string' && typeof s.subtitle === 'string' && typeof s.button === 'string' &&
475
+ /^#[\da-f]{6}$/i.test(s.accent) && (s.answerType === undefined || s.answerType === 'text' || s.answerType === 'singleChoice') &&
476
+ (s.options === undefined || (Array.isArray(s.options) && s.options.every((option) => typeof option === 'string'))) &&
477
+ (s.optionIcons === undefined || (Array.isArray(s.optionIcons) && s.optionIcons.length <= 24 && s.optionIcons.every((icon) => typeof icon === 'string' && icon.length <= 32))) &&
478
+ (s.translations === undefined || (typeof s.translations === 'object' && s.translations !== null && !Array.isArray(s.translations) && Object.values(s.translations).every((copy) =>
479
+ typeof copy === 'object' && copy !== null && !Array.isArray(copy) && Object.entries(copy).every(([key, text]) =>
480
+ ['title', 'subtitle', 'button', 'skipLabel', 'backLabel', 'termsLabel'].includes(key) ? typeof text === 'string' : key === 'options' && Array.isArray(text) && text.every((option) => typeof option === 'string'))))) &&
481
+ (s.illustration === undefined || typeof s.illustration === 'string') &&
482
+ !(s.kind === 'Question' && s.answerType === 'singleChoice' && !(Array.isArray(s.options) && s.options.length > 0));
483
+ }
484
+
485
+ /** A flow test is used only when every version's screens pass the same checks; otherwise the original runs. */
486
+ function usableTest(flow: OnboardingFlow) {
487
+ const test = flow.experiment;
488
+ if (!test || !Array.isArray(test.variants) || !test.variants.length) return undefined;
489
+ return test.variants.every((variant) => Array.isArray(variant.screens) && variant.screens.length > 0 && variant.screens.every(validScreen)) ? test : undefined;
490
+ }
491
+
492
+ /** The screens this install runs: the original, or its flow test version. */
493
+ function screensFor(flow: OnboardingFlow, assignId: string) {
494
+ const test = usableTest(flow);
495
+ const version = test ? pickFlowVersion(test, assignId) : undefined;
496
+ return test && version && version !== 'a' ? test.variants.find((variant) => variant.id === version)?.screens ?? flow.screens : flow.screens;
497
+ }
498
+
499
+ const stringRecord = (value: unknown): value is Record<string, string> =>
500
+ !!value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every((item) => typeof item === 'string');
501
+
502
+ /** Saved progress is used only for the same flow version, and only when its screen is still at the saved position. */
503
+ function usableProgress(value: unknown, flow: OnboardingFlow, assignId: string): Progress | undefined {
504
+ if (!value || typeof value !== 'object') return undefined;
505
+ const saved = value as Partial<Progress>;
506
+ const screens = screensFor(flow, assignId);
507
+ const valid = saved.version === flow.version && Number.isInteger(saved.step) && typeof saved.screenId === 'string' && typeof saved.sessionId === 'string' &&
508
+ Array.isArray(saved.history) && saved.history.every((index) => Number.isInteger(index) && index >= 0 && index < screens.length) &&
509
+ stringRecord(saved.answers) && stringRecord(saved.displays) &&
510
+ !!saved.picked && typeof saved.picked === 'object' && Object.values(saved.picked).every((list) => Array.isArray(list) && list.every((item) => typeof item === 'string'));
511
+ return valid && screens[saved.step as number]?.id === saved.screenId ? saved as Progress : undefined;
512
+ }
513
+
514
+ function localeCopy(screen: FlowScreen, locale?: string): ScreenCopy | undefined {
515
+ if (!locale || !screen.translations) return undefined;
516
+ const translations = screen.translations;
517
+ const normalized = locale.trim().replace(/_/g, '-').toLowerCase();
518
+ const exact = Object.keys(translations).find((key) => key.toLowerCase() === normalized);
519
+ const language = normalized.split('-')[0];
520
+ const fallback = exact ?? Object.keys(translations).find((key) => key.toLowerCase() === language);
521
+ return fallback ? translations[fallback] : undefined;
522
+ }
523
+
524
+ function optionIconAt(icons: unknown, index: number) {
525
+ if (!Array.isArray(icons)) return '';
526
+ const icon = icons[index];
527
+ if (typeof icon !== 'string') return '';
528
+ const text = icon.trim();
529
+ return text && text.length <= 32 ? text : '';
530
+ }
531
+
532
+ function deviceLocale() {
533
+ try {
534
+ if (typeof Intl !== 'undefined' && typeof Intl.DateTimeFormat === 'function') {
535
+ return Intl.DateTimeFormat().resolvedOptions().locale;
536
+ }
537
+ } catch { /* Use the default locale when the runtime cannot report one. */ }
538
+ return 'en';
539
+ }
540
+
541
+ // {name} tokens in copy become saved answers.
542
+ function fillScreen(screen: FlowScreen, values: Record<string, string>): FlowScreen {
543
+ const fill = (text: string) => fillTokens(text, values);
544
+ return {
545
+ ...screen,
546
+ proof: screen.proof ? { ...screen.proof, stat: screen.proof.stat ? fill(screen.proof.stat) : undefined } : undefined,
547
+ title: fill(screen.title),
548
+ subtitle: fill(screen.subtitle),
549
+ button: fill(screen.button),
550
+ loader: screen.loader ? { ...screen.loader, steps: screen.loader.steps.map(fill) } : undefined,
551
+ checklist: screen.checklist ? { ...screen.checklist, items: screen.checklist.items.map(fill) } : undefined,
552
+ };
553
+ }
554
+
555
+ function localizeScreen(screen: FlowScreen, locale?: string): FlowScreen {
556
+ const copy = localeCopy(screen, locale);
557
+ if (!copy) return screen;
558
+ const theme = screen.theme ?? {};
559
+ return {
560
+ ...screen,
561
+ title: copy.title ?? screen.title,
562
+ subtitle: copy.subtitle ?? screen.subtitle,
563
+ button: copy.button ?? screen.button,
564
+ options: copy.options ?? screen.options,
565
+ theme: {
566
+ ...theme,
567
+ skipLabel: copy.skipLabel ?? theme.skipLabel,
568
+ backLabel: copy.backLabel ?? theme.backLabel,
569
+ termsLabel: copy.termsLabel ?? theme.termsLabel,
570
+ },
571
+ };
572
+ }
573
+
574
+ const ENTERS = ['fade', 'rise', 'slide', 'pop'] as const;
575
+
576
+ function readMotion(value: unknown, duration = 480, delay = 0): PartMotion | undefined {
577
+ if (!value || typeof value !== 'object') return undefined;
578
+ const item = value as Partial<PartMotion>;
579
+ if (typeof item.enter !== 'string' || !(ENTERS as readonly string[]).includes(item.enter)) return undefined;
580
+ const span = typeof item.duration === 'number' && Number.isFinite(item.duration) ? Math.min(1600, Math.max(160, Math.round(item.duration))) : duration;
581
+ const wait = typeof item.delay === 'number' && Number.isFinite(item.delay) ? Math.min(2000, Math.max(0, Math.round(item.delay))) : delay;
582
+ return { enter: item.enter as PartMotion['enter'], duration: span, delay: wait };
583
+ }
584
+
585
+ function readCharts(value: unknown): ScreenChart[] {
586
+ if (!Array.isArray(value)) return [];
587
+ return value.flatMap((chart) => {
588
+ if (!chart || typeof chart !== 'object') return [];
589
+ const item = chart as Partial<ScreenChart>;
590
+ if (typeof item.id !== 'string' || (item.kind !== 'bar' && item.kind !== 'line' && item.kind !== 'donut')) return [];
591
+ if (item.hidden) return [];
592
+ if (!Array.isArray(item.points)) return [];
593
+ const points = item.points.flatMap((point) => {
594
+ if (!point || typeof point.label !== 'string' || typeof point.value !== 'number' || !Number.isFinite(point.value)) return [];
595
+ return [{ label: point.label.slice(0, 16), value: Math.min(100, Math.max(0, point.value)) }];
596
+ }).slice(0, 8);
597
+ if (!points.length) return [];
598
+ return [{ id: item.id, kind: item.kind, title: typeof item.title === 'string' ? item.title.slice(0, 48) : '', points, motion: readMotion(item.motion, 520, 300) }];
599
+ }).slice(0, 4);
600
+ }
601
+
602
+ function shade(hex: string, index: number) {
603
+ if (index === 0 || !HEX.test(hex)) return hex;
604
+ const amount = [0.28, 0.46, 0.62, 0.22, 0.4, 0.7, 0.54][(index - 1) % 7];
605
+ const toward = index % 2 === 0 ? 'ffffff' : '1f2937';
606
+ const mix = (start: number) => {
607
+ const from = Number.parseInt(hex.slice(start, start + 2), 16);
608
+ const to = Number.parseInt(toward.slice(start - 1, start + 1), 16);
609
+ return Math.round(from + (to - from) * amount).toString(16).padStart(2, '0');
610
+ };
611
+ return `#${mix(1)}${mix(3)}${mix(5)}`;
612
+ }
613
+
614
+ function Entrance({ motion, children }: { motion: PartMotion; children: React.ReactNode }) {
615
+ const opacity = useRef(new Animated.Value(0)).current;
616
+ const drift = useRef(new Animated.Value(motion.enter === 'rise' ? 16 : motion.enter === 'slide' ? 22 : 0)).current;
617
+ const scale = useRef(new Animated.Value(motion.enter === 'pop' ? 0.92 : 1)).current;
618
+ useEffect(() => {
619
+ opacity.setValue(0);
620
+ drift.setValue(motion.enter === 'rise' ? 16 : motion.enter === 'slide' ? 22 : 0);
621
+ scale.setValue(motion.enter === 'pop' ? 0.92 : 1);
622
+ const duration = motion.duration ?? 480;
623
+ const delay = motion.delay ?? 0;
624
+ Animated.parallel([
625
+ Animated.timing(opacity, { toValue: 1, duration, delay, useNativeDriver: true }),
626
+ Animated.timing(drift, { toValue: 0, duration, delay, useNativeDriver: true }),
627
+ Animated.timing(scale, { toValue: 1, duration, delay, useNativeDriver: true }),
628
+ ]).start();
629
+ }, [delayOf(motion), drift, motion.enter, opacity, scale]);
630
+ const shift = motion.enter === 'slide' ? { translateX: drift } : { translateY: drift };
631
+ return <Animated.View style={{ alignSelf: 'stretch', opacity, transform: [shift, { scale }] }}>{children}</Animated.View>;
632
+ }
633
+
634
+ function delayOf(motion: PartMotion) {
635
+ return `${motion.enter}:${motion.duration ?? 0}:${motion.delay ?? 0}`;
636
+ }
637
+
638
+ // Graphs draw in from zero: bars grow, lines reveal left to right, and donut segments sweep around.
639
+ function NativeChart({ chart, color, ink, muted, surface, animate = true, delay = 300 }: { chart: ScreenChart; color: string; ink: string; muted: string; surface: string; animate?: boolean; delay?: number }) {
640
+ const [width, setWidth] = useState(0);
641
+ const draw = useRef(new Animated.Value(animate ? 0 : 1)).current;
642
+ useEffect(() => {
643
+ if (!animate) return;
644
+ Animated.timing(draw, { toValue: 1, duration: 1000, delay, easing: Easing.out(Easing.cubic), useNativeDriver: false }).start();
645
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
646
+ const max = Math.max(...chart.points.map((point) => point.value), 1);
647
+ const total = chart.points.reduce((sum, point) => sum + point.value, 0) || 1;
648
+ const height = 92;
649
+ return (
650
+ <View style={styles.chart} onLayout={(event) => setWidth(event.nativeEvent.layout.width)}>
651
+ {!!chart.title.trim() && <Text style={[styles.chartTitle, { color: ink }]}>{chart.title}</Text>}
652
+ {chart.kind === 'donut' ? (
653
+ <View style={styles.donutRow}>
654
+ <View style={styles.donut}>
655
+ {Array.from({ length: 42 }, (_, index) => {
656
+ const at = (index + 0.5) / 42;
657
+ let cursor = 0;
658
+ let tone = color;
659
+ for (let pointIndex = 0; pointIndex < chart.points.length; pointIndex += 1) {
660
+ cursor += chart.points[pointIndex].value / total;
661
+ tone = shade(color, pointIndex);
662
+ if (at <= cursor) break;
663
+ }
664
+ return <Animated.View key={index} style={[styles.donutTickWrap, { opacity: draw.interpolate({ inputRange: [Math.max(0, at - 0.05), at], outputRange: [0, 1], extrapolate: 'clamp' }), transform: [{ rotate: `${(index / 42) * 360}deg` }] }]}><View style={[styles.donutTick, { backgroundColor: tone }]} /></Animated.View>;
665
+ })}
666
+ <View style={[styles.donutHole, { backgroundColor: surface }]}><Text style={[styles.donutTotal, { color: ink }]}>{Math.round(total)}</Text></View>
667
+ </View>
668
+ <View style={styles.legend}>{chart.points.map((point, index) => <Text key={`${point.label}-${index}`} style={[styles.legendItem, { color: muted }]}>{point.label} {point.value}</Text>)}</View>
669
+ </View>
670
+ ) : chart.kind === 'line' ? (
671
+ <View style={{ height: height + 18 }}>
672
+ <Animated.View style={{ position: 'absolute', left: 0, top: 0, bottom: 0, overflow: 'hidden', width: draw.interpolate({ inputRange: [0, 1], outputRange: [0, width + 4] }) }}><View style={{ width, height: height + 16 }}>
673
+ {/* Soft fill under the line, drawn as thin columns because React Native has no SVG paths. */}
674
+ {width > 0 && chart.points.length > 1 && Array.from({ length: Math.ceil((width - 8) / 2) }, (_, column) => {
675
+ const x = 4 + column * 2 + 1;
676
+ const position = ((x - 4) / (width - 8)) * (chart.points.length - 1);
677
+ const at = Math.min(chart.points.length - 2, Math.floor(position));
678
+ const value = chart.points[at].value + (chart.points[at + 1].value - chart.points[at].value) * (position - at);
679
+ const top = height - 8 - (value / max) * (height - 16);
680
+ return <View key={`fill-${column}`} style={{ position: 'absolute', left: x - 1, top, width: 2, height: height - top, backgroundColor: color, opacity: 0.14 }} />;
681
+ })}
682
+ {width > 0 && chart.points.slice(0, -1).map((point, index) => {
683
+ const next = chart.points[index + 1];
684
+ const x1 = (index / (chart.points.length - 1)) * (width - 8) + 4;
685
+ const x2 = ((index + 1) / (chart.points.length - 1)) * (width - 8) + 4;
686
+ const y1 = height - 8 - (point.value / max) * (height - 16);
687
+ const y2 = height - 8 - (next.value / max) * (height - 16);
688
+ const length = Math.max(1, Math.hypot(x2 - x1, y2 - y1));
689
+ return <View key={index} style={{ position: 'absolute', left: (x1 + x2) / 2 - length / 2, top: (y1 + y2) / 2 - 1, width: length, height: 2, borderRadius: 1, backgroundColor: color, transform: [{ rotate: `${Math.atan2(y2 - y1, x2 - x1)}rad` }] }} />;
690
+ })}
691
+ {chart.points.map((point, index) => {
692
+ const x = chart.points.length === 1 ? width / 2 : (index / (chart.points.length - 1)) * (width - 8) + 4;
693
+ const y = height - 8 - (point.value / max) * (height - 16);
694
+ return <View key={`${point.label}-${index}`} style={[styles.lineDot, { left: x - 3, top: y - 3, backgroundColor: color }]} />;
695
+ })}
696
+ </View></Animated.View>
697
+ {width > 0 && chart.points.map((point, index) => {
698
+ const x = chart.points.length === 1 ? width / 2 : (index / (chart.points.length - 1)) * (width - 8) + 4;
699
+ return <Text key={`label-${index}`} numberOfLines={1} style={[styles.lineLabel, { color: muted, left: Math.max(0, Math.min(width - 56, x - 28)), textAlign: index === 0 && x - 28 < 0 ? 'left' : index === chart.points.length - 1 && x + 28 > width ? 'right' : 'center' }]}>{point.label}</Text>;
700
+ })}
701
+ </View>
702
+ ) : (
703
+ <View style={styles.bars}>
704
+ {chart.points.map((point, index) => <View key={`${point.label}-${index}`} style={styles.barCol}><Animated.View style={[styles.bar, { height: draw.interpolate({ inputRange: [0, 1], outputRange: [0, Math.max(4, (point.value / max) * 72)] }), backgroundColor: color, opacity: 0.45 + (point.value / max) * 0.55 }]} /><Text style={[styles.barLabel, { color: muted }]} numberOfLines={1}>{point.label}</Text></View>)}
705
+ </View>
706
+ )}
707
+ </View>
708
+ );
709
+ }
710
+
711
+ function withEntrance(motion: PartMotion | undefined, key: string, node: React.ReactNode) {
712
+ if (!motion || node == null || node === false) return node;
713
+ return <Entrance key={key} motion={motion}>{node}</Entrance>;
714
+ }
715
+
716
+ function ImageMotion({ motion, reduceMotion, children, style }: { motion?: ScreenTheme['imageMotion']; reduceMotion: boolean; children: React.ReactNode; style?: StyleProp<ViewStyle> }) {
717
+ const progress = useRef(new Animated.Value(0)).current;
718
+ useEffect(() => {
719
+ progress.stopAnimation();
720
+ progress.setValue(0);
721
+ if (reduceMotion || !motion || motion === 'none') return;
722
+ const floating = motion === 'float';
723
+ const rocking = motion === 'rock';
724
+ const pulse = motion === 'pulse';
725
+ const from = Animated.timing(progress, { toValue: 1, duration: floating || rocking ? 1900 : 2400, easing: Easing.inOut(Easing.sin), useNativeDriver: true });
726
+ const back = Animated.timing(progress, { toValue: 0, duration: floating || rocking ? 1900 : 2400, easing: Easing.inOut(Easing.sin), useNativeDriver: true });
727
+ const loop = Animated.loop(Animated.sequence([from, back]));
728
+ loop.start();
729
+ return () => loop.stop();
730
+ }, [motion, progress, reduceMotion]);
731
+ const transform = motion === 'float' ? [{ translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [0, -8] }) }] : motion === 'rock' ? [{ rotate: progress.interpolate({ inputRange: [0, 0.5, 1], outputRange: ['-2deg', '2deg', '-2deg'] }) }] : motion === 'pulse' ? [{ scale: progress.interpolate({ inputRange: [0, 1], outputRange: [1, 1.035] }) }] : motion === 'drift' ? [{ scale: progress.interpolate({ inputRange: [0, 1], outputRange: [1, 1.08] }) }] : [];
732
+ return <Animated.View style={[style, !reduceMotion && motion !== 'none' && { transform }]}>{children}</Animated.View>;
733
+ }
734
+
735
+ export function Onboarding({ flowId, apiKey, baseUrl, fallbackFlow, locale, onComplete, onAction, onAnswer, appearance, analyticsEnvironment: configuredAnalyticsEnvironment, resume = true, renderVideo }: Props) {
736
+ const scheme = useColorScheme();
737
+ const [flow, setFlow] = useState<OnboardingFlow>(fallbackFlow);
738
+ const [step, setStep] = useState(0);
739
+ const [ready, setReady] = useState(false);
740
+ const [answer, setAnswer] = useState('');
741
+ // The answer as the person saw it (e.g. "154 lb"), kept per screen for recalled variables.
742
+ const [shown, setShown] = useState('');
743
+ const [displays, setDisplays] = useState<Record<string, string>>({});
744
+ const [answers, setAnswers] = useState<Record<string, string>>({});
745
+ // Picks per screen drive conditions; history lets Back return to the screens that were actually shown.
746
+ const [picks, setPicks] = useState<string[]>([]);
747
+ const [picked, setPicked] = useState<Record<string, string[]>>({});
748
+ const [history, setHistory] = useState<number[]>([]);
749
+ // A resumed onboarding keeps its session id, so analytics count it as one run.
750
+ const [sessionId, setSessionId] = useState(() => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`);
751
+ const [resumed, setResumed] = useState(false);
752
+ // True while the app handles an action that returned a Promise, such as sign-up.
753
+ const [busy, setBusy] = useState(false);
754
+ const [done, setDone] = useState(false);
755
+ // A per-install id keeps A/B assignments the same across app launches.
756
+ const [installId, setInstallId] = useState('');
757
+ // The version of the onboarding this install runs: the original, or a flow test version. Screen tests pause during a flow test.
758
+ const assignId = installId || sessionId;
759
+ const flowTest = usableTest(flow);
760
+ const flowVersion = flowTest ? pickFlowVersion(flowTest, assignId) : undefined;
761
+ const playFlow: OnboardingFlow = { ...flow, screens: screensFor(flow, assignId) };
762
+ const analyticsEnvironment = configuredAnalyticsEnvironment ?? (__DEV__ ? 'development' : 'production');
763
+ const track = (event_name: 'flow_started' | 'screen_viewed' | 'screen_completed' | 'flow_completed', screen_id?: string, version = flow.version) => {
764
+ // Screens under an A/B test tag their events with the version this session sees, so results can be compared.
765
+ const tested = !flowTest && screen_id && event_name !== 'flow_started' && event_name !== 'flow_completed' ? playFlow.screens.find((item) => item.id === screen_id) : undefined;
766
+ const variant = flowVersion ? `flow-${flowVersion}` : tested?.experiment?.variants?.length ? pickVariant(tested, assignId) : undefined;
767
+ void fetch(`${baseUrl.replace(/\/$/, '')}/api/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', [KEY_HEADER]: apiKey }, body: JSON.stringify({ app_id: flowId, event_name, screen_id, session_id: sessionId, flow_version: version, environment: analyticsEnvironment, ...(variant ? { variant } : {}) }) }).catch(() => {});
768
+ };
769
+ useEffect(() => {
770
+ let mounted = true;
771
+ async function load() {
772
+ let install = '';
773
+ try {
774
+ install = (await AsyncStorage.getItem(INSTALL_KEY)) ?? '';
775
+ if (!install) { install = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`; await AsyncStorage.setItem(INSTALL_KEY, install); }
776
+ if (mounted) setInstallId(install);
777
+ } catch { /* Without storage, assignments fall back to this session. */ }
778
+ let loaded = fallbackFlow;
779
+ try {
780
+ const response = await fetch(`${baseUrl.replace(/\/$/, '')}/api/flows/${encodeURIComponent(flowId)}`, { headers: { [KEY_HEADER]: apiKey } });
781
+ if (response.status === 401 && __DEV__) console.warn(`OnboardingYak: the apiKey for "${flowId}" is missing or doesn't belong to this app. Copy the publishable key from the studio. Showing the cached or fallback flow.`);
782
+ if (!response.ok) throw new Error('No published flow');
783
+ const data: unknown = await response.json();
784
+ if (!validFlow(data, flowId)) throw new Error('Unsupported flow format');
785
+ loaded = data;
786
+ await AsyncStorage.setItem(cacheKey(flowId), JSON.stringify(data));
787
+ } catch {
788
+ try {
789
+ const cached: unknown = JSON.parse((await AsyncStorage.getItem(cacheKey(flowId))) ?? 'null');
790
+ if (validFlow(cached, flowId)) loaded = cached;
791
+ } catch { /* Keep the bundled fallback flow. */ }
792
+ }
793
+ // Pick up where the person left off, e.g. after leaving the app to sign up.
794
+ let saved: Progress | undefined;
795
+ if (resume) {
796
+ try { saved = usableProgress(JSON.parse((await AsyncStorage.getItem(progressKey(flowId))) ?? 'null'), loaded, install || sessionId); }
797
+ catch { /* Start from the first screen. */ }
798
+ }
799
+ if (!mounted) return;
800
+ setFlow(loaded);
801
+ setStep(saved?.step ?? 0);
802
+ if (saved) {
803
+ setSessionId(saved.sessionId);
804
+ setHistory(saved.history);
805
+ setAnswers(saved.answers);
806
+ setPicked(saved.picked);
807
+ setDisplays(saved.displays);
808
+ setResumed(true);
809
+ }
810
+ setReady(true);
811
+ }
812
+ load();
813
+ return () => { mounted = false; };
814
+ }, [apiKey, baseUrl, flowId]);
815
+ useEffect(() => {
816
+ if (ready) {
817
+ // A resumed onboarding already counted its start; the step effect below counts the screen it resumes on.
818
+ if (!resumed) track('flow_started');
819
+ if (step === 0) track('screen_viewed', playFlow.screens[0]?.id);
820
+ }
821
+ // Count once when the initial remote or fallback flow has loaded.
822
+ // eslint-disable-next-line react-hooks/exhaustive-deps
823
+ }, [ready, flowId]);
824
+ // Saved answers become typed variables; copy uses their display text, and the app gets all of them when the flow ends.
825
+ const collectVariables = (source: Record<string, string>, sourcePicks: Record<string, string[]>, sourceDisplays: Record<string, string>) => {
826
+ const variables: Record<string, VariableValue> = {};
827
+ for (const item of playFlow.screens) {
828
+ if (!item.variable || source[item.id] === undefined) continue;
829
+ const value = variableValue(item, source[item.id], sourcePicks[item.id] ?? [], sourceDisplays[item.id]);
830
+ if (value) variables[item.variable.toLowerCase()] = value;
831
+ }
832
+ return variables;
833
+ };
834
+ const tokenValues: Record<string, string> = {};
835
+ for (const [name, value] of Object.entries(collectVariables(answers, picked, displays))) tokenValues[name] = value.display;
836
+ Object.assign(tokenValues, builtInValues(flow.appName, tokenValues));
837
+ const raw = playFlow.screens[step];
838
+ const screen = raw ? fillScreen(applyVariant(localizeScreen(raw, locale?.trim() || deviceLocale()), flowTest ? 'a' : pickVariant(raw, assignId)), tokenValues) : undefined;
839
+ useEffect(() => {
840
+ if (ready && step > 0) track('screen_viewed', playFlow.screens[step]?.id);
841
+ // Track a single view each time the step changes.
842
+ // eslint-disable-next-line react-hooks/exhaustive-deps
843
+ }, [step, ready]);
844
+ useEffect(() => {
845
+ const screenId = playFlow.screens[step]?.id;
846
+ if (!resume || !ready || done || !screenId) return;
847
+ const progress: Progress = { version: flow.version, step, screenId, sessionId, history, answers, picked, displays };
848
+ AsyncStorage.setItem(progressKey(flowId), JSON.stringify(progress)).catch(() => {});
849
+ // Saved on each move; the screens follow from flow and step.
850
+ // eslint-disable-next-line react-hooks/exhaustive-deps
851
+ }, [resume, ready, done, step, history, answers, picked, displays, sessionId, flowId, flow.version]);
852
+ const [reduceMotion, setReduceMotion] = useState(false);
853
+ const [loaderDoneStep, setLoaderDoneStep] = useState(-1);
854
+ useEffect(() => {
855
+ let mounted = true;
856
+ AccessibilityInfo.isReduceMotionEnabled().then((value) => { if (mounted) setReduceMotion(value); }).catch(() => {});
857
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
858
+ return () => { mounted = false; subscription.remove(); };
859
+ }, []);
860
+ if (!ready || !screen) return <SafeAreaView style={styles.loading}><ActivityIndicator color="#8b1e24" /></SafeAreaView>;
861
+ const mode = appearance ?? flow.appearance ?? 'light';
862
+ const dark = mode === 'dark' || (mode === 'system' && scheme === 'dark');
863
+ const theme = resolveTheme(screen, dark);
864
+ const line = dark ? '#ffffff26' : '#dfe5df';
865
+ // Wheel screens lock the page scroll so vertical drags always reach the wheel instead of the surrounding scroll view.
866
+ const hasWheel = screen.kind === 'Question' && screen.answerType !== 'singleChoice' && (screen.input?.format === 'wheel' || screen.input?.format === 'date');
867
+ const loaderPending = theme.showLoader && !!screen.loader?.steps?.length && loaderDoneStep !== step;
868
+ // Custom accent colors apply in both modes; custom text and track colors are light-mode only so dark screens stay readable.
869
+ const checkColor = colorOf(screen.checklist?.color, theme.primary);
870
+ const checkText = dark ? theme.text : colorOf(screen.checklist?.textColor, theme.text);
871
+ const pickerColors = { onPrimary: lightBackground(theme.primary) ? '#243028' : '#ffffff', text: theme.text, muted: theme.muted, primary: theme.primary, card: theme.cardBackground, line, font: theme.font, bodyFont: theme.bodyFont };
872
+ const buttonStyle = screen.buttonStyle ?? 'filled';
873
+ const onPrimary = lightBackground(theme.primary) ? '#243028' : '#ffffff';
874
+ const buttonVisual = buttonStyle === 'outline'
875
+ ? { backgroundColor: 'transparent', borderColor: theme.primary, borderWidth: 1 }
876
+ : buttonStyle === 'text'
877
+ ? { backgroundColor: 'transparent' }
878
+ : { backgroundColor: theme.primary };
879
+ const labelColor = buttonStyle === 'filled' ? onPrimary : theme.primary;
880
+ const cross = theme.align === 'right' ? 'flex-end' : theme.align === 'left' ? 'flex-start' : 'center';
881
+ const multi = screen.kind === 'Question' && screen.answerType === 'singleChoice' && !!screen.multiple;
882
+ const minPicks = Math.max(1, screen.multiple?.min ?? 1);
883
+ const needsPicks = multi && picks.length < minPicks;
884
+ // Ends the onboarding and clears saved progress, so the next launch starts fresh.
885
+ const finish = (nextAnswers: Record<string, string>, nextPicked: Record<string, string[]>, nextDisplays: Record<string, string>) => {
886
+ track('flow_completed', screen.id);
887
+ setDone(true);
888
+ AsyncStorage.removeItem(progressKey(flowId)).catch(() => {});
889
+ onComplete?.(nextAnswers, { variables: collectVariables(nextAnswers, nextPicked, nextDisplays) });
890
+ };
891
+ // Moves to the next screen whose condition passes, or finishes when none is left.
892
+ const advance = (nextPicked: Record<string, string[]>, nextAnswers: Record<string, string>, nextDisplays = displays) => {
893
+ const next = nextVisibleIndex(playFlow.screens, step, nextPicked);
894
+ if (next < 0) { finish(nextAnswers, nextPicked, nextDisplays); return; }
895
+ setHistory((list) => [...list, step]);
896
+ setStep(next);
897
+ };
898
+ const goBack = () => {
899
+ if (busy || !history.length) return;
900
+ setStep(history[history.length - 1]);
901
+ setHistory((list) => list.slice(0, -1));
902
+ setAnswer('');
903
+ setShown('');
904
+ setPicks([]);
905
+ };
906
+ const continueFlow = async () => {
907
+ if (loaderPending || busy) return;
908
+ const values = screen.kind !== 'Question' ? [] : multi ? picks : [answer.trim()].filter(Boolean);
909
+ const response = values.join(', ');
910
+ if (screen.kind === 'Question' && (!response || (multi && values.length < minPicks))) return;
911
+ Keyboard.dismiss();
912
+ const nextAnswers = screen.kind === 'Question' ? { ...answers, [screen.id]: response } : answers;
913
+ const nextPicked = screen.kind === 'Question' ? { ...picked, [screen.id]: values } : picked;
914
+ const nextDisplays = screen.kind === 'Question' && shown ? { ...displays, [screen.id]: shown } : displays;
915
+ if (screen.kind === 'Question') {
916
+ setAnswers(nextAnswers);
917
+ setPicked(nextPicked);
918
+ setDisplays(nextDisplays);
919
+ onAnswer?.(screen, response, values, variableValue(screen, response, values, nextDisplays[screen.id]) ?? undefined);
920
+ }
921
+ const action = screen.action ?? (nextVisibleIndex(playFlow.screens, step, nextPicked) < 0 ? 'complete' : 'next');
922
+ if (action !== 'next' && onAction) {
923
+ // A Promise (e.g. sign-up) holds the flow; false or an error keeps the person here with their answer still selected.
924
+ let result: ActionResult | Promise<ActionResult>;
925
+ try { result = onAction(action, screen, { answers: nextAnswers, variables: collectVariables(nextAnswers, nextPicked, nextDisplays) }); }
926
+ catch { return; }
927
+ if (result && typeof result === 'object') {
928
+ setBusy(true);
929
+ try { result = await result; }
930
+ catch { result = false; }
931
+ finally { setBusy(false); }
932
+ }
933
+ if (result === false) return;
934
+ }
935
+ track('screen_completed', screen.id);
936
+ if (screen.kind === 'Question') {
937
+ setAnswer('');
938
+ setShown('');
939
+ setPicks([]);
940
+ }
941
+ if (action === 'complete') finish(nextAnswers, nextPicked, nextDisplays);
942
+ else advance(nextPicked, nextAnswers, nextDisplays);
943
+ };
944
+ const darkSurface = !lightBackground(theme.showImage && theme.imagePlacement === 'background' && screen.illustration ? theme.overlayColor : theme.background);
945
+ const progressTrack = darkSurface ? '#ffffff33' : '#eaede9';
946
+ const progress = !theme.showProgress ? null : theme.progressStyle === 'bar' ? <View style={[styles.progressTrack, { backgroundColor: progressTrack }]}><View style={[styles.progressFill, { width: `${((step + 1) / playFlow.screens.length) * 100}%`, backgroundColor: theme.primary }]} /></View> : <View style={[styles.progress, theme.progressStyle === 'dots' && styles.progressDots]}>{playFlow.screens.map((s, i) => <View key={s.id} style={[theme.progressStyle === 'dots' ? styles.progressDot : styles.progressPart, theme.progressStyle === 'dots' && i === step && styles.progressDotCurrent, { backgroundColor: i <= step ? theme.primary : progressTrack }]} />)}</View>;
947
+ const imageResize = theme.imageFit === 'contain' ? 'contain' as const : theme.imageFit === 'fill' ? 'stretch' as const : 'cover' as const;
948
+ const move = (part: MotionPart, delay = 0) => reduceMotion ? undefined : readMotion(screen.theme?.motion?.[part], 480, delay);
949
+ const enter = (part: MotionPart, key: string, node: React.ReactNode, delay = 0) => withEntrance(move(part, delay), `${screen.id}-${key}-${step}`, node);
950
+ const charts = readCharts(screen.charts);
951
+ const images = carouselImages(screen);
952
+ const carousel = images.length >= 2 ? { images, resize: imageResize, interval: rangeOf(screen.carousel?.interval, 3500, 1500, 10000), autoplay: screen.carousel?.autoplay !== false && !reduceMotion } : null;
953
+ const backdrop = theme.showImage && theme.imagePlacement === 'background' && images.length > 0;
954
+ const arrangement = resolveArrangement(screen.theme?.arrangement, theme.progress);
955
+ const aligns = sectionAligns(screen.theme, backdrop);
956
+ // A footer moved off the bottom scrolls with the content; a bottom footer stays pinned above the keyboard.
957
+ const footerInScroll = aligns.footer !== 'bottom';
958
+ const framed = theme.imageFrame !== 'none';
959
+ const art = carousel ? <InlineCarousel key={`${screen.id}-${images.length}`} {...carousel} /> : images[0] ? <Media uri={images[0]} resize={imageResize} style={styles.illustration} /> : <><View style={styles.sun}/><Text style={styles.sparkle}>✳</Text></>;
960
+ const image = theme.showImage && !backdrop ? <ImageMotion motion={theme.imageMotion} reduceMotion={reduceMotion}><View style={[styles.art, boxStyle(theme.imagePadding, theme.imageMargin), framed && styles.artFramed, {
961
+ backgroundColor: framed ? 'transparent' : screen.accent,
962
+ width: theme.imageWidthMode === 'fill' ? '100%' : theme.imageWidth,
963
+ height: theme.imageHeightMode === 'fill' ? undefined : theme.imageHeight,
964
+ aspectRatio: theme.imageHeightMode === 'fill' ? theme.imageWidth / Math.max(theme.imageHeight, 1) : undefined,
965
+ borderRadius: framed ? 0 : theme.imageRadius,
966
+ borderWidth: framed ? 0 : theme.imageBorderWidth,
967
+ borderColor: theme.imageBorderColor,
968
+ }]}>{theme.imageFrame !== 'none' ? <DeviceFrame device={theme.imageFrame}>{art}</DeviceFrame> : art}</View></ImageMotion> : null;
969
+ const partNodes: Record<LayoutPart, React.ReactNode> = {
970
+ back: theme.showBack && enter('back', 'back', <Pressable accessibilityRole="button" onPress={goBack} style={styles.back}><Text style={[styles.skipText, { color: theme.muted, fontFamily: theme.bodyFont, fontSize: Math.max(12, theme.bodySize - 2) }]}>{`‹ ${theme.backLabel}`}</Text></Pressable>, 0),
971
+ progress: enter('progress', 'progress', progress, 40),
972
+ image: enter('image', 'image', image, 90),
973
+ title: theme.showTitle && enter('title', 'title', <Text style={[styles.title, { color: theme.text, textAlign: theme.align, fontFamily: theme.font, fontSize: theme.titleSize, lineHeight: Math.round(theme.titleSize * 1.2), fontWeight: theme.titleWeight }]}>{screen.title}</Text>, 180),
974
+ subtitle: theme.showSubtitle && enter('subtitle', 'subtitle', <Text style={[styles.subtitle, { color: theme.muted, textAlign: theme.align, fontFamily: theme.bodyFont, fontSize: theme.bodySize, lineHeight: Math.round(theme.bodySize * 1.45) }]}>{screen.subtitle}</Text>, 260),
975
+ answers: screen.kind === 'Question' && (screen.answerType === 'singleChoice' ? enter('cards', 'cards', <View style={styles.options}>{(screen.options ?? []).map((option, index) => {
976
+ const label = option.trim();
977
+ if (!label) return null;
978
+ const icon = optionIconAt(screen.optionIcons, index);
979
+ const picked = multi ? picks.includes(label) : answer === label;
980
+ const justify = theme.align === 'center' ? 'center' : theme.align === 'right' ? 'flex-end' : 'flex-start';
981
+ const toggle = () => {
982
+ if (!multi) { setAnswer(label); return; }
983
+ setPicks((list) => list.includes(label) ? list.filter((item) => item !== label) : screen.multiple?.max && list.length >= screen.multiple.max ? list : [...list, label]);
984
+ };
985
+ return <Pressable key={`${index}-${label}`} accessibilityRole={multi ? 'checkbox' : 'radio'} accessibilityState={{ checked: picked }} onPress={toggle} style={[styles.option, { borderRadius: theme.cardRadius, paddingTop: theme.cardPadding.top, paddingRight: theme.cardPadding.right, paddingBottom: theme.cardPadding.bottom, paddingLeft: theme.cardPadding.left, backgroundColor: picked ? (dark ? `${theme.primary}26` : '#f0f6f0') : theme.cardBackground, borderColor: line, justifyContent: justify }, picked && styles.optionSelected, picked && { borderColor: theme.primary, backgroundColor: dark ? `${theme.primary}26` : '#f0f6f0' }]}>{icon ? <Text style={styles.optionIcon}>{icon}</Text> : null}<Text style={[styles.optionText, { textAlign: theme.align, fontFamily: theme.bodyFont, fontSize: Math.max(12, theme.bodySize - 1) }, dark && { color: theme.text }, picked && styles.optionTextSelected, picked && { color: theme.primary }]}>{label}</Text>{multi ? <View style={[styles.multiCheck, { borderColor: picked ? theme.primary : line, backgroundColor: picked ? theme.primary : 'transparent' }]}>{picked ? <Text style={[styles.multiGlyph, { color: onPrimary }]}>✓</Text> : null}</View> : null}</Pressable>;
986
+ })}{multi && (minPicks > 1 || screen.multiple?.max) ? <Text style={[styles.multiHint, { color: theme.muted, fontFamily: theme.bodyFont, textAlign: theme.align }]}>{minPicks > 1 && screen.multiple?.max ? `Choose ${minPicks} to ${screen.multiple.max}` : minPicks > 1 ? `Choose at least ${minPicks}` : `Choose up to ${screen.multiple?.max}`}</Text> : null}</View>, 340) : enter('answers', 'answers', screen.input?.format === 'wheel'
987
+ ? <NumberWheel key={screen.id} input={screen.input} onChange={(value, display) => { setAnswer(value); setShown(display ?? ''); }} colors={pickerColors} />
988
+ : screen.input?.format === 'date'
989
+ ? <DateWheels key={screen.id} minYear={screen.input.min} maxYear={screen.input.max} initialYear={screen.input.initial} onChange={setAnswer} colors={pickerColors} />
990
+ : screen.input?.format === 'country'
991
+ ? <CountryPicker key={screen.id} value={answer} onChange={setAnswer} placeholder={screen.input.placeholder} colors={pickerColors} />
992
+ : screen.input?.format === 'number'
993
+ ? <NumberEntry key={screen.id} input={screen.input} placeholder={screen.input.placeholder} onChange={(value, display) => { setAnswer(value); setShown(display ?? ''); }} colors={pickerColors} size={Math.round(theme.titleSize * 1.5)} />
994
+ : <TextInput accessibilityLabel="Your answer" multiline value={answer} onChangeText={setAnswer} placeholder={screen.input?.placeholder ?? 'Write your answer…'} placeholderTextColor={dark ? '#7c8480' : '#929b93'} style={[styles.answerInput, { borderColor: line, textAlign: theme.align, color: theme.text, fontFamily: theme.bodyFont, fontSize: theme.bodySize }]} />, 340)),
995
+ charts: !theme.showCharts ? null : charts.map((chart) => <React.Fragment key={`${chart.id}-${step}`}>{withEntrance(reduceMotion ? undefined : chart.motion, `${screen.id}-${chart.id}-${step}`, <NativeChart chart={chart} color={theme.primary} ink={theme.text} muted={theme.muted} surface={theme.background} animate={!reduceMotion} delay={(chart.motion?.delay ?? 200) + 120} />)}</React.Fragment>),
996
+ loader: theme.showLoader && screen.loader?.steps?.length ? <LoaderBlock key={`${screen.id}-${step}`} steps={screen.loader.steps.slice(0, 6)} duration={rangeOf(screen.loader.duration, 5000, 2000, 15000)} colors={{ ...pickerColors, primary: colorOf(screen.loader.color, theme.primary), text: dark ? theme.text : colorOf(screen.loader.textColor, theme.text) }} track={dark ? '#ffffff1f' : colorOf(screen.loader.trackColor, '#e6e9e6')} onDone={() => {
997
+ setLoaderDoneStep(step);
998
+ if (screen.loader?.autoAdvance !== false) setTimeout(() => { track('screen_completed', screen.id); advance(picked, answers); }, 650);
999
+ }} /> : null,
1000
+ proof: theme.showProof && screen.proof ? <ProofBlock key={`${screen.id}-proof-${step}`} proof={screen.proof} colors={pickerColors} reduceMotion={reduceMotion} align={theme.align} /> : null,
1001
+ checklist: theme.showChecklist && screen.checklist?.items?.length ? <View style={styles.checklist}>{screen.checklist.items.slice(0, 8).map((item, index) => withEntrance(reduceMotion ? undefined : { enter: 'rise', duration: 420, delay: 220 + index * 110 }, `${screen.id}-check-${index}-${step}`, <View style={styles.checkRow}><View style={[styles.checkMark, { backgroundColor: checkColor }]}><Text style={[styles.checkGlyph, { color: lightBackground(checkColor) ? '#243028' : '#ffffff' }]}>✓</Text></View><Text style={[styles.checkText, { color: checkText, fontFamily: theme.bodyFont, fontSize: theme.bodySize }]}>{item}</Text></View>))}</View> : null,
1002
+ };
1003
+ const placeParts = (parts: LayoutPart[]) => parts.map((part) => <React.Fragment key={part}>{partNodes[part]}</React.Fragment>);
1004
+ const headerView = <View key="header" style={[styles.header, boxStyle(theme.headerPadding, theme.headerMargin), { gap: theme.gap, alignItems: cross }]}>{placeParts(arrangement.header)}</View>;
1005
+ const bodyView = <View key="body" style={[styles.body, boxStyle(theme.bodyPadding, theme.bodyMargin), { flexGrow: 0, gap: theme.gap, alignItems: cross }]}>{placeParts(arrangement.body)}</View>;
1006
+ const footerView = ((theme.showButton || theme.showSkip || theme.showTerms) && <View key="footer" style={[styles.footer, footerInScroll && { marginTop: 0 }, boxStyle(theme.footerPadding, theme.footerMargin), { gap: theme.gap, backgroundColor: backdrop ? 'transparent' : theme.background }]}>
1007
+ {theme.showButton && enter('button', 'button', <Pressable accessibilityRole="button" accessibilityState={{ disabled: loaderPending || needsPicks, busy }} disabled={loaderPending || needsPicks || busy} onPress={() => { void continueFlow(); }} style={[styles.button, buttonVisual, (loaderPending || needsPicks) && { opacity: 0.4 }, { borderRadius: theme.buttonRadius, paddingTop: theme.buttonPadding.top, paddingRight: theme.buttonPadding.right, paddingBottom: theme.buttonPadding.bottom, paddingLeft: theme.buttonPadding.left }]}>{busy ? <ActivityIndicator color={labelColor} /> : <Text style={[styles.buttonText, { color: labelColor, fontFamily: theme.bodyFont, fontSize: theme.bodySize }]}>{screen.button}</Text>}</Pressable>, 420)}
1008
+ {theme.showSkip && enter('skip', 'skip', <Pressable accessibilityRole="button" disabled={busy} onPress={() => { track('screen_completed', screen.id); finish(answers, picked, displays); }} style={styles.skip}><Text style={[styles.skipText, { color: theme.muted, fontFamily: theme.bodyFont, fontSize: Math.max(12, theme.bodySize - 2) }]}>{theme.skipLabel}</Text></Pressable>, 500)}
1009
+ {theme.showTerms && enter('terms', 'terms', <Pressable accessibilityRole={theme.termsUrl ? 'link' : 'text'} onPress={() => { if (theme.termsUrl) void Linking.openURL(theme.termsUrl); }} style={styles.terms}><Text style={[styles.termsText, { color: theme.muted, textAlign: theme.align, fontFamily: theme.bodyFont, fontSize: Math.max(11, theme.bodySize - 3), textDecorationLine: theme.termsUrl ? 'underline' : 'none' }]}>{theme.termsLabel}</Text></Pressable>, 560)}
1010
+ </View>) || null;
1011
+ // Top, middle, and bottom zones with flexible spacers between them. Each spacer cancels one gap so spacing between parts stays as set.
1012
+ const sectionsFor = () => {
1013
+ const zones: Record<SectionAlign, React.ReactNode[]> = { top: [], center: [], bottom: [] };
1014
+ zones[aligns.header].push(headerView);
1015
+ zones[aligns.body].push(bodyView);
1016
+ if (footerInScroll) zones[aligns.footer].push(footerView);
1017
+ const spacer = (key: string) => <View key={key} style={{ flexGrow: 1, marginTop: -theme.gap }} />;
1018
+ return [...zones.top, spacer('space-above'), ...(zones.center.length ? [...zones.center, spacer('space-below')] : []), ...zones.bottom];
1019
+ };
1020
+ // The footer sits outside the scroll area, so the keyboard pushes the button up instead of covering it.
1021
+ // Taps on empty space inside the scroll area close the keyboard (keyboardShouldPersistTaps="handled"). The container is a plain
1022
+ // View, not a Pressable: a Pressable around the screen would claim touches before nested scroll views such as the picker wheels.
1023
+ // A background image covers the whole screen, safe areas included, and its copy settles at the bottom.
1024
+ return <VideoRenderer.Provider value={renderVideo}><View style={[styles.safe, { backgroundColor: theme.background }]}>{backdrop && <ImageMotion motion={theme.imageMotion} reduceMotion={reduceMotion} style={StyleSheet.absoluteFill}>{carousel ? <BackdropCarousel key={`${screen.id}-${images.length}`} {...carousel} /> : <Media uri={images[0]} resize={imageResize} style={StyleSheet.absoluteFill} />}<Overlay color={theme.overlayColor} opacity={theme.overlayOpacity} style={theme.overlayStyle} /></ImageMotion>}<SafeAreaView style={[styles.safe, { backgroundColor: backdrop ? 'transparent' : theme.background }]}><KeyboardAvoidingView style={styles.keyboard} behavior={Platform.OS === 'ios' ? 'padding' : undefined}><View style={[styles.container, { paddingTop: theme.margin.vertical, paddingBottom: theme.margin.vertical, paddingHorizontal: theme.margin.horizontal, gap: theme.gap, alignItems: cross }]}>
1025
+ <ScrollView style={styles.scroll} scrollEnabled={!hasWheel} contentContainerStyle={[styles.scrollContent, { gap: theme.gap }]} keyboardShouldPersistTaps="handled" keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'} showsVerticalScrollIndicator={false}>
1026
+ {sectionsFor()}
1027
+ </ScrollView>
1028
+ {!footerInScroll && footerView}
1029
+ </View></KeyboardAvoidingView></SafeAreaView></View></VideoRenderer.Provider>;
1030
+ }
1031
+ const styles = StyleSheet.create({
1032
+ safe: { flex: 1, backgroundColor: '#fff' }, loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
1033
+ keyboard: { flex: 1 },
1034
+ container: { flex: 1, alignItems: 'center', paddingTop: 40 },
1035
+ scroll: { flex: 1, alignSelf: 'stretch' }, scrollContent: { flexGrow: 1 },
1036
+ header: { width: '100%', alignSelf: 'stretch' },
1037
+ body: { width: '100%', alignSelf: 'stretch', flexGrow: 1 },
1038
+ art: { width: 180, height: 160, borderRadius: 24, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', alignSelf: 'center' },
1039
+ illustration: { width: '100%', height: '100%' },
1040
+ artFramed: { overflow: 'visible' },
1041
+ deviceFrame: { height: '100%', aspectRatio: 9 / 19.5, maxWidth: '100%', alignSelf: 'center' },
1042
+ deviceScreen: { flex: 1, overflow: 'hidden', backgroundColor: '#000000' },
1043
+ deviceIsland: { position: 'absolute', left: '50%', borderRadius: 999, backgroundColor: '#000000' },
1044
+ deviceButton: { position: 'absolute', borderRadius: 2, backgroundColor: '#2c2c31' },
1045
+ sun: { position: 'absolute', width: 34, height: 34, borderRadius: 17, backgroundColor: '#f0c77f', top: 32 }, sparkle: { color: '#56745f', fontSize: 32 },
1046
+ progress: { flexDirection: 'row', gap: 5, width: '100%', alignSelf: 'stretch' }, progressPart: { flex: 1, height: 4, borderRadius: 4, backgroundColor: '#eaede9' },
1047
+ progressTrack: { width: '100%', height: 4, borderRadius: 4, overflow: 'hidden', alignSelf: 'stretch' }, progressFill: { height: '100%', borderRadius: 4 },
1048
+ progressDots: { justifyContent: 'center', gap: 7 }, progressDot: { width: 8, height: 8, borderRadius: 4 }, progressDotCurrent: { width: 10, height: 10, borderRadius: 5 },
1049
+ title: { color: '#252b26', fontWeight: '700', fontSize: 23, lineHeight: 28, alignSelf: 'stretch' }, subtitle: { color: '#818a82', fontSize: 15, lineHeight: 22, alignSelf: 'stretch' },
1050
+ carouselDots: { position: 'absolute', left: 0, right: 0, bottom: 10, flexDirection: 'row', justifyContent: 'center', gap: 5 },
1051
+ carouselDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#ffffff', opacity: 0.55 },
1052
+ carouselDotActive: { width: 16, opacity: 1 },
1053
+ multiCheck: { width: 22, height: 22, marginLeft: 'auto', borderRadius: 6, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
1054
+ multiGlyph: { fontSize: 13, fontWeight: '800', lineHeight: 16 },
1055
+ multiHint: { fontSize: 12, marginTop: 2 },
1056
+ checklist: { alignSelf: 'stretch', gap: 12 },
1057
+ checkRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
1058
+ checkMark: { width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
1059
+ checkGlyph: { fontSize: 13, fontWeight: '800', lineHeight: 16 },
1060
+ checkText: { flex: 1, fontWeight: '500' },
1061
+ numberRow: { alignSelf: 'stretch', flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', gap: 8, borderBottomWidth: 2, paddingVertical: 10 },
1062
+ numberInput: { minWidth: 72, textAlign: 'center', fontWeight: '700', padding: 0 },
1063
+ numberUnit: { fontWeight: '600' },
1064
+ answerInput: { alignSelf: 'stretch', minHeight: 90, textAlignVertical: 'top', borderColor: '#dfe5df', borderWidth: 1, borderRadius: 12, padding: 12, color: '#39433b' },
1065
+ options: { alignSelf: 'stretch', gap: 9 }, option: { minHeight: 46, flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 1, borderColor: '#e0e6e0', borderRadius: 12, paddingHorizontal: 14, backgroundColor: '#fff' }, optionIcon: { fontSize: 20, lineHeight: 24 }, optionSelected: { borderColor: '#8b1e24', backgroundColor: '#f7ecec' }, optionText: { flexShrink: 1, color: '#687269', fontSize: 14 }, optionTextSelected: { color: '#8b1e24', fontWeight: '600' },
1066
+ footer: { marginTop: 'auto', width: '100%', alignSelf: 'stretch' },
1067
+ button: { minHeight: 52, width: '100%', borderRadius: 12, backgroundColor: '#8b1e24', alignItems: 'center', justifyContent: 'center' }, buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 },
1068
+ back: { alignSelf: 'flex-start', paddingVertical: 4 },
1069
+ skip: { padding: 14 }, skipText: { color: '#828a82', fontSize: 13 },
1070
+ terms: { paddingVertical: 4, alignSelf: 'stretch' }, termsText: { color: '#828a82', fontSize: 11, lineHeight: 15 },
1071
+ chart: { alignSelf: 'stretch', width: '100%', gap: 8 },
1072
+ chartTitle: { fontSize: 15, fontWeight: '700' },
1073
+ bars: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, minHeight: 96 },
1074
+ barCol: { flex: 1, alignItems: 'center', gap: 4 },
1075
+ bar: { width: '70%', borderRadius: 5, minHeight: 4 },
1076
+ barLabel: { fontSize: 11 },
1077
+ lineDot: { position: 'absolute', width: 6, height: 6, borderRadius: 3 },
1078
+ lineLabel: { position: 'absolute', top: 94, width: 56, fontSize: 11 },
1079
+ donutRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
1080
+ donut: { width: 112, height: 112 },
1081
+ donutTickWrap: { position: 'absolute', width: 112, height: 112, alignItems: 'center' },
1082
+ donutTick: { width: 5, height: 12, borderRadius: 3 },
1083
+ donutHole: { position: 'absolute', width: 72, height: 72, borderRadius: 36, top: 20, left: 20, alignItems: 'center', justifyContent: 'center' },
1084
+ donutTotal: { fontSize: 16, fontWeight: '700' },
1085
+ legend: { flex: 1, gap: 4 },
1086
+ legendItem: { fontSize: 12 },
1087
+ });