Onboarding flow

This commit is contained in:
2026-07-06 22:25:52 +02:00
parent 99cd885833
commit 6b57a2acfc
37 changed files with 1503 additions and 507 deletions

View File

@@ -14,7 +14,7 @@ import {
} 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';
@@ -24,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') {
@@ -71,6 +71,7 @@ export default function LoginScreen() {
const colors = useColors(isDarkMode, colorPalette);
const copy = getCopy(language);
const posthog = useSafeAnalytics();
const params = useLocalSearchParams<{ returnTo?: string; topup?: string }>();
const isExpoGo = Constants.appOwnership === 'expo';
const [email, setEmail] = useState('');
@@ -106,6 +107,16 @@ export default function LoginScreen() {
if (session.isNewUser) {
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)');
};
@@ -181,12 +192,13 @@ export default function LoginScreen() {
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}>
<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>
@@ -283,7 +295,7 @@ export default function LoginScreen() {
)}
</TouchableOpacity>
<TouchableOpacity style={styles.signupLink} onPress={() => router.replace('/auth/signup')} activeOpacity={0.78}>
<TouchableOpacity style={styles.signupLink} onPress={() => router.replace({ pathname: '/auth/signup', params: { returnTo: params.returnTo, topup: params.topup } })} activeOpacity={0.78}>
<Text style={[styles.signupLinkText, { color: colors.textSecondary }]}>
{copy.newHere}{' '}
<Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.create}</Text>
@@ -320,6 +332,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.28)',
zIndex: 10,
elevation: 10,
},
heroCopy: { gap: 10 },
heroTitle: {

View File

@@ -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',

View File

@@ -2,13 +2,13 @@ import React, { useEffect } from 'react';
import {
Image,
ImageBackground,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useApp } from '../context/AppContext';
@@ -61,6 +61,7 @@ export default function OnboardingScreen() {
const compact = height < 700;
const posthog = useSafeAnalytics();
const copy = getWelcomeCopy(language);
const insets = useSafeAreaInsets();
useEffect(() => {
posthog.capture('onboarding_welcome_viewed');
@@ -68,14 +69,13 @@ export default function OnboardingScreen() {
return (
<View style={styles.container}>
<ImageBackground
source={require('../assets/welcome_botanical_hero.png')}
style={[styles.hero, { height: compact ? '48%' : '55%' }]}
imageStyle={styles.heroImageContent}
<Image
source={require('../assets/welcome_hero_vertical.png')}
style={[{ position: 'absolute', top: 0, left: 0, right: 0, width: '100%' }, { height: compact ? '65%' : '70%' }]}
resizeMode="cover"
>
<View style={styles.heroShadeTop} />
<SafeAreaView style={styles.heroSafe}>
/>
<View style={[{ width: '100%' }, { height: compact ? '48%' : '52%' }]}>
<View style={[styles.heroSafe, { paddingTop: Math.max(insets.top, 16) }]}>
<View style={styles.heroTopRow}>
<View style={styles.brandRow}>
<Image
@@ -104,12 +104,13 @@ export default function OnboardingScreen() {
</View>
</View>
</View>
</SafeAreaView>
</ImageBackground>
</View>
</View>
<View style={styles.sheet}>
<View style={styles.sheetHandle} />
<View style={styles.sheetContent}>
<View style={styles.topSpacer} />
<Text style={[styles.headline, compact && styles.headlineCompact]}>{copy.headline}</Text>
<Text style={styles.subline}>{copy.subline}</Text>
@@ -131,7 +132,7 @@ export default function OnboardingScreen() {
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/scanner')} style={styles.demoLink}>
<Ionicons name="scan-outline" size={16} color="#4b7c31" />
<Ionicons name="scan-outline" size={18} color="#a6d66f" />
<Text style={styles.demoText}>{copy.demoScan}</Text>
</TouchableOpacity>
@@ -164,14 +165,14 @@ const styles = StyleSheet.create({
heroSafe: {
flex: 1,
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 24,
paddingHorizontal: 24,
paddingBottom: 32,
},
heroTopRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
brandRow: {
flexDirection: 'row',
@@ -190,18 +191,18 @@ const styles = StyleSheet.create({
fontWeight: '900',
},
brandAccent: {
color: '#a6d66f',
color: '#8ba885',
},
ratingPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
backgroundColor: 'rgba(255,255,255,0.18)',
gap: 6,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
borderColor: 'rgba(255,255,255,0.25)',
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
paddingHorizontal: 14,
paddingVertical: 8,
},
ratingText: {
color: '#ffffff',
@@ -209,20 +210,23 @@ const styles = StyleSheet.create({
fontWeight: '700',
},
testimonialCard: {
backgroundColor: 'rgba(255,255,255,0.97)',
backgroundColor: 'rgba(16, 26, 18, 0.75)',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.18,
shadowOpacity: 0.25,
shadowRadius: 14,
elevation: 4,
marginBottom: 8,
},
testimonialCardCompact: {
padding: 12,
},
testimonialText: {
color: '#191d16',
color: '#ffffff',
fontSize: 14.5,
lineHeight: 20,
fontStyle: 'italic',
@@ -234,7 +238,7 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
},
testimonialAuthor: {
color: '#42493c',
color: '#a6d66f',
fontSize: 13,
fontWeight: '700',
},
@@ -244,17 +248,19 @@ const styles = StyleSheet.create({
},
sheet: {
flex: 1,
backgroundColor: '#fbfaf3',
backgroundColor: '#0c160d',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
marginTop: -20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.05)',
},
sheetHandle: {
alignSelf: 'center',
width: 44,
height: 5,
borderRadius: 3,
backgroundColor: 'rgba(16,28,18,0.15)',
backgroundColor: 'rgba(255,255,255,0.2)',
marginTop: 12,
marginBottom: 8,
},
@@ -264,8 +270,11 @@ const styles = StyleSheet.create({
paddingTop: 8,
paddingBottom: 12,
},
topSpacer: {
height: 12,
},
headline: {
color: '#101c12',
color: '#ffffff',
fontSize: 28,
lineHeight: 34,
fontWeight: '900',
@@ -277,15 +286,14 @@ const styles = StyleSheet.create({
lineHeight: 29,
},
subline: {
color: '#5f625d',
color: '#dae6d6',
fontSize: 16,
lineHeight: 22,
fontWeight: '500',
textAlign: 'center',
},
spacer: {
flex: 1,
minHeight: 12,
height: 18,
},
cta: {
height: 60,
@@ -305,7 +313,7 @@ const styles = StyleSheet.create({
paddingVertical: 10,
},
loginText: {
color: '#437824',
color: '#a6d66f',
fontSize: 15,
fontWeight: '800',
},
@@ -313,17 +321,22 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 7,
paddingVertical: 6,
marginBottom: 8,
gap: 8,
height: 52,
borderRadius: 14,
borderWidth: 1.5,
borderColor: '#a6d66f',
backgroundColor: 'rgba(166, 214, 111, 0.05)',
marginVertical: 8,
marginBottom: 12,
},
demoText: {
color: '#4b7c31',
fontSize: 13.5,
color: '#a6d66f',
fontSize: 15,
fontWeight: '700',
},
legal: {
color: '#6b6d68',
color: '#8d9382',
fontSize: 11,
lineHeight: 14,
fontWeight: '500',

View File

@@ -107,7 +107,7 @@ export default function HealthCheckOnboardingScreen() {
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<ImageBackground
source={require('../../assets/onboarding_health_scan_mockup.png')}
style={[styles.illustration, { borderColor: colors.border }]}
style={[styles.illustration, { borderColor: colors.border, backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]}
imageStyle={styles.illustrationImage}
resizeMode="cover"
>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Image, StyleSheet, Text, TouchableOpacity, View, useWindowDimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -168,6 +168,8 @@ function splitReminder(text: string): [string, string] {
export default function OnboardingSlidesScreen() {
const router = useRouter();
const { height } = useWindowDimensions();
const compact = height < 700;
const { language, isDarkMode, colorPalette } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics();
@@ -197,13 +199,13 @@ export default function OnboardingSlidesScreen() {
return (
<View style={[styles.container, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}>
<View style={styles.imageArea}>
<View style={[styles.imageArea, { height: compact ? '58%' : '65%' }]}>
<Image
source={
page === 0
? require('../../assets/paywall_scan_background.png')
: page === 1
? require('../../assets/onboarding_health_scan_mockup.png')
? require('../../assets/onboarding_health_scan_mockup_vertical.png')
: require('../../assets/welcome_botanical_header.png')
}
style={styles.image}
@@ -353,7 +355,7 @@ const styles = StyleSheet.create({
// Health card overlay (slide 2)
healthCard: {
position: 'absolute',
bottom: 16,
bottom: 34,
left: 16,
right: 16,
backgroundColor: 'rgba(255,255,255,0.96)',

View File

@@ -1,74 +1,894 @@
import React, { useState } from 'react';
import { useRouter } from 'expo-router';
import { useSafeAnalytics } from '../../services/analytics';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion';
const SOURCE_OPTIONS = [
{ id: 'app_store', emoji: '🏬', signal: 'organic_store' },
{ id: 'instagram', emoji: '📸', signal: 'social_visual' },
{ id: 'tiktok', emoji: '🎵', signal: 'social_video' },
{ id: 'friend', emoji: '👥', signal: 'referral' },
{ id: 'search', emoji: '🔎', signal: 'high_intent_search' },
{ id: 'other', emoji: '✨', signal: 'unclassified' },
];
export default function OnboardingSourceScreen() {
const router = useRouter();
const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const sourceLabels: Record<string, string> = {
app_store: t.sourceOptionAppStore,
instagram: t.sourceOptionInstagram,
tiktok: t.sourceOptionTikTok,
friend: t.sourceOptionFriend,
search: t.sourceOptionSearch,
other: t.sourceOptionOther,
};
const options: QuestionOption[] = SOURCE_OPTIONS.map((option) => ({
id: option.id,
emoji: option.emoji,
label: sourceLabels[option.id],
}));
const finish = (source: string | null) => {
if (session?.userId && source) {
OnboardingProgressService.setAcquisitionSource(session.userId, source);
}
if (source) {
void PreAuthOnboardingService.setAnswer('acquisitionSource', source);
}
posthog.capture('onboarding_source_completed', {
source: source ?? 'skipped',
revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped',
});
router.replace('/onboarding/goal');
};
return (
<OnboardingQuestion
colors={colors}
isDarkMode={isDarkMode}
step={1}
totalSteps={4}
title={t.sourceOnboardingTitle}
subtitle={t.sourceOnboardingSubtitle}
options={options}
selectedId={selectedSource}
onSelect={setSelectedSource}
onContinue={() => finish(selectedSource)}
onBack={() => router.back()}
continueLabel={t.sourceOnboardingContinue}
skipLabel={t.sourceOnboardingSkip}
onSkip={() => finish(null)}
/>
);
}
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Easing,
FlatList,
Image,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { useSafeAnalytics } from '../../services/analytics';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { Language } from '../../types';
// Avatar path matching copied file
const BOT_AVATAR = require('../../assets/robot_monstera_avatar.jpg');
interface Message {
id: string;
sender: 'bot' | 'user';
text: string;
subtext?: string;
timestamp: string;
}
const getChatCopy = (language: Language) => {
if (language === 'de') {
return {
title: 'Monstera',
skip: 'Überspringen',
continue: 'Weiter',
today: 'Heute, 20:42 Uhr',
typing: 'schreibt...',
botGreeting: "Hi! Ich bin deine neue Monstera. 🌿 Ich freue mich riesig darauf, mit dir zu wachsen!",
botSource: "Als erstes: Wie hast du von GreenLens gehört?",
botGoal: "Verstanden! Und was ist dein Hauptziel mit deinen Pflanzen?",
botExperience: "Schön! Wie erfahren bist du in der Pflanzenpflege?",
botLight: "Damit ich glücklich bleibe und diese großen, schönen Blätter wachsen, muss ich ein bisschen über mein neues Zuhause wissen. Wie viel Licht bekomme ich hier?",
botComplete: "Perfekt! Ich bin bereit einzuziehen. Lass uns deinen Pflegeplan erstellen!",
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Freunde oder Familie',
search: 'Google oder Suche',
other: 'Etwas anderes',
},
goals: {
identify: 'Pflanzen erkennen',
care: 'Pflegepläne',
collection: 'Sammlung vergrößern',
learn: 'Über Pflanzen lernen',
},
experiences: {
beginner: 'Anfänger (Ich bin neu)',
intermediate: 'Fortgeschritten (Ich habe Pflanzen)',
advanced: 'Experte (Ich weiß Bescheid)',
},
lights: {
bright_indirect: {
title: 'Helles, indirektes Licht',
sub: 'In der Nähe eines sonnigen Fensters, ohne direkte Strahlen',
},
low_light: {
title: 'Wenig Licht',
sub: 'Weit weg von Fenstern, meistens Schatten',
},
direct_sunlight: {
title: 'Direktes Sonnenlicht',
sub: 'Den ganzen Tag direkt an einem sonnigen Fenster',
},
},
};
}
if (language === 'es') {
return {
title: 'Monstera',
skip: 'Omitir',
continue: 'Continuar',
today: 'Hoy, 8:42 PM',
typing: 'escribiendo...',
botGreeting: "¡Hola! Soy tu nueva Monstera. 🌿 ¡Estoy muy emocionada de crecer contigo!",
botSource: "Primero, ¿cómo te enteraste de GreenLens?",
botGoal: "¡Entendido! ¿Y cuál es tu objetivo principal con tus plantas?",
botExperience: "¡Genial! ¿Qué experiencia tienes en el cuidado de plantas?",
botLight: "Para estar feliz y que crezcan esas hojas grandes y hermosas, necesito saber un poco sobre mi nuevo hogar. ¿Cuánta luz recibiré aquí?",
botComplete: "¡Perfecto! Estoy lista para mudarme. ¡Creemos tu plan de cuidados!",
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Amigos o familia',
search: 'Google o búsqueda',
other: 'Otra cosa',
},
goals: {
identify: 'Identificar plantas',
care: 'Horarios de cuidado',
collection: 'Crecer mi colección',
learn: 'Aprender sobre plantas',
},
experiences: {
beginner: 'Principiante (Soy nuevo)',
intermediate: 'Intermedio (Tengo plantas)',
advanced: 'Experto (Sé bastante)',
},
lights: {
bright_indirect: {
title: 'Luz brillante e indirecta',
sub: 'Cerca de una ventana soleada, sin sol directo',
},
low_light: {
title: 'Poca luz',
sub: 'Lejos de las ventanas, principalmente sombra',
},
direct_sunlight: {
title: 'Luz solar directa',
sub: 'Directamente en una ventana soleada todo el día',
},
},
};
}
return {
title: 'Monstera',
skip: 'Skip',
continue: 'Continue',
today: 'Today, 8:42 PM',
typing: 'typing...',
botGreeting: "Hi! I'm your new Monstera. 🌿 I'm so excited to grow with you!",
botSource: "First, how did you hear about GreenLens?",
botGoal: "Got it! And what is your main goal with your plants?",
botExperience: "Nice! How experienced are you with plant care?",
botLight: "To stay happy and grow those big, beautiful leaves, I need to know a bit about my new home. How much light do I get here?",
botComplete: "Perfect! I'm ready to move in. Let's build your care plan!",
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Friends or family',
search: 'Google or search',
other: 'Something else',
},
goals: {
identify: 'Identify plants',
care: 'Care schedules',
collection: 'Grow collection',
learn: 'Learn about plants',
},
experiences: {
beginner: 'Beginner (I\'m a new plant parent)',
intermediate: 'Intermediate (I have some plants)',
advanced: 'Advanced (I\'m a plant expert)',
},
lights: {
bright_indirect: {
title: 'Bright, indirect light',
sub: 'Near a sunny window, but no harsh rays',
},
low_light: {
title: 'Low light',
sub: 'Far from windows, mostly shadow',
},
direct_sunlight: {
title: 'Direct sunlight',
sub: 'Directly in a sunny window all day',
},
},
};
};
const TypingIndicator = ({ colors }: { colors: any }) => {
const dot1 = useRef(new Animated.Value(0)).current;
const dot2 = useRef(new Animated.Value(0)).current;
const dot3 = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animateDot = (value: Animated.Value, delay: number) => {
return Animated.loop(
Animated.sequence([
Animated.delay(delay),
Animated.timing(value, {
toValue: -6,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(value, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.delay(350),
])
);
};
const anim1 = animateDot(dot1, 0);
const anim2 = animateDot(dot2, 120);
const anim3 = animateDot(dot3, 240);
anim1.start();
anim2.start();
anim3.start();
return () => {
anim1.stop();
anim2.stop();
anim3.stop();
};
}, [dot1, dot2, dot3]);
return (
<View style={styles.typingContainer}>
<Animated.View style={[styles.typingDot, { backgroundColor: colors.primary, transform: [{ translateY: dot1 }] }]} />
<Animated.View style={[styles.typingDot, { backgroundColor: colors.primary, transform: [{ translateY: dot2 }] }]} />
<Animated.View style={[styles.typingDot, { backgroundColor: colors.primary, transform: [{ translateY: dot3 }] }]} />
</View>
);
};
export default function OnboardingChatScreen() {
const router = useRouter();
const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, language } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const copy = getChatCopy(language);
const { height } = useWindowDimensions();
const compact = height < 700;
const [currentStep, setCurrentStep] = useState<number>(0);
const [messages, setMessages] = useState<Message[]>([]);
const [isTyping, setIsTyping] = useState<boolean>(false);
const [showOptions, setShowOptions] = useState<boolean>(false);
const [answers, setAnswers] = useState({
acquisitionSource: null as string | null,
primaryGoal: null as string | null,
experienceLevel: null as string | null,
lightLevel: null as string | null,
});
const flatListRef = useRef<FlatList>(null);
const floatAnim = useRef(new Animated.Value(0)).current;
// Float animation loop for the background monstera pot
useEffect(() => {
const float = () => {
Animated.sequence([
Animated.timing(floatAnim, {
toValue: -12,
duration: 3000,
easing: Easing.inOut(Easing.sin),
useNativeDriver: true,
}),
Animated.timing(floatAnim, {
toValue: 0,
duration: 3000,
easing: Easing.inOut(Easing.sin),
useNativeDriver: true,
}),
]).start(() => float());
};
float();
}, [floatAnim]);
// Initial Bot Greeting
useEffect(() => {
setIsTyping(true);
setShowOptions(false);
const timer1 = setTimeout(() => {
setMessages([
{
id: 'greeting',
sender: 'bot',
text: copy.botGreeting,
timestamp: copy.today,
},
]);
setIsTyping(false);
// Delay before asking first question
const timer2 = setTimeout(() => {
setIsTyping(true);
const timer3 = setTimeout(() => {
setMessages((prev) => [
...prev,
{
id: 'question-source',
sender: 'bot',
text: copy.botSource,
timestamp: copy.today,
},
]);
setIsTyping(false);
setShowOptions(true); // Only show options after question is fully posted!
}, 800);
return () => clearTimeout(timer3);
}, 500);
return () => clearTimeout(timer2);
}, 1200);
return () => clearTimeout(timer1);
}, []);
// Scroll to bottom on messages update
useEffect(() => {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
}, [messages, isTyping]);
const selectOption = (id: string, label: string) => {
// Add user bubble
const userMsg: Message = {
id: `user-${currentStep}-${Date.now()}`,
sender: 'user',
text: label,
timestamp: copy.today,
};
setMessages((prev) => [...prev, userMsg]);
const nextStep = currentStep + 1;
setCurrentStep(nextStep);
// Save answer
const nextAnswers = { ...answers };
if (currentStep === 0) nextAnswers.acquisitionSource = id;
else if (currentStep === 1) nextAnswers.primaryGoal = id;
else if (currentStep === 2) nextAnswers.experienceLevel = id;
else if (currentStep === 3) nextAnswers.lightLevel = id;
setAnswers(nextAnswers);
// Trigger bot response typing
setIsTyping(true);
setShowOptions(false); // Hide immediately when user answers
const delay = setTimeout(() => {
let botText = '';
let botSubtext = undefined;
if (nextStep === 1) {
botText = copy.botGoal;
} else if (nextStep === 2) {
botText = copy.botExperience;
} else if (nextStep === 3) {
botText = copy.botLight;
} else if (nextStep === 4) {
botText = copy.botComplete;
}
setMessages((prev) => [
...prev,
{
id: `bot-${nextStep}-${Date.now()}`,
sender: 'bot',
text: botText,
subtext: botSubtext,
timestamp: copy.today,
},
]);
setIsTyping(false);
setShowOptions(true); // Re-enable options after bot finishes typing
}, 850);
return () => clearTimeout(delay);
};
const handleBack = () => {
if (currentStep === 0) {
router.back();
return;
}
const prevStep = currentStep - 1;
setCurrentStep(prevStep);
// Revert last answers
const nextAnswers = { ...answers };
if (prevStep === 0) nextAnswers.acquisitionSource = null;
else if (prevStep === 1) nextAnswers.primaryGoal = null;
else if (prevStep === 2) nextAnswers.experienceLevel = null;
else if (prevStep === 3) nextAnswers.lightLevel = null;
setAnswers(nextAnswers);
// Remove user bubble and bot bubble from messages history
setMessages((prev) => {
const copyMsgs = [...prev];
// Pop bot question bubble and user response bubble
copyMsgs.pop();
copyMsgs.pop();
return copyMsgs;
});
};
const onSkip = () => {
posthog.capture('onboarding_chat_skipped', { step: currentStep });
router.replace('/onboarding/health-check');
};
const onFinish = () => {
if (session?.userId) {
if (answers.acquisitionSource) OnboardingProgressService.setAcquisitionSource(session.userId, answers.acquisitionSource);
if (answers.primaryGoal) OnboardingProgressService.setPrimaryGoal(session.userId, answers.primaryGoal);
if (answers.experienceLevel) OnboardingProgressService.setExperienceLevel(session.userId, answers.experienceLevel);
}
if (answers.acquisitionSource) void PreAuthOnboardingService.setAnswer('acquisitionSource', answers.acquisitionSource);
if (answers.primaryGoal) void PreAuthOnboardingService.setAnswer('primaryGoal', answers.primaryGoal);
if (answers.experienceLevel) void PreAuthOnboardingService.setAnswer('experienceLevel', answers.experienceLevel);
if (answers.lightLevel) void PreAuthOnboardingService.setAnswer('lightLevel', answers.lightLevel);
posthog.capture('onboarding_chat_completed', {
source: answers.acquisitionSource,
goal: answers.primaryGoal,
experience: answers.experienceLevel,
light: answers.lightLevel,
});
router.replace('/onboarding/health-check');
};
// Build options list based on current question step
const renderOptions = () => {
if (!showOptions || isTyping) return null;
if (currentStep === 0) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>How did you find GreenLens?</Text>
<View style={styles.optionsGrid}>
{([
{ id: 'app_store', label: copy.sources.app_store, icon: 'logo-apple' as const },
{ id: 'instagram', label: copy.sources.instagram, icon: 'logo-instagram' as const },
{ id: 'tiktok', label: copy.sources.tiktok, icon: 'logo-youtube' as const },
{ id: 'friend', label: copy.sources.friend, icon: 'people-outline' as const },
{ id: 'search', label: copy.sources.search, icon: 'search-outline' as const },
{ id: 'other', label: copy.sources.other, icon: 'sparkles-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.pillBtn, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<Ionicons name={opt.icon} size={15} color={colors.primary} style={{ marginRight: 4 }} />
<Text style={[styles.pillLabel, { color: colors.text }]}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
if (currentStep === 1) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>What is your main goal?</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'identify', label: copy.goals.identify, icon: 'search-outline' as const },
{ id: 'care', label: copy.goals.care, icon: 'water-outline' as const },
{ id: 'collection', label: copy.goals.collection, icon: 'leaf-outline' as const },
{ id: 'learn', label: copy.goals.learn, icon: 'book-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.optionRow, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.optionRowLeft}>
<Ionicons name={opt.icon} size={18} color={colors.primary} style={{ marginRight: 10 }} />
<Text style={[styles.rowText, { color: colors.text }]}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
</View>
);
}
if (currentStep === 2) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>What is your experience level?</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'beginner', label: copy.experiences.beginner, icon: 'leaf-outline' as const },
{ id: 'intermediate', label: copy.experiences.intermediate, icon: 'sunny-outline' as const },
{ id: 'advanced', label: copy.experiences.advanced, icon: 'flask-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.optionRow, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.optionRowLeft}>
<Ionicons name={opt.icon} size={18} color={colors.primary} style={{ marginRight: 10 }} />
<Text style={[styles.rowText, { color: colors.text }]}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
</View>
);
}
if (currentStep === 3) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>How much light do I get here?</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'bright_indirect', label: copy.lights.bright_indirect.title, sub: copy.lights.bright_indirect.sub, icon: 'wb-twilight' },
{ id: 'low_light', label: copy.lights.low_light.title, sub: copy.lights.low_light.sub, icon: 'brightness-4' },
{ id: 'direct_sunlight', label: copy.lights.direct_sunlight.title, sub: copy.lights.direct_sunlight.sub, icon: 'sunny' },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.bentoOption, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.bentoCopy}>
<Text style={[styles.bentoTitle, { color: colors.text }]}>{opt.label}</Text>
<Text style={[styles.bentoSub, { color: colors.textSecondary }]}>{opt.sub}</Text>
</View>
<Ionicons name={opt.icon === 'sunny' ? 'sunny-outline' : opt.icon === 'brightness-4' ? 'contrast' : 'partly-sunny-outline'} size={20} color={colors.primary} />
</TouchableOpacity>
))}
</View>
</View>
);
}
if (currentStep === 4) {
return (
<View style={styles.footerWrap}>
<TouchableOpacity style={[styles.continueCta, { backgroundColor: colors.primary }]} onPress={onFinish} activeOpacity={0.86}>
<Text style={[styles.continueCtaText, { color: colors.onPrimary }]}>{copy.continue}</Text>
</TouchableOpacity>
</View>
);
}
return null;
};
const renderMessageItem = ({ item }: { item: Message }) => {
const isBot = item.sender === 'bot';
return (
<View style={[styles.messageRow, isBot ? styles.messageRowLeft : styles.messageRowRight]}>
{isBot && (
<Image source={BOT_AVATAR} style={styles.chatAvatar} resizeMode="cover" />
)}
<View style={[
styles.bubble,
isBot
? [styles.bubbleLeft, { backgroundColor: '#142215' }]
: [styles.bubbleRight, { backgroundColor: '#2e4f00' }]
]}>
<Text style={[styles.bubbleText, { color: isBot ? '#dae6d6' : '#ffffff' }]}>{item.text}</Text>
{item.subtext ? (
<Text style={styles.bubbleSub}>{item.subtext}</Text>
) : null}
</View>
</View>
);
};
const progressPercent = `${Math.min(currentStep * 25, 100)}%`;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Animated.Image
source={{ uri: 'https://lh3.googleusercontent.com/aida/AP1WRLvNv80S62DEz_zBQ-9HwoEIRjqH2RBaY0knbsg2dBsQySI1kJC23xC8q_rkQ_100IiLnW4uwtzj0t7koHQvFUc6-4kyGCdyYdE7JY_nMeC628hnY7KYw52GNy1ErcjFii-YOISxq1GRLNpaFN_IklHttmJ30NC85GX4pCt7Q23hrYSQ_Mfx4_T1lqXum2KdLfiyyOZoRKuLCP0jTBbAcEi8DCLZtsAUSYcttqmUtuaTMnt5W07_sIn0dAw' }}
style={[styles.robotBackground, { transform: [{ translateY: floatAnim }] }]}
resizeMode="cover"
/>
{/* Soft stepwise gradient mask to blend bottom image edge with background */}
<View style={styles.maskContainer}>
<View style={[styles.maskLine, { opacity: 0.1, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 0.25, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 0.45, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 0.65, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 0.85, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 1.0, backgroundColor: '#131e14' }]} />
</View>
<SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
{/* Header Bar */}
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.headerBack}>
<Ionicons name="arrow-back" size={24} color="#dae6d6" />
</TouchableOpacity>
<View style={styles.progressContainer}>
<View style={styles.progressBarTrack}>
<View style={[styles.progressBarFill, { width: progressPercent as any }]} />
</View>
</View>
<TouchableOpacity onPress={onSkip} style={styles.headerSkip}>
<Text style={styles.skipText}>{copy.skip}</Text>
</TouchableOpacity>
</View>
{/* Chat History Panel */}
<View style={styles.chatPanel}>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={renderMessageItem}
contentContainerStyle={styles.chatListContent}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={styles.timeDivider}>
<Text style={styles.timeDividerText}>{copy.today}</Text>
</View>
}
ListFooterComponent={
isTyping ? (
<View style={[styles.messageRow, styles.messageRowLeft]}>
<Image source={BOT_AVATAR} style={styles.chatAvatar} resizeMode="cover" />
<View style={[styles.bubble, styles.bubbleLeft, { backgroundColor: '#142215' }]}>
<TypingIndicator colors={colors} />
</View>
</View>
) : null
}
/>
</View>
{/* Input/Selection Panel */}
<View style={styles.inputPanel}>
{renderOptions()}
</View>
</SafeAreaView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#131e14',
},
robotBackground: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: 380,
opacity: 0.28,
},
maskContainer: {
position: 'absolute',
top: 260,
left: 0,
right: 0,
height: 120,
},
maskLine: {
height: 20,
width: '100%',
},
optionRowLeft: {
flexDirection: 'row',
alignItems: 'center',
},
safe: {
flex: 1,
justifyContent: 'space-between',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(255,255,255,0.08)',
},
headerBack: {
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.04)',
},
progressContainer: {
flex: 1,
marginHorizontal: 16,
},
progressBarTrack: {
height: 6,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
},
progressBarFill: {
height: 6,
borderRadius: 3,
backgroundColor: '#a5d56e',
},
headerSkip: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: 'rgba(255,255,255,0.04)',
},
skipText: {
color: '#dae6d6',
fontSize: 13,
fontWeight: '700',
},
chatPanel: {
flex: 1,
paddingHorizontal: 16,
},
chatListContent: {
paddingVertical: 16,
gap: 16,
},
timeDivider: {
alignSelf: 'center',
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 4,
marginBottom: 16,
},
timeDividerText: {
color: '#8d9382',
fontSize: 11,
fontWeight: '700',
},
messageRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 10,
marginVertical: 4,
},
messageRowLeft: {
alignSelf: 'flex-start',
maxWidth: '85%',
},
messageRowRight: {
alignSelf: 'flex-end',
flexDirection: 'row-reverse',
maxWidth: '85%',
},
chatAvatar: {
width: 32,
height: 32,
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
bubble: {
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 1,
},
bubbleLeft: {
borderBottomLeftRadius: 3,
},
bubbleRight: {
borderBottomRightRadius: 3,
},
bubbleText: {
fontSize: 15,
lineHeight: 21,
fontWeight: '600',
},
bubbleSub: {
fontSize: 12,
marginTop: 4,
color: '#8d9382',
},
typingContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
width: 36,
height: 18,
justifyContent: 'center',
},
typingDot: {
width: 6,
height: 6,
borderRadius: 3,
},
inputPanel: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(255,255,255,0.08)',
paddingTop: 12,
paddingBottom: 24,
paddingHorizontal: 16,
backgroundColor: '#131e14',
},
optionsWrap: {
gap: 10,
},
stepTitle: {
color: '#8d9382',
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 4,
textAlign: 'center',
},
optionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
justifyContent: 'center',
},
pillBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 999,
borderWidth: 1.5,
backgroundColor: '#101e11',
},
pillEmoji: {
fontSize: 14,
},
pillLabel: {
fontSize: 13.5,
fontWeight: '700',
},
optionsVertical: {
gap: 8,
},
optionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 14,
borderWidth: 1,
backgroundColor: '#101e11',
},
rowText: {
fontSize: 14.5,
fontWeight: '700',
},
bentoOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 14,
borderWidth: 1,
backgroundColor: '#101e11',
},
bentoCopy: {
flex: 1,
gap: 2,
marginRight: 12,
},
bentoTitle: {
fontSize: 15,
fontWeight: '700',
},
bentoSub: {
fontSize: 11.5,
fontWeight: '600',
lineHeight: 15,
},
footerWrap: {
paddingVertical: 8,
},
continueCta: {
height: 56,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
continueCtaText: {
fontSize: 16.5,
fontWeight: '900',
},
});

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, Switch } from 'react-native';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, Switch, useWindowDimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter, useLocalSearchParams } from 'expo-router';
@@ -303,6 +303,8 @@ const getBillingCopy = (language: Language) => {
export default function BillingScreen() {
const router = useRouter();
const { height } = useWindowDimensions();
const compact = height < 700;
const params = useLocalSearchParams<{ view?: string; context?: string }>();
const paywallRequested = params.view === 'paywall';
const onboardingContext = params.context === 'onboarding';
@@ -628,7 +630,7 @@ export default function BillingScreen() {
</TouchableOpacity>
</View>
<View style={[styles.paywallSheet, { backgroundColor: colors.background }]}>
<View style={[styles.paywallSheet, { backgroundColor: colors.background, marginTop: compact ? 110 : 190 }]}>
<ScrollView
contentContainerStyle={styles.paywallBody}
showsVerticalScrollIndicator={false}

View File

@@ -19,14 +19,13 @@ import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types';
import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService } from '../services/authService';
import { getMockPlantByImage } from '../services/backend/mockCatalog';
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService } from '../services/authService';
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet';
const DEMO_SCAN_LIMIT = 5;
const DEMO_SCAN_LIMIT = 3;
const getBillingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
@@ -52,7 +51,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
demoTitle: 'Rettungsplan bereit',
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.',
demoNoCreditsTitle: 'Demo-Scans aufgebraucht',
demoNoCreditsMessage: 'Du hast deine 5 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.',
demoNoCreditsMessage: 'Du hast deine 3 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.',
demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`,
creditsRemaining: (count: number) => `${count} Scans übrig`,
appleCta: 'Mit Apple fortfahren',
@@ -84,7 +83,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
demoTitle: 'Plan de rescate listo',
demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.',
demoNoCreditsTitle: 'Escaneos demo agotados',
demoNoCreditsMessage: 'Ya usaste tus 5 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.',
demoNoCreditsMessage: 'Ya usaste tus 3 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.',
demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`,
creditsRemaining: (count: number) => `${count} escaneos restantes`,
appleCta: 'Continuar con Apple',
@@ -115,7 +114,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
demoTitle: 'Rescue plan ready',
demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.',
demoNoCreditsTitle: 'Demo scans used',
demoNoCreditsMessage: 'You used your 5 free demo scans on this device. Start Pro to keep scanning plants.',
demoNoCreditsMessage: 'You used your 3 free demo scans on this device. Start Pro to keep scanning plants.',
demoCreditsRemaining: (count: number) => `${count} demo scans left`,
creditsRemaining: (count: number) => `${count} scans left`,
appleCta: 'Continue with Apple',
@@ -161,7 +160,7 @@ export default function ScannerScreen() {
: params.sharedImageKey;
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
const isDemoMode = !session; // guests get the local demo scan; signed-in users burn real credits
const isDemoMode = !session; // guests get limited AI demo scans; signed-in users burn real credits
const availableCredits = billingSummary?.credits.available ?? 0;
const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount);
@@ -262,17 +261,8 @@ export default function ScannerScreen() {
if (isAnalyzing) return;
if (isDemoMode && guestScanCount >= DEMO_SCAN_LIMIT) {
Alert.alert(
billingCopy.demoNoCreditsTitle,
billingCopy.demoNoCreditsMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/profile/billing'),
},
],
);
posthog.capture('out_of_credits_shown', { trigger: 'demo_limit', scan_type: 'identification' });
setOutOfCreditsVisible(true);
return;
}
@@ -303,21 +293,22 @@ export default function ScannerScreen() {
}, 150);
try {
if (isDemoMode) {
posthog.capture('demo_scan_started', {
authenticated: Boolean(session),
scan_type: isHealthMode ? 'health_check' : 'identification',
demo_scans_used: guestScanCount,
demo_scans_remaining: demoScansRemaining,
});
await new Promise(resolve => setTimeout(resolve, 2100));
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 350));
const demoResult = getMockPlantByImage(galleryImageUri || imageUri, language, true);
incrementGuestScanCount();
setAnalysisResult(demoResult);
posthog.capture('demo_scan_completed', {
if (isDemoMode) {
posthog.capture('demo_scan_started', {
authenticated: Boolean(session),
scan_type: isHealthMode ? 'health_check' : 'identification',
demo_scans_used: guestScanCount,
demo_scans_remaining: demoScansRemaining,
});
const demoResult = await PlantRecognitionService.identify(imageUri, language, {
idempotencyKey: createIdempotencyKey('demo-scan-plant'),
});
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 350));
incrementGuestScanCount();
setAnalysisResult(demoResult);
posthog.capture('demo_scan_completed', {
authenticated: Boolean(session),
latency_ms: Date.now() - startTime,
demo_scans_used_after: guestScanCount + 1,
@@ -859,16 +850,27 @@ export default function ScannerScreen() {
renewsAtIso={billingSummary?.credits.cycleEndsAt}
onSeePlans={() => {
setOutOfCreditsVisible(false);
posthog.capture('paywall_opened', { source: 'out_of_credits' });
router.push('/profile/billing?view=paywall');
if (!session) {
posthog.capture('paywall_opened', { source: 'out_of_credits', authenticated: false });
router.replace('/profile/billing?view=paywall&context=out_of_credits');
return;
}
posthog.capture('billing_opened', { source: 'out_of_credits', authenticated: true });
router.replace('/profile/billing');
}}
onTopup={() => {
onTopup={(productId) => {
setOutOfCreditsVisible(false);
router.push('/profile/billing'); // topups live on the billing management screen
posthog.capture('topup_prompt_opened', { source: 'out_of_credits', product_id: productId, authenticated: Boolean(session) });
if (session) {
router.replace({ pathname: '/profile/billing', params: { topup: productId } });
return;
}
router.replace({ pathname: '/auth/signup', params: { returnTo: 'billing', topup: productId } });
}}
onDismiss={() => {
posthog.capture('paywall_dismissed', { source: 'out_of_credits_sheet' });
setOutOfCreditsVisible(false);
handleClose();
}}
/>
</View>