@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/src/logic.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Mirrors lib/flow-schema.ts so the SDK branches and fills copy exactly like the studio preview.
|
|
2
|
+
/** Show a screen only when an earlier choice question's answer matches (or, with `not`, doesn't match) one of `anyOf`. */
|
|
3
|
+
export type ScreenCondition = { screen: string; anyOf: string[]; not?: boolean };
|
|
4
|
+
/** Let people pick several options. The button stays disabled until `min` are picked. */
|
|
5
|
+
export type MultipleChoice = { min?: number; max?: number };
|
|
6
|
+
|
|
7
|
+
/** Whether a screen should show, given the options picked so far (keyed by screen id). Unanswered conditions hide the screen. */
|
|
8
|
+
export function screenVisible(screen: { showIf?: ScreenCondition }, picked: Record<string, string[]>) {
|
|
9
|
+
const rule = screen.showIf;
|
|
10
|
+
if (!rule || !rule.screen || !Array.isArray(rule.anyOf)) return true;
|
|
11
|
+
const values = picked[rule.screen];
|
|
12
|
+
if (!values) return !!rule.not;
|
|
13
|
+
const hit = values.some((value) => rule.anyOf.includes(value));
|
|
14
|
+
return rule.not ? !hit : hit;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The next screen index to show after `from` in a direction, skipping screens whose condition fails; -1 when there is none. */
|
|
18
|
+
export function nextVisibleIndex(screens: { showIf?: ScreenCondition }[], from: number, picked: Record<string, string[]>) {
|
|
19
|
+
for (let index = from + 1; index < screens.length; index += 1) if (screenVisible(screens[index], picked)) return index;
|
|
20
|
+
return -1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TOKEN = /\{([a-z][a-z0-9_]{0,23})(?:\|([^{}]{0,40}))?\}/gi;
|
|
24
|
+
|
|
25
|
+
/** Replaces {name} with a saved answer, or {name|fallback} with the fallback when there is no answer yet. */
|
|
26
|
+
export function fillTokens(text: string, values: Record<string, string>) {
|
|
27
|
+
return text.replace(TOKEN, (_, name: string, fallback?: string) => {
|
|
28
|
+
const value = values[name.toLowerCase()];
|
|
29
|
+
return value && value.trim() ? value : fallback ?? '';
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Token names used in a piece of copy, for hints and checks. */
|
|
34
|
+
export function tokensIn(text: string) {
|
|
35
|
+
return [...text.matchAll(TOKEN)].map((match) => match[1].toLowerCase());
|
|
36
|
+
}
|
package/src/pickers.tsx
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Animated, Easing, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { COUNTRIES, flagOf } from './countries';
|
|
4
|
+
import { nearestIndex, toBaseUnit, unitChoices, unitLabel, unitValues, type UnitChoice, type UnitOption } from './units';
|
|
5
|
+
import { formatNumber } from './variables';
|
|
6
|
+
|
|
7
|
+
/** The value as shown in the unit the person chose, e.g. "154 lb" or "5′ 9″". */
|
|
8
|
+
const shownIn = (value: number, choice: UnitChoice) => choice.display === 'feet-inches' ? unitLabel(value, choice) : `${formatNumber(value)}${choice.unit ? ` ${choice.unit}` : ''}`;
|
|
9
|
+
|
|
10
|
+
type Colors = { text: string; muted: string; primary: string; card: string; line: string; font?: string; bodyFont?: string; onPrimary?: string };
|
|
11
|
+
|
|
12
|
+
const ROW = 44;
|
|
13
|
+
const VISIBLE = 5;
|
|
14
|
+
|
|
15
|
+
// A snapping wheel; the middle row is the selection.
|
|
16
|
+
function Wheel({ values, index, onIndex, colors, label, flex = 1 }: { values: string[]; index: number; onIndex: (index: number) => void; colors: Colors; label: string; flex?: number }) {
|
|
17
|
+
const scroller = useRef<React.ComponentRef<typeof ScrollView>>(null);
|
|
18
|
+
const [current, setCurrent] = useState(index);
|
|
19
|
+
const placed = useRef(false);
|
|
20
|
+
useEffect(() => { setCurrent(index); scroller.current?.scrollTo({ y: index * ROW, animated: false }); }, [index]);
|
|
21
|
+
const settle = (offset: number) => {
|
|
22
|
+
const next = Math.max(0, Math.min(values.length - 1, Math.round(offset / ROW)));
|
|
23
|
+
setCurrent(next);
|
|
24
|
+
if (next !== index) onIndex(next);
|
|
25
|
+
};
|
|
26
|
+
return <View style={{ flex, height: ROW * VISIBLE }} accessible accessibilityRole="adjustable" accessibilityLabel={label} accessibilityValue={{ text: values[current] }}
|
|
27
|
+
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
|
28
|
+
onAccessibilityAction={(event) => { const next = Math.max(0, Math.min(values.length - 1, current + (event.nativeEvent.actionName === 'increment' ? 1 : -1))); onIndex(next); }}>
|
|
29
|
+
<ScrollView ref={scroller} showsVerticalScrollIndicator={false} snapToInterval={ROW} decelerationRate="fast" nestedScrollEnabled
|
|
30
|
+
contentOffset={{ x: 0, y: index * ROW }} contentContainerStyle={{ paddingVertical: ROW * 2 }}
|
|
31
|
+
// Android ignores contentOffset and drops scrollTo calls made before layout, so place the wheel once its content is measured.
|
|
32
|
+
onContentSizeChange={() => { if (!placed.current) { placed.current = true; scroller.current?.scrollTo({ y: index * ROW, animated: false }); } }}
|
|
33
|
+
onScroll={(event) => setCurrent(Math.max(0, Math.min(values.length - 1, Math.round(event.nativeEvent.contentOffset.y / ROW))))} scrollEventThrottle={32}
|
|
34
|
+
onMomentumScrollEnd={(event) => settle(event.nativeEvent.contentOffset.y)}
|
|
35
|
+
onScrollEndDrag={(event) => { if (!event.nativeEvent.velocity?.y) settle(event.nativeEvent.contentOffset.y); }}>
|
|
36
|
+
{values.map((value, valueIndex) => {
|
|
37
|
+
const distance = Math.abs(valueIndex - current);
|
|
38
|
+
return <View key={valueIndex} style={styles.wheelRow}><Text style={{ color: distance === 0 ? colors.text : colors.muted, opacity: distance === 0 ? 1 : distance === 1 ? 0.6 : 0.3, fontSize: distance === 0 ? 22 : 18, fontWeight: distance === 0 ? '700' : '500', fontFamily: colors.font }}>{value}</Text></View>;
|
|
39
|
+
})}
|
|
40
|
+
</ScrollView>
|
|
41
|
+
</View>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Band({ colors }: { colors: Colors }) {
|
|
45
|
+
return <View pointerEvents="none" style={[styles.band, { top: ROW * 2, backgroundColor: colors.card, borderColor: colors.line }]} />;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Segmented control for switching units, e.g. kg / lb.
|
|
49
|
+
function UnitTabs({ units, active, onSelect, colors }: { units: string[]; active: number; onSelect: (index: number) => void; colors: Colors }) {
|
|
50
|
+
return <View style={[styles.tabs, { backgroundColor: colors.card, borderColor: colors.line }]} accessibilityRole="tablist">
|
|
51
|
+
{units.map((unit, index) => {
|
|
52
|
+
const on = index === active;
|
|
53
|
+
return <Pressable key={unit} accessibilityRole="tab" accessibilityState={{ selected: on }} onPress={() => onSelect(index)} style={[styles.tab, on && { backgroundColor: colors.primary }]}>
|
|
54
|
+
<Text style={{ color: on ? colors.onPrimary : colors.muted, fontSize: 14, fontWeight: '700', fontFamily: colors.bodyFont }}>{unit}</Text>
|
|
55
|
+
</Pressable>;
|
|
56
|
+
})}
|
|
57
|
+
</View>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type UnitInput = { min?: number; max?: number; step?: number; initial?: number; unit?: string; units?: UnitOption[] };
|
|
61
|
+
|
|
62
|
+
/** A number wheel with an optional unit switch. The answer is always in the base unit. */
|
|
63
|
+
export function NumberWheel({ input, onChange, colors }: { input: UnitInput; onChange: (value: string, display?: string) => void; colors: Colors }) {
|
|
64
|
+
const choices = useMemo(() => unitChoices(input), [input]);
|
|
65
|
+
const [unitIndex, setUnitIndex] = useState(0);
|
|
66
|
+
const choice = choices[unitIndex] ?? choices[0];
|
|
67
|
+
const values = useMemo(() => unitValues(choice), [choice]);
|
|
68
|
+
const baseValues = useMemo(() => unitValues(choices[0]), [choices]);
|
|
69
|
+
const start = nearestIndex(baseValues, input.initial ?? baseValues[Math.floor(baseValues.length / 2)] ?? 0);
|
|
70
|
+
const [index, setIndex] = useState(start);
|
|
71
|
+
const report = (value: number | undefined, from = choice) => { if (value !== undefined) onChange(String(toBaseUnit(value, from, choices[0])), shownIn(value, from)); };
|
|
72
|
+
useEffect(() => { report(baseValues[start], choices[0]); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
73
|
+
const switchUnit = (next: number) => {
|
|
74
|
+
if (next === unitIndex) return;
|
|
75
|
+
const target = choices[next];
|
|
76
|
+
const base = toBaseUnit(values[index] ?? 0, choice, choices[0]);
|
|
77
|
+
const nextValues = unitValues(target);
|
|
78
|
+
const nextIndex = nearestIndex(nextValues, base * target.factor);
|
|
79
|
+
setUnitIndex(next);
|
|
80
|
+
setIndex(nextIndex);
|
|
81
|
+
report(nextValues[nextIndex], target);
|
|
82
|
+
};
|
|
83
|
+
return <View style={styles.wheelField}>
|
|
84
|
+
{choices.length > 1 ? <UnitTabs units={choices.map((item) => item.unit)} active={unitIndex} onSelect={switchUnit} colors={colors} /> : null}
|
|
85
|
+
<View style={styles.wheelWrap}>
|
|
86
|
+
<Band colors={colors} />
|
|
87
|
+
<Wheel key={unitIndex} values={values.map((value) => unitLabel(value, choice))} index={index} label={choice.unit ? `Value in ${choice.unit}` : 'Value'} colors={colors} onIndex={(next) => { setIndex(next); report(values[next]); }} />
|
|
88
|
+
{choice.unit && choice.display !== 'feet-inches' ? <View pointerEvents="none" style={[styles.unit, { top: ROW * 2 }]}><Text style={{ color: colors.muted, fontSize: 16, fontWeight: '600', fontFamily: colors.bodyFont }}>{choice.unit}</Text></View> : null}
|
|
89
|
+
</View>
|
|
90
|
+
</View>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A large number field with the number keypad and an optional unit switch. The answer is always in the base unit. */
|
|
94
|
+
export function NumberEntry({ input, placeholder, onChange, colors, size }: { input: UnitInput; placeholder?: string; onChange: (value: string, display?: string) => void; colors: Colors; size: number }) {
|
|
95
|
+
const choices = useMemo(() => unitChoices(input), [input]);
|
|
96
|
+
const [unitIndex, setUnitIndex] = useState(0);
|
|
97
|
+
const [text, setText] = useState('');
|
|
98
|
+
const choice = choices[unitIndex] ?? choices[0];
|
|
99
|
+
const report = (raw: string, from = choice) => {
|
|
100
|
+
const value = Number(raw.replace(',', '.'));
|
|
101
|
+
onChange(raw && Number.isFinite(value) ? String(toBaseUnit(value, from, choices[0])) : '', raw && Number.isFinite(value) ? shownIn(value, from) : undefined);
|
|
102
|
+
};
|
|
103
|
+
const switchUnit = (next: number) => {
|
|
104
|
+
const value = Number(text.replace(',', '.'));
|
|
105
|
+
const target = choices[next];
|
|
106
|
+
const converted = text && Number.isFinite(value) ? String(Math.round(toBaseUnit(value, choice, choices[0]) * target.factor * 10) / 10) : '';
|
|
107
|
+
setUnitIndex(next);
|
|
108
|
+
setText(converted);
|
|
109
|
+
report(converted, target);
|
|
110
|
+
};
|
|
111
|
+
return <View style={styles.wheelField}>
|
|
112
|
+
{choices.length > 1 ? <UnitTabs units={choices.map((item) => item.unit)} active={unitIndex} onSelect={switchUnit} colors={colors} /> : null}
|
|
113
|
+
<View style={[styles.numberRow, { borderColor: text ? colors.primary : colors.line }]}>
|
|
114
|
+
<TextInput accessibilityLabel={choice.unit ? `Your answer in ${choice.unit}` : 'Your answer'} keyboardType="decimal-pad" value={text} onChangeText={(raw) => { const clean = raw.replace(/[^\d.,]/g, ''); setText(clean); report(clean); }}
|
|
115
|
+
placeholder={unitIndex === 0 ? placeholder ?? '0' : '0'} placeholderTextColor={colors.muted} style={[styles.numberInput, { color: colors.text, fontFamily: colors.font, fontSize: size }]} />
|
|
116
|
+
{choice.unit ? <Text style={{ color: colors.muted, fontSize: 17, fontWeight: '600', fontFamily: colors.bodyFont }}>{choice.unit}</Text> : null}
|
|
117
|
+
</View>
|
|
118
|
+
</View>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
122
|
+
const daysIn = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
|
123
|
+
|
|
124
|
+
/** Month, day, and year wheels. The answer is an ISO date, YYYY-MM-DD. */
|
|
125
|
+
export function DateWheels({ minYear, maxYear, initialYear, onChange, colors }: { minYear?: number; maxYear?: number; initialYear?: number; onChange: (value: string) => void; colors: Colors }) {
|
|
126
|
+
const thisYear = new Date().getFullYear();
|
|
127
|
+
const lowest = Math.round(minYear ?? thisYear - 100);
|
|
128
|
+
const highest = Math.max(lowest, Math.round(maxYear ?? thisYear));
|
|
129
|
+
const years = useMemo(() => Array.from({ length: highest - lowest + 1 }, (_, offset) => lowest + offset), [lowest, highest]);
|
|
130
|
+
const [year, setYear] = useState(Math.min(highest, Math.max(lowest, Math.round(initialYear ?? Math.min(highest, thisYear - 25)))));
|
|
131
|
+
const [month, setMonth] = useState(0);
|
|
132
|
+
const [day, setDay] = useState(1);
|
|
133
|
+
const days = daysIn(year, month);
|
|
134
|
+
const safeDay = Math.min(day, days);
|
|
135
|
+
useEffect(() => { onChange(`${year}-${String(month + 1).padStart(2, '0')}-${String(safeDay).padStart(2, '0')}`); }, [year, month, safeDay]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
136
|
+
return <View style={[styles.wheelWrap, styles.dateRow]}>
|
|
137
|
+
<Band colors={colors} />
|
|
138
|
+
<Wheel flex={1.6} values={MONTHS} index={month} label="Month" colors={colors} onIndex={setMonth} />
|
|
139
|
+
<Wheel flex={0.8} values={Array.from({ length: days }, (_, offset) => String(offset + 1))} index={safeDay - 1} label="Day" colors={colors} onIndex={(next) => setDay(next + 1)} />
|
|
140
|
+
<Wheel flex={1.1} values={years.map(String)} index={years.indexOf(year)} label="Year" colors={colors} onIndex={(next) => setYear(years[next])} />
|
|
141
|
+
</View>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A searchable country list. The answer is the country's English name. */
|
|
145
|
+
export function CountryPicker({ value, onChange, colors, placeholder }: { value: string; onChange: (value: string) => void; colors: Colors; placeholder?: string }) {
|
|
146
|
+
const [query, setQuery] = useState('');
|
|
147
|
+
const needle = query.trim().toLowerCase();
|
|
148
|
+
const matches = needle ? COUNTRIES.filter(([code, name]) => name.toLowerCase().includes(needle) || code.toLowerCase() === needle) : COUNTRIES;
|
|
149
|
+
return <View style={styles.country}>
|
|
150
|
+
<TextInput value={query} onChangeText={setQuery} placeholder={placeholder ?? 'Search countries'} placeholderTextColor={colors.muted} accessibilityLabel="Search countries" autoCorrect={false}
|
|
151
|
+
style={[styles.countrySearch, { color: colors.text, borderColor: colors.line, backgroundColor: colors.card, fontFamily: colors.bodyFont }]} />
|
|
152
|
+
{matches.map(([code, name]) => {
|
|
153
|
+
const picked = value === name;
|
|
154
|
+
return <Pressable key={code} accessibilityRole="radio" accessibilityState={{ checked: picked }} onPress={() => onChange(name)} style={[styles.countryRow, { borderColor: picked ? colors.primary : colors.line, backgroundColor: picked ? `${colors.primary}1f` : colors.card }]}>
|
|
155
|
+
<Text style={styles.countryFlag}>{flagOf(code)}</Text>
|
|
156
|
+
<Text style={{ flex: 1, color: colors.text, fontSize: 15, fontWeight: picked ? '700' : '500', fontFamily: colors.bodyFont }}>{name}</Text>
|
|
157
|
+
{picked ? <Text style={{ color: colors.primary, fontSize: 16, fontWeight: '700' }}>✓</Text> : null}
|
|
158
|
+
</Pressable>;
|
|
159
|
+
})}
|
|
160
|
+
{!matches.length ? <Text style={{ color: colors.muted, textAlign: 'center', paddingVertical: 12 }}>No countries match “{query}”</Text> : null}
|
|
161
|
+
</View>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const STARS = (count: number) => '★★★★★'.slice(0, Math.max(0, Math.min(5, Math.round(count)))) + '☆☆☆☆☆'.slice(0, 5 - Math.max(0, Math.min(5, Math.round(count))));
|
|
165
|
+
|
|
166
|
+
type Proof = { rating?: { score: number; count?: string; label?: string }; testimonials?: { quote: string; author: string; detail?: string; stars?: number }[]; stat?: string };
|
|
167
|
+
|
|
168
|
+
/** Star rating, testimonials, and a stat line; cards rise in one after another. */
|
|
169
|
+
export function ProofBlock({ proof, colors, reduceMotion, align }: { proof: Proof; colors: Colors; reduceMotion: boolean; align: 'left' | 'center' | 'right' }) {
|
|
170
|
+
const items = proof.testimonials?.filter((entry) => entry.quote.trim()).slice(0, 3) ?? [];
|
|
171
|
+
const rise = useRef([0, 1, 2, 3, 4].map(() => new Animated.Value(reduceMotion ? 1 : 0))).current;
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (reduceMotion) return;
|
|
174
|
+
Animated.stagger(110, rise.map((value) => Animated.timing(value, { toValue: 1, duration: 420, easing: Easing.out(Easing.cubic), useNativeDriver: true }))).start();
|
|
175
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
176
|
+
const enter = (index: number) => ({ opacity: rise[index], transform: [{ translateY: rise[index].interpolate({ inputRange: [0, 1], outputRange: [10, 0] }) }] });
|
|
177
|
+
const cross = align === 'center' ? 'center' as const : align === 'right' ? 'flex-end' as const : 'flex-start' as const;
|
|
178
|
+
return <View style={styles.proof}>
|
|
179
|
+
{proof.rating ? <Animated.View style={[styles.proofRating, { alignItems: cross }, enter(0)]} accessible accessibilityLabel={`Rated ${proof.rating.score} out of 5${proof.rating.count ? `, ${proof.rating.count}` : ''}`}>
|
|
180
|
+
<View style={styles.proofScoreRow}>
|
|
181
|
+
<Text style={{ color: colors.text, fontSize: 34, fontWeight: '800', fontFamily: colors.font }}>{proof.rating.score.toFixed(1)}</Text>
|
|
182
|
+
<Text style={{ color: '#f5b301', fontSize: 22, letterSpacing: 2 }}>{STARS(proof.rating.score)}</Text>
|
|
183
|
+
</View>
|
|
184
|
+
{proof.rating.count || proof.rating.label ? <Text style={{ color: colors.muted, fontSize: 13, fontFamily: colors.bodyFont }}>{[proof.rating.count, proof.rating.label].filter(Boolean).join(' · ')}</Text> : null}
|
|
185
|
+
</Animated.View> : null}
|
|
186
|
+
{proof.stat ? <Animated.Text style={[{ color: colors.text, fontSize: 15, fontWeight: '700', textAlign: align, fontFamily: colors.bodyFont }, enter(1)]}>{proof.stat}</Animated.Text> : null}
|
|
187
|
+
{items.map((entry, index) => <Animated.View key={index} style={[styles.proofCard, { backgroundColor: colors.card, borderColor: colors.line }, enter(index + 2)]}>
|
|
188
|
+
{entry.stars ? <Text style={{ color: '#f5b301', fontSize: 14, letterSpacing: 1 }} accessibilityLabel={`${entry.stars} out of 5 stars`}>{STARS(entry.stars)}</Text> : null}
|
|
189
|
+
<Text style={{ color: colors.text, fontSize: 15, lineHeight: 21, fontFamily: colors.bodyFont }}>“{entry.quote}”</Text>
|
|
190
|
+
<Text style={{ color: colors.muted, fontSize: 13, fontWeight: '600', fontFamily: colors.bodyFont }}>{entry.author}{entry.detail ? ` · ${entry.detail}` : ''}</Text>
|
|
191
|
+
</Animated.View>)}
|
|
192
|
+
</View>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const TICKS = 60;
|
|
196
|
+
|
|
197
|
+
/** A ring that fills to 100% while each step gets a check mark; calls onDone when finished. */
|
|
198
|
+
export function LoaderBlock({ steps, duration, colors, track, onDone }: { steps: string[]; duration: number; colors: Colors; track: string; onDone: () => void }) {
|
|
199
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
200
|
+
const [percent, setPercent] = useState(0);
|
|
201
|
+
const finished = useRef(false);
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const id = progress.addListener(({ value }) => setPercent(Math.round(value * 100)));
|
|
204
|
+
Animated.timing(progress, { toValue: 1, duration, easing: Easing.inOut(Easing.quad), useNativeDriver: false }).start(({ finished: done }) => {
|
|
205
|
+
if (done && !finished.current) { finished.current = true; onDone(); }
|
|
206
|
+
});
|
|
207
|
+
return () => progress.removeListener(id);
|
|
208
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
209
|
+
return <View style={styles.loader}>
|
|
210
|
+
<View style={styles.ring} accessible accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: percent }}>
|
|
211
|
+
{Array.from({ length: TICKS }, (_, index) => <View key={index} style={[styles.ringTickWrap, { transform: [{ rotate: `${(index / TICKS) * 360}deg` }] }]}><View style={[styles.ringTick, { backgroundColor: index / TICKS < percent / 100 ? colors.primary : track }]} /></View>)}
|
|
212
|
+
<Text style={[styles.ringValue, { color: colors.text, fontFamily: colors.font }]}>{percent}%</Text>
|
|
213
|
+
</View>
|
|
214
|
+
<View style={styles.steps}>{steps.map((label, index) => {
|
|
215
|
+
const done = percent >= Math.round(((index + 1) / steps.length) * 100);
|
|
216
|
+
const active = !done && percent >= Math.round((index / steps.length) * 100);
|
|
217
|
+
return <View key={`${index}-${label}`} style={styles.stepRow}>
|
|
218
|
+
<View style={[styles.stepMark, { borderColor: done ? colors.primary : colors.line, backgroundColor: done ? colors.primary : 'transparent' }]}>{done ? <Text style={[styles.stepGlyph, { color: colors.card }]}>✓</Text> : null}</View>
|
|
219
|
+
<Text style={{ flex: 1, color: done || active ? colors.text : colors.muted, fontSize: 15, fontWeight: active ? '700' : '500', fontFamily: colors.bodyFont }}>{label}</Text>
|
|
220
|
+
</View>;
|
|
221
|
+
})}</View>
|
|
222
|
+
</View>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const styles = StyleSheet.create({
|
|
226
|
+
wheelWrap: { alignSelf: 'stretch', height: ROW * VISIBLE, justifyContent: 'center' },
|
|
227
|
+
wheelField: { alignSelf: 'stretch', gap: 14 },
|
|
228
|
+
tabs: { flexDirection: 'row', alignSelf: 'center', padding: 3, borderRadius: 12, borderWidth: 1 },
|
|
229
|
+
tab: { minWidth: 64, paddingVertical: 7, paddingHorizontal: 14, borderRadius: 9, alignItems: 'center' },
|
|
230
|
+
numberRow: { alignSelf: 'stretch', flexDirection: 'row', alignItems: 'baseline', justifyContent: 'center', gap: 8, borderBottomWidth: 2, paddingVertical: 10 },
|
|
231
|
+
numberInput: { minWidth: 72, textAlign: 'center', fontWeight: '700', padding: 0 },
|
|
232
|
+
dateRow: { flexDirection: 'row', gap: 4 },
|
|
233
|
+
wheelRow: { height: ROW, alignItems: 'center', justifyContent: 'center' },
|
|
234
|
+
band: { position: 'absolute', left: 0, right: 0, height: ROW, borderRadius: 12, borderWidth: 1 },
|
|
235
|
+
unit: { position: 'absolute', right: 18, height: ROW, justifyContent: 'center' },
|
|
236
|
+
country: { alignSelf: 'stretch', gap: 8 },
|
|
237
|
+
countrySearch: { minHeight: 46, borderWidth: 1, borderRadius: 12, paddingHorizontal: 14, fontSize: 15 },
|
|
238
|
+
countryRow: { minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 14, borderWidth: 1, borderRadius: 12 },
|
|
239
|
+
countryFlag: { fontSize: 22 },
|
|
240
|
+
loader: { alignSelf: 'stretch', alignItems: 'center', gap: 22 },
|
|
241
|
+
proof: { alignSelf: 'stretch', gap: 12 },
|
|
242
|
+
proofRating: { gap: 2 },
|
|
243
|
+
proofScoreRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
244
|
+
proofCard: { gap: 6, padding: 14, borderRadius: 14, borderWidth: 1 },
|
|
245
|
+
ring: { width: 148, height: 148, alignItems: 'center', justifyContent: 'center' },
|
|
246
|
+
ringTickWrap: { position: 'absolute', width: 148, height: 148, alignItems: 'center' },
|
|
247
|
+
ringTick: { width: 4, height: 14, borderRadius: 2 },
|
|
248
|
+
ringValue: { fontSize: 32, fontWeight: '800' },
|
|
249
|
+
steps: { alignSelf: 'stretch', gap: 12 },
|
|
250
|
+
stepRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
|
251
|
+
stepMark: { width: 24, height: 24, borderRadius: 12, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
|
|
252
|
+
stepGlyph: { fontSize: 13, fontWeight: '800', lineHeight: 16 },
|
|
253
|
+
});
|
package/src/units.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Mirrors lib/flow-schema.ts so the SDK converts units exactly like the studio preview.
|
|
2
|
+
/** An extra unit people can switch to, e.g. lb for a kg wheel. `factor` converts from the base unit: other = base × factor. */
|
|
3
|
+
export type UnitOption = { unit: string; factor: number; min?: number; max?: number; step?: number; display?: 'feet-inches' };
|
|
4
|
+
export type UnitChoice = { unit: string; factor: number; min: number; max: number; step: number; display?: 'feet-inches' };
|
|
5
|
+
|
|
6
|
+
const decimalsOf = (value: number) => (String(value).split('.')[1] ?? '').length;
|
|
7
|
+
|
|
8
|
+
/** The base unit first, then up to two switchable units with ranges derived from the base when left out. */
|
|
9
|
+
export function unitChoices(input: { unit?: string; min?: number; max?: number; step?: number; units?: UnitOption[] } | undefined): UnitChoice[] {
|
|
10
|
+
const step = input?.step && input.step > 0 ? input.step : 1;
|
|
11
|
+
const base: UnitChoice = { unit: input?.unit ?? '', factor: 1, min: input?.min ?? 0, max: input?.max ?? 100, step };
|
|
12
|
+
const extra = (input?.units ?? []).filter((option) => option && typeof option.unit === 'string' && option.unit && Number.isFinite(option.factor) && option.factor > 0).slice(0, 2)
|
|
13
|
+
.map((option): UnitChoice => ({ unit: option.unit, factor: option.factor, min: option.min ?? Math.round(base.min * option.factor), max: option.max ?? Math.round(base.max * option.factor), step: option.step && option.step > 0 ? option.step : 1, display: option.display }));
|
|
14
|
+
return [base, ...extra];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function unitValues(choice: UnitChoice) {
|
|
18
|
+
const list: number[] = [];
|
|
19
|
+
for (let value = choice.min; value <= choice.max + 1e-9 && list.length < 2000; value += choice.step) list.push(Math.round(value * 100) / 100);
|
|
20
|
+
return list;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 69 → 5′ 9″ for feet-inches; otherwise the number as is. */
|
|
24
|
+
export function unitLabel(value: number, choice: UnitChoice) {
|
|
25
|
+
if (choice.display !== 'feet-inches') return String(value);
|
|
26
|
+
const inches = Math.round(value);
|
|
27
|
+
return `${Math.floor(inches / 12)}′ ${inches % 12}″`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Answers are always reported in the base unit, rounded to the base step's precision. */
|
|
31
|
+
export function toBaseUnit(value: number, choice: UnitChoice, base: UnitChoice) {
|
|
32
|
+
if (choice.factor === 1) return value;
|
|
33
|
+
const places = decimalsOf(base.step);
|
|
34
|
+
return Math.round((value / choice.factor) * 10 ** places) / 10 ** places;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function nearestIndex(values: number[], target: number) {
|
|
38
|
+
let best = 0;
|
|
39
|
+
for (let index = 1; index < values.length; index += 1) if (Math.abs(values[index] - target) < Math.abs(values[best] - target)) best = index;
|
|
40
|
+
return best;
|
|
41
|
+
}
|
package/src/variables.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Mirrors lib/flow-schema.ts so recalled variables read the same in the app as in the studio preview.
|
|
2
|
+
export type VariableType = 'text' | 'number' | 'money' | 'date' | 'choice' | 'list' | 'country';
|
|
3
|
+
export type DateStyle = 'long' | 'medium' | 'short' | 'numeric';
|
|
4
|
+
/** How a question's saved answer is shown when recalled: a currency turns a number into money; dates pick a style. */
|
|
5
|
+
export type VariableFormat = { currency?: string; dateStyle?: DateStyle };
|
|
6
|
+
/** What the app receives for each variable: the raw value for logic or storage, and the text the onboarding showed. */
|
|
7
|
+
export type VariableValue = { type: VariableType; value: string | number | string[]; unit?: string; currency?: string; display: string };
|
|
8
|
+
|
|
9
|
+
/** Always available in copy; question answers can't use these names. */
|
|
10
|
+
export const BUILT_IN_VARIABLES = ['app_name', 'first_name', 'today', 'weekday'] as const;
|
|
11
|
+
export const CURRENCY_SYMBOLS: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥', INR: '₹', BRL: 'R$', MXN: 'MX$', CAD: 'CA$', AUD: 'A$', CHF: 'CHF ', SEK: 'kr ', NOK: 'kr ', DKK: 'kr ', KRW: '₩', ZAR: 'R ', TRY: '₺', PLN: 'zł ', SGD: 'S$', NZD: 'NZ$' };
|
|
12
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
13
|
+
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
14
|
+
|
|
15
|
+
/** The variable type a question produces, from its answer format. */
|
|
16
|
+
export function variableType(screen: { answerType?: string; multiple?: unknown; input?: { format?: string }; variableFormat?: VariableFormat }): VariableType {
|
|
17
|
+
if (screen.answerType === 'singleChoice') return screen.multiple ? 'list' : 'choice';
|
|
18
|
+
const format = screen.input?.format;
|
|
19
|
+
if (format === 'number' || format === 'wheel') return screen.variableFormat?.currency ? 'money' : 'number';
|
|
20
|
+
if (format === 'date') return 'date';
|
|
21
|
+
if (format === 'country') return 'country';
|
|
22
|
+
return 'text';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 3000 → "3,000"; 70.5 → "70.5". Done by hand so it reads the same on every device and JavaScript engine. */
|
|
26
|
+
export function formatNumber(value: number) {
|
|
27
|
+
const fixed = Math.round(value * 100) / 100;
|
|
28
|
+
const [whole, fraction] = String(Math.abs(fixed)).split('.');
|
|
29
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
30
|
+
return `${fixed < 0 ? '-' : ''}${grouped}${fraction ? `.${fraction}` : ''}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatMoney(value: number, currency: string) {
|
|
34
|
+
const symbol = CURRENCY_SYMBOLS[currency.toUpperCase()] ?? `${currency.toUpperCase()} `;
|
|
35
|
+
return `${symbol}${formatNumber(value)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "1995-01-04" → "January 4, 1995" (long), "Jan 4, 1995" (medium), "Jan 4" (short), "1/4/1995" (numeric). */
|
|
39
|
+
export function formatDate(iso: string, style: DateStyle = 'long') {
|
|
40
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
41
|
+
if (!match) return iso;
|
|
42
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
43
|
+
const name = MONTHS[month - 1] ?? '';
|
|
44
|
+
if (style === 'numeric') return `${month}/${day}/${year}`;
|
|
45
|
+
if (style === 'short') return `${name.slice(0, 3)} ${day}`;
|
|
46
|
+
if (style === 'medium') return `${name.slice(0, 3)} ${day}, ${year}`;
|
|
47
|
+
return `${name} ${day}, ${year}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** ["Dairy", "Nuts", "Gluten"] → "Dairy, Nuts and Gluten". */
|
|
51
|
+
export function joinList(items: string[]) {
|
|
52
|
+
if (items.length <= 1) return items[0] ?? '';
|
|
53
|
+
return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A question's answer as a typed variable. `display` from a number wheel or field carries the unit the person chose (e.g. "154 lb"). */
|
|
57
|
+
export function variableValue(screen: { answerType?: string; multiple?: unknown; input?: { format?: string; unit?: string }; variableFormat?: VariableFormat }, answer: string, picks: string[] = [], display?: string): VariableValue | null {
|
|
58
|
+
const type = variableType(screen);
|
|
59
|
+
if (type === 'list') return picks.length ? { type, value: picks, display: joinList(picks) } : null;
|
|
60
|
+
if (!answer.trim()) return null;
|
|
61
|
+
if (type === 'number' || type === 'money') {
|
|
62
|
+
const number = Number(answer.replace(',', '.'));
|
|
63
|
+
if (!Number.isFinite(number)) return { type: 'text', value: answer, display: answer };
|
|
64
|
+
if (type === 'money') { const currency = (screen.variableFormat?.currency ?? 'USD').toUpperCase(); return { type, value: number, currency, display: formatMoney(number, currency) }; }
|
|
65
|
+
const unit = screen.input?.unit || undefined;
|
|
66
|
+
return { type, value: number, unit, display: display || `${formatNumber(number)}${unit ? ` ${unit}` : ''}` };
|
|
67
|
+
}
|
|
68
|
+
if (type === 'date') return { type, value: answer, display: formatDate(answer, screen.variableFormat?.dateStyle) };
|
|
69
|
+
return { type, value: answer, display: answer };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Values for the built-in variables. */
|
|
73
|
+
export function builtInValues(appName: string | undefined, values: Record<string, string>, now = new Date()) {
|
|
74
|
+
return {
|
|
75
|
+
app_name: appName ?? '',
|
|
76
|
+
first_name: (values.name ?? '').trim().split(/\s+/)[0] ?? '',
|
|
77
|
+
today: `${MONTHS[now.getMonth()]} ${now.getDate()}`,
|
|
78
|
+
weekday: WEEKDAYS[now.getDay()],
|
|
79
|
+
};
|
|
80
|
+
}
|