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