Onboarding flow
This commit is contained in:
@@ -10,10 +10,11 @@ import {
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import Constants from 'expo-constants';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
@@ -23,7 +24,7 @@ import { useSafeAnalytics } from '../../services/analytics';
|
||||
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
|
||||
import { Language } from '../../types';
|
||||
|
||||
const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png');
|
||||
const HERO_IMAGE = require('../../assets/welcome_hero_vertical.png');
|
||||
|
||||
const getCopy = (language: Language) => {
|
||||
if (language === 'de') {
|
||||
@@ -74,7 +75,10 @@ export default function SignupScreen() {
|
||||
const copy = getCopy(language);
|
||||
const posthog = useSafeAnalytics();
|
||||
const pendingPlant = getPendingPlant();
|
||||
const params = useLocalSearchParams<{ returnTo?: string; topup?: string }>();
|
||||
const isExpoGo = Constants.appOwnership === 'expo';
|
||||
const { height } = useWindowDimensions();
|
||||
const compact = height < 700;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -87,6 +91,10 @@ export default function SignupScreen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const heroHeight = emailExpanded ? (compact ? 200 : 240) : (compact ? 320 : 400);
|
||||
const appleButtonHeight = emailExpanded ? 44 : 56;
|
||||
const inputHeight = emailExpanded && compact ? 46 : 52;
|
||||
|
||||
useEffect(() => {
|
||||
posthog.capture('signup_screen_viewed', { context: 'onboarding' });
|
||||
}, [posthog]);
|
||||
@@ -115,6 +123,16 @@ export default function SignupScreen() {
|
||||
await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {});
|
||||
}
|
||||
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||
const returnTo = Array.isArray(params.returnTo) ? params.returnTo[0] : params.returnTo;
|
||||
const topup = Array.isArray(params.topup) ? params.topup[0] : params.topup;
|
||||
if (returnTo === 'billing') {
|
||||
router.replace({ pathname: '/profile/billing', params: topup ? { topup } : undefined });
|
||||
return;
|
||||
}
|
||||
if (returnTo === 'paywall') {
|
||||
router.replace('/profile/billing?view=paywall&context=out_of_credits');
|
||||
return;
|
||||
}
|
||||
router.replace('/(tabs)');
|
||||
};
|
||||
|
||||
@@ -200,12 +218,17 @@ export default function SignupScreen() {
|
||||
style={[styles.flex, { backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}>
|
||||
<Ionicons name="arrow-back" size={20} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
|
||||
<ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}>
|
||||
<ImageBackground
|
||||
source={HERO_IMAGE}
|
||||
style={[styles.hero, { height: heroHeight, minHeight: heroHeight }]}
|
||||
imageStyle={styles.heroImage}
|
||||
>
|
||||
<View style={styles.heroOverlay} />
|
||||
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}>
|
||||
<Ionicons name="arrow-back" size={20} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={styles.heroTitle}>{copy.headline}</Text>
|
||||
<Text style={styles.heroSubline}>{copy.subline}</Text>
|
||||
@@ -213,160 +236,165 @@ export default function SignupScreen() {
|
||||
</ImageBackground>
|
||||
|
||||
<View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}>
|
||||
{pendingPlant ? (
|
||||
<View style={[styles.pendingHint, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
|
||||
<Ionicons name="sparkles" size={18} color={colors.primary} />
|
||||
<Text style={[styles.pendingHintText, { color: colors.text }]}>
|
||||
{copy.savePlantPrefix} {pendingPlant.result.name}
|
||||
<View style={{ gap: emailExpanded ? 8 : 14 }}>
|
||||
{pendingPlant ? (
|
||||
<View style={[styles.pendingHint, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
|
||||
<Ionicons name="sparkles" size={18} color={colors.primary} />
|
||||
<Text style={[styles.pendingHintText, { color: colors.text }]}>
|
||||
{copy.savePlantPrefix} {pendingPlant.result.name}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{appleAvailable ? (
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||
buttonStyle={isDarkMode
|
||||
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||
cornerRadius={14}
|
||||
style={[styles.appleButton, { height: appleButtonHeight }]}
|
||||
onPress={handleAppleSignIn}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{appleAvailable ? (
|
||||
<View style={[styles.dividerRow, { marginVertical: emailExpanded ? 2 : 6 }]}>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!emailExpanded ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.emailChoiceBtn, { backgroundColor: colors.surface, borderColor: colors.borderStrong }]}
|
||||
onPress={() => setEmailExpanded(true)}
|
||||
activeOpacity={0.84}
|
||||
>
|
||||
<Ionicons name="mail-outline" size={19} color={colors.primary} />
|
||||
<Text style={[styles.emailChoiceText, { color: colors.text }]}>{copy.emailCta}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={[styles.form, { gap: emailExpanded && compact ? 8 : 12 }]}>
|
||||
<View style={[styles.fieldGroup, { gap: emailExpanded && compact ? 4 : 6 }]}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border, height: inputHeight }]}>
|
||||
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text, height: inputHeight }]}
|
||||
placeholder={t.namePlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.fieldGroup, { gap: emailExpanded && compact ? 4 : 6 }]}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border, height: inputHeight }]}>
|
||||
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text, height: inputHeight }]}
|
||||
placeholder={t.emailPlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.fieldGroup, { gap: emailExpanded && compact ? 4 : 6 }]}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border, height: inputHeight }]}>
|
||||
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text, height: inputHeight }]}
|
||||
placeholder={t.passwordPlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="new-password"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
|
||||
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.fieldGroup, { gap: emailExpanded && compact ? 4 : 6 }]}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
|
||||
<View style={[
|
||||
styles.inputRow,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.border,
|
||||
height: inputHeight,
|
||||
},
|
||||
]}>
|
||||
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text, height: inputHeight }]}
|
||||
placeholder={t.confirmPasswordPlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={passwordConfirm}
|
||||
onChangeText={setPasswordConfirm}
|
||||
secureTextEntry={!showPasswordConfirm}
|
||||
autoComplete="new-password"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSignup}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
|
||||
<Ionicons name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ gap: 12, marginTop: 16 }}>
|
||||
{error ? (
|
||||
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
|
||||
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
|
||||
<Text style={[styles.errorText, { color: colors.danger }]} selectable>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{emailExpanded ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]}
|
||||
onPress={handleSignup}
|
||||
activeOpacity={0.84}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} size="small" />
|
||||
) : (
|
||||
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.createCta}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace({ pathname: '/auth/login', params: { returnTo: params.returnTo, topup: params.topup } })} activeOpacity={0.78}>
|
||||
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
|
||||
{copy.already}{' '}
|
||||
<Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.login}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{appleAvailable ? (
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||
buttonStyle={isDarkMode
|
||||
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||
cornerRadius={14}
|
||||
style={styles.appleButton}
|
||||
onPress={handleAppleSignIn}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{appleAvailable ? (
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!emailExpanded ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.emailChoiceBtn, { backgroundColor: colors.surface, borderColor: colors.borderStrong }]}
|
||||
onPress={() => setEmailExpanded(true)}
|
||||
activeOpacity={0.84}
|
||||
>
|
||||
<Ionicons name="mail-outline" size={19} color={colors.primary} />
|
||||
<Text style={[styles.emailChoiceText, { color: colors.text }]}>{copy.emailCta}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.form}>
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder={t.namePlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder={t.emailPlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder={t.passwordPlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="new-password"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
|
||||
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
|
||||
<View style={[
|
||||
styles.inputRow,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.border,
|
||||
},
|
||||
]}>
|
||||
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder={t.confirmPasswordPlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={passwordConfirm}
|
||||
onChangeText={setPasswordConfirm}
|
||||
secureTextEntry={!showPasswordConfirm}
|
||||
autoComplete="new-password"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSignup}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
|
||||
<Ionicons name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
|
||||
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
|
||||
<Text style={[styles.errorText, { color: colors.danger }]} selectable>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{emailExpanded ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]}
|
||||
onPress={handleSignup}
|
||||
activeOpacity={0.84}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} size="small" />
|
||||
) : (
|
||||
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.createCta}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')} activeOpacity={0.78}>
|
||||
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
|
||||
{copy.already}{' '}
|
||||
<Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.login}</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.legal, { color: colors.textMuted }]}>{copy.legal}</Text>
|
||||
<Text style={[styles.legal, { color: colors.textMuted }]}>{copy.legal}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
@@ -398,6 +426,8 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.28)',
|
||||
zIndex: 10,
|
||||
elevation: 10,
|
||||
},
|
||||
heroCopy: { gap: 10 },
|
||||
heroTitle: {
|
||||
@@ -418,9 +448,9 @@ const styles = StyleSheet.create({
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
paddingHorizontal: 22,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 32,
|
||||
gap: 14,
|
||||
paddingTop: 28,
|
||||
paddingBottom: 40,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
pendingHint: {
|
||||
flexDirection: 'row',
|
||||
|
||||
Reference in New Issue
Block a user