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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Image, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Language } from '../types';
import { useColors } from '../constants/Colors';
@@ -97,8 +97,12 @@ export function OutOfCreditsSheet({ visible, language, colors, isPro = false, re
<View style={[styles.sheet, { backgroundColor: colors.surface }]} accessibilityViewIsModal>
<View style={[styles.handle, { backgroundColor: colors.border }]} />
<View style={styles.iconWrap}>
<View style={[styles.iconCircle, { backgroundColor: colors.primarySoft }]}>
<Ionicons name="leaf-outline" size={34} color={colors.primary} />
<View style={[styles.iconCircle, { backgroundColor: colors.primarySoft, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }]}>
<Image
source={require('../assets/icon.png')}
style={{ width: 44, height: 44, borderRadius: 10 }}
resizeMode="contain"
/>
</View>
<View style={[styles.zeroBadge, { backgroundColor: colors.danger }]}>
<Text style={styles.zeroBadgeText}>0</Text>

View File

@@ -117,16 +117,16 @@
</head>
<body class="bg-background text-on-surface h-screen w-full overflow-hidden flex flex-col font-body-md select-none">
<!-- Top 55%: Hero Image Area -->
<div class="h-[486px] w-full relative shrink-0">
<div class="h-[440px] md:h-[480px] w-full relative shrink-0">
<!-- Hero Background Image -->
<div class="absolute inset-0 bg-cover bg-center" data-alt="A stunning, high-resolution botanical photograph of a lush indoor jungle featuring large Monstera deliciosa and Calathea plants. The composition is shot from a slightly elevated angle, capturing the vibrant green, textured leaves bathed in soft, natural morning light pouring through a nearby window. The background features a subtle, warm cream-colored wall that perfectly aligns with a premium minimalist light-mode app aesthetic. The depth of field is shallow, keeping the foreground leaves razor-sharp while gently blurring the background, creating a calm, focused, and high-end lifestyle magazine mood." style="background-image: url('https://lh3.googleusercontent.com/aida-public/AB6AXuBx6w-Q0WxDraDoAiIgMxWkqZHEYA2QyGU65IPOu1fB8mwd5-O08Z4VcoQ4LdjcnCsU2ybQtlguuy_01cHv1sVfj5RSFen2jMoqF5NAZe_Xg0NqFQXwLIioixIHU-D9ryaPU-Pe3Qgezzgdcr2DXHxTkITv_zzpjcgW4eHm-I5ms5386DGlPXluAxgD-Iy4bQES2VAU1KXak7ZJuqn74-Ue9xRIP85gxxYoST-BemjrzJCEc9jn_h4Fk7HesA75uZGOyzYuEY2lyNA')"></div>
<div class="absolute inset-0 bg-cover bg-center" data-alt="A stunning, high-resolution botanical photograph of a lush indoor jungle featuring large Monstera deliciosa and Calathea plants. The composition is shot from a slightly elevated angle, capturing the vibrant green, textured leaves bathed in soft, natural morning light pouring through a nearby window. The background features a subtle, warm cream-colored wall that perfectly aligns with a premium minimalist light-mode app aesthetic. The depth of field is shallow, keeping the foreground leaves razor-sharp while gently blurring the background, creating a calm, focused, and high-end lifestyle magazine mood." style="background-image: url('../../../assets/welcome_hero_vertical.png')"></div>
<!-- Subtle Top Gradient for Text Legibility -->
<div class="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-on-surface/50 to-transparent"></div>
<!-- Header: Logo & App Store Badge -->
<div class="absolute top-12 left-0 w-full px-container-margin flex items-center justify-between">
<div class="flex items-center gap-2 text-surface-container-lowest">
<span class="material-symbols-outlined text-[28px]" style="font-variation-settings: 'FILL' 1;">spa</span>
<span class="font-display text-display tracking-tight leading-none text-surface-container-lowest pt-1">GreenLens</span>
<span class="font-display text-display tracking-tight leading-none text-surface-container-lowest pt-1">Green<span class="text-[#8ba885]">Lens</span></span>
</div>
<div class="flex items-center gap-1 bg-surface-container-lowest/20 backdrop-blur-md border border-surface-container-lowest/10 rounded-full px-3 py-1.5 shadow-sm">
<span class="material-symbols-outlined text-[14px] text-surface-container-lowest" style="font-variation-settings: 'FILL' 1;">star</span>
@@ -134,7 +134,7 @@
</div>
</div>
<!-- Floating Testimonial Card -->
<div class="absolute bottom-12 left-container-margin right-container-margin bg-surface-container-lowest/95 backdrop-blur-md rounded-[16px] p-4 shadow-[0px_4px_20px_rgba(16,28,18,0.12)] border border-surface-variant/50">
<div class="absolute bottom-12 left-container-margin right-container-margin bg-surface-container-lowest/75 backdrop-blur-md rounded-[16px] p-4 shadow-[0px_4px_20px_rgba(16,28,18,0.12)] border border-surface-variant/50">
<p class="font-body-md text-body-md text-on-surface mb-3 italic leading-relaxed">"Finally my plants stay alive! Highly recommend."</p>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">

View File

@@ -187,16 +187,16 @@
</head>
<body class="bg-background text-on-surface h-screen w-full overflow-hidden flex flex-col font-body-md select-none">
<!-- Top 55%: Hero Image Area -->
<div class="h-[486px] w-full relative shrink-0">
<div class="h-[440px] md:h-[480px] w-full relative shrink-0">
<!-- Hero Background Image -->
<div class="absolute inset-0 bg-cover bg-center" data-alt="A stunning, high-resolution botanical photograph of a lush indoor jungle featuring large Monstera deliciosa and Calathea plants. The composition is shot from a slightly elevated angle, capturing the vibrant green, textured leaves bathed in soft, natural morning light pouring through a nearby window. The background features a subtle, warm cream-colored wall that perfectly aligns with a premium minimalist light-mode app aesthetic. The depth of field is shallow, keeping the foreground leaves razor-sharp while gently blurring the background, creating a calm, focused, and high-end lifestyle magazine mood." style="background-image: url('https://lh3.googleusercontent.com/aida-public/AB6AXuBx6w-Q0WxDraDoAiIgMxWkqZHEYA2QyGU65IPOu1fB8mwd5-O08Z4VcoQ4LdjcnCsU2ybQtlguuy_01cHv1sVfj5RSFen2jMoqF5NAZe_Xg0NqFQXwLIioixIHU-D9ryaPU-Pe3Qgezzgdcr2DXHxTkITv_zzpjcgW4eHm-I5ms5386DGlPXluAxgD-Iy4bQES2VAU1KXak7ZJuqn74-Ue9xRIP85gxxYoST-BemjrzJCEc9jn_h4Fk7HesA75uZGOyzYuEY2lyNA')"></div>
<div class="absolute inset-0 bg-cover bg-center" data-alt="A stunning, high-resolution botanical photograph of a lush indoor jungle featuring large Monstera deliciosa and Calathea plants. The composition is shot from a slightly elevated angle, capturing the vibrant green, textured leaves bathed in soft, natural morning light pouring through a nearby window. The background features a subtle, warm cream-colored wall that perfectly aligns with a premium minimalist light-mode app aesthetic. The depth of field is shallow, keeping the foreground leaves razor-sharp while gently blurring the background, creating a calm, focused, and high-end lifestyle magazine mood." style="background-image: url('../../../assets/welcome_hero_vertical.png')"></div>
<!-- Subtle Top Gradient for Text Legibility -->
<div class="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-surface/80 to-transparent"></div>
<!-- Header: Logo & App Store Badge -->
<div class="absolute top-12 left-0 w-full px-container-margin flex items-center justify-between">
<div class="flex items-center gap-2 text-white">
<span class="material-symbols-outlined text-[28px]" style="font-variation-settings: 'FILL' 1;">spa</span>
<span class="font-display text-display tracking-tight leading-none text-white pt-1">GreenLens</span>
<span class="font-display text-display tracking-tight leading-none text-white pt-1">Green<span class="text-[#8ba885]">Lens</span></span>
</div>
<div class="flex items-center gap-1 bg-surface-container-lowest/40 backdrop-blur-md border border-white/20 rounded-full px-3 py-1.5 shadow-sm text-white">
<span class="material-symbols-outlined text-[14px] text-white" style="font-variation-settings: 'FILL' 1;">star</span>
@@ -204,7 +204,7 @@
</div>
</div>
<!-- Floating Testimonial Card -->
<div class="absolute bottom-12 left-container-margin right-container-margin bg-surface-container-lowest/95 backdrop-blur-md rounded-[16px] p-4 shadow-[0px_4px_20px_rgba(0,0,0,0.5)] border border-surface-variant/50">
<div class="absolute bottom-12 left-container-margin right-container-margin bg-surface-container-lowest/75 backdrop-blur-md rounded-[16px] p-4 shadow-[0px_4px_20px_rgba(0,0,0,0.5)] border border-surface-variant/50">
<p class="font-body-md text-body-md text-on-surface mb-3 italic leading-relaxed">"Finally my plants stay alive! Highly recommend."</p>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">

View File

@@ -22,12 +22,12 @@ Note: `onboarding/customize.tsx` stays out of the chain (as today). The spec lis
**Key backend facts:**
- `server/lib/billing.js`: `FREE_MONTHLY_CREDITS = 0` (line 3), `getAvailableCredits` returns 0 for non-pro (line 257), `consumeCredits` throws for non-pro (line 573), `alignAccountToCurrentCycle` self-heals allowance via `isAllowedMonthlyAllowance` (line 76) — legacy free accounts with stored allowance 0 auto-migrate once `FREE_MONTHLY_CREDITS` changes.
- Costs (`server/index.js:84-87`): scan primary 1, scan review 0, semantic search 2, health check 2.
- Guests: `isGuest(userId)` = `userId === 'guest'`. Guests currently never reach credit consumption because `ensureActiveProEntitlement` throws first. **When removing it, guests must still be blocked server-side** — otherwise all guests share one global `'guest'` billing account and health checks become free for guests (`server/index.js:1005` skips charging guests).
- Guests: `isGuest(userId)` = `userId === 'guest'`. Guests may run the limited pre-auth demo identification scan, but the demo scan must use the same AI identification path as a normal scan. Guests must still be blocked from health checks and semantic search because those would otherwise use one shared `'guest'` billing account.
- Scan model per plan: `server/lib/openai.js:33` `getScanModelChain(plan)` — free uses the cheap chain. Decision: free gets the pro chain (same quality).
- Billing summary shape (`buildBillingSummary`): `credits.cycleEndsAt` is the free-credit renewal date.
**Key app facts:**
- `app/scanner.tsx`: `isDemoMode = !hasActiveEntitlement` (line 170) — ALL non-pro users currently get client-side mock scans (`getMockPlantByImage`), limited to 5/device via `guestScanCount`. New rule: demo mode = guests only (`!session`); signed-in free users do real scans with credits.
- `app/scanner.tsx`: demo mode is guests only (`!session`), limited to 5/device via `guestScanCount`. Demo scans must call the normal identification service, not `getMockPlantByImage`; signed-in free users do real scans with credits.
- `app/profile/billing.tsx` (1779 lines): already contains the full paywall branch `showPaywallPlans` (line 360: `!session || (!isLoadingBilling && planId !== 'pro')`), purchase/restore/sync/Expo-Go-simulation logic, per-language copy via `getBillingCopy(language)`. We reuse ALL purchase logic — only the paywall trigger and the paywall JSX change.
- Theme: `useColors(isDarkMode, colorPalette)` from `constants/Colors.ts` (tokens like `colors.primary`, `colors.surface`, `colors.text`, `colors.primarySoft`, `colors.border`, `colors.onPrimary`, `colors.textSecondary`, `colors.textMuted`, `colors.surfaceMuted`). New screens must support dark mode via these tokens (Stitch dark variants exist as reference).
- New-screen copy: follow the `getBillingCopy(language)`-style local copy object pattern (de/es/en) — do NOT add keys to `utils/translations.ts` unless a screen already uses `t.` keys you're keeping.
@@ -235,19 +235,19 @@ git commit -m "feat(server): free tier with 3 monthly credits"
---
### Task 3: Remove the server hard paywall (guests stay blocked)
### Task 3: Remove the server hard paywall (guest demo scan allowed)
**Files:**
- Modify: `server/index.js:206-218` (helpers), `:734` (scan), `:742` (scan model), `:906` (semantic search), `:946` (health check)
- [ ] **Step 1: Replace the pro-gate helper with a guest gate**
- [ ] **Step 1: Replace the pro-gate helper with a guest gate for non-demo endpoints**
Replace lines 206-218 (`createHardPaywallError` + `ensureActiveProEntitlement`) with:
```js
const ensureNotGuest = (userId, requiredCredits) => {
// Guests use the client-side demo scan; the shared 'guest' billing account
// must never consume real credits or run free AI analyses.
// Guests may use the limited pre-auth demo scan, but the shared 'guest'
// billing account must never consume credits for non-demo endpoints.
if (isGuest(userId)) {
const error = new Error('Sign in to use scan credits.');
error.code = 'INSUFFICIENT_CREDITS';
@@ -262,7 +262,7 @@ Note: `isGuest` is defined at line 353, *after* this helper — that's fine (fun
- [ ] **Step 2: Swap the three call sites**
- `server/index.js:734`: `ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);``ensureNotGuest(userId, SCAN_PRIMARY_COST);`
- `server/index.js:734`: remove the guest gate from `/v1/scan`; guest demo scans may run primary AI identification without consuming account credits.
- `server/index.js:906`: `ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);``ensureNotGuest(userId, SEMANTIC_SEARCH_COST);`
- `server/index.js:946`: `ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);``ensureNotGuest(userId, HEALTH_CHECK_COST);`
@@ -507,7 +507,7 @@ git commit -m "feat(app): out-of-credits bottom sheet (Stitch design)"
---
### Task 6: Scanner — demo mode for guests only, credits + sheet for free users
### Task 6: Scanner — AI demo mode for guests only, credits + sheet for free users
**Files:**
- Modify: `app/scanner.tsx:168-172` (mode flags), `:269-297` (pre-checks), `:414-425` (402 handler)
@@ -519,7 +519,7 @@ Replace lines 168-172:
```tsx
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);
```
@@ -1481,7 +1481,7 @@ Run: `npx expo export --platform android` → completes without errors.
1. Fresh install → welcome (social proof) → Let's Go → 3 slides → 4 question steps with progress bar → personalizing (auto) → paywall with trial toggle → ✕ → sign-up → tabs.
2. New account has 3 credits; scan works and decrements; after 3 scans the out-of-credits sheet appears; "See Pro Plans" opens the paywall; ✕ returns to the scanner (app still usable).
3. Guest demo scan from welcome still works (5 local demo scans), server still 402s direct guest API calls.
3. Guest demo scan from welcome still works (5 AI demo scans), direct guest health-check and semantic-search API calls still 402.
4. Existing pro account: no paywall anywhere, unchanged manage view under Profile → billing.
5. Login as existing free user: lands in tabs (no hard-paywall redirect), sees credit badge in scanner.
6. Dark mode: slides, questions, personalizing, paywall, sheet all render with dark tokens (compare `_dark_mode` mockups).
@@ -1501,5 +1501,5 @@ git commit -m "docs: mark onboarding/soft-paywall/free-tier spec as implemented"
## Self-review notes (already applied)
- Spec coverage: welcome ✓(T10) slides ✓(T11) questions ✓(T12) personalizing ✓(T13) paywall ✓(T7/8) sign-up/login ✓(T14) out-of-credits ✓(T5/6) free tier ✓(T1-3) soft gating ✓(T4/6) credits badge ✓(T6 step 6) analytics ✓(inline). Deviations from spec, both agreed-level "Kleinigkeiten": `customize.tsx` stays out of the chain (health-check is step 4), and sign-up shows no personal name (no name is collected).
- Guest safety: T3 keeps guests blocked server-side (`ensureNotGuest`) — required because `getOrCreateAccount(db, 'guest')` would otherwise mint a shared free account.
- Guest safety: T3 keeps guests blocked server-side for non-demo endpoints (`ensureNotGuest`) — required because `getOrCreateAccount(db, 'guest')` would otherwise mint a shared free account.
- Type consistency: `PreAuthOnboardingService` keys (`acquisitionSource`/`primaryGoal`/`experienceLevel`) match `OnboardingProgressService` setters; `OutOfCreditsSheet` props match the T6 call site; paywall param names (`view`, `context`) consistent across T6/T7/T13.

View File

@@ -20,7 +20,7 @@ Welcome (social proof)
→ App (tabs), free plan with 3 credits/month
```
- The guest **demo scan stays** available from the welcome screen (existing `isGuest` server handling unchanged).
- The guest **demo scan stays** available from the welcome screen, but it must use the same AI identification path as a normal scan. Demo scans must never return local hash/mock plant results or fake confidence values.
- Existing users: "Log in" link on the welcome screen → login.
- Paywall ✕ during onboarding → continues to sign-up. Paywall ✕ in-app → simply closes.
@@ -46,10 +46,11 @@ Known mockup fixes (agreed): replace AI-artifact photos (garbled phone UI on sca
## 3. Free tier (backend, `server/lib/billing.js` + `server/index.js`)
- `FREE_MONTHLY_CREDITS: 0 → 3`. Existing monthly-allowance reset logic already covers free accounts; verify reset works for plan `free`.
- **Remove server hard paywall:** drop `ensureActiveProEntitlement` from scan and health-check endpoints (`server/index.js`). Credit consumption becomes the only gate; 0 credits → existing 402 `INSUFFICIENT_CREDITS` error.
- **Same scan model for free and pro** (`getScanModel` returns the pro model for both plans). Cost accepted (~cents/user/month at 3 scans).
- **Low-confidence AI review pass stays pro-only** (unchanged) so a free scan never burns 2 credits, and Pro keeps a quality edge.
- Trial handling unchanged (yearly plan carries 7-day trial, 30 credits during trial).
- **Remove server hard paywall:** drop `ensureActiveProEntitlement` from scan and health-check endpoints (`server/index.js`). Credit consumption becomes the only gate; 0 credits → existing 402 `INSUFFICIENT_CREDITS` error.
- **Same scan model for free and pro** (`getScanModel` returns the pro model for both plans). Cost accepted (~cents/user/month at 3 scans).
- **Low-confidence AI review pass stays pro-only** (unchanged) so a free scan never burns 2 credits, and Pro keeps a quality edge.
- **Guest demo scans use AI identification without consuming account credits.** Guests remain blocked from health checks and semantic search, but `/v1/scan` may run the primary identification model for the limited pre-auth demo experience.
- Trial handling unchanged (yearly plan carries 7-day trial, 30 credits during trial).
## 4. Soft paywall (app)

231
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "greenlens",
"version": "2.2.7",
"version": "2.2.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "greenlens",
"version": "2.2.7",
"version": "2.2.9",
"hasInstallScript": true,
"dependencies": {
"@expo/vector-icons": "^15.0.3",
@@ -107,6 +107,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1560,7 +1561,7 @@
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@egjs/hammerjs": {
@@ -2341,7 +2342,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
"integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/types": "^29.6.3",
@@ -2359,7 +2360,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
"integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/console": "^29.7.0",
@@ -2407,7 +2408,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -2432,7 +2433,7 @@
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz",
"integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -2457,7 +2458,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
"integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"expect": "^29.7.0",
@@ -2471,7 +2472,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
"integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"jest-get-type": "^29.6.3"
@@ -2501,7 +2502,7 @@
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
"integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -2511,7 +2512,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
"integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/environment": "^29.7.0",
@@ -2527,7 +2528,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
"integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^0.2.3",
@@ -2571,7 +2572,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -2583,7 +2584,7 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -2604,7 +2605,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz",
"integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"dependencies": {
"@babel/core": "^7.23.9",
@@ -2621,7 +2622,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -2634,7 +2635,7 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2647,7 +2648,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -2672,7 +2673,7 @@
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
"integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.18",
@@ -2687,7 +2688,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
"integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/console": "^29.7.0",
@@ -2703,7 +2704,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
"integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/test-result": "^29.7.0",
@@ -3388,6 +3389,7 @@
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"peer": true,
"dependencies": {
"merge-options": "^3.0.4"
},
@@ -3734,6 +3736,7 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz",
"integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-navigation/core": "^7.14.0",
"escape-string-regexp": "^4.0.0",
@@ -3843,7 +3846,7 @@
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz",
"integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"jest-matcher-utils": "^30.0.5",
@@ -3870,7 +3873,7 @@
"version": "30.0.5",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz",
"integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.34.0"
@@ -3883,14 +3886,14 @@
"version": "0.34.48",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz",
"integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@testing-library/react-native/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -3903,7 +3906,7 @@
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz",
"integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/diff-sequences": "30.0.1",
@@ -3919,7 +3922,7 @@
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz",
"integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/get-type": "30.1.0",
@@ -3935,7 +3938,7 @@
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz",
"integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "30.0.5",
@@ -3950,7 +3953,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tootallnate/once": {
@@ -4079,8 +4082,9 @@
"version": "19.1.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4781,6 +4785,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4900,7 +4905,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4958,7 +4963,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
"integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -5024,7 +5029,7 @@
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/cli-cursor": {
@@ -5096,7 +5101,7 @@
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
"integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"iojs": ">= 1.0.0",
@@ -5107,7 +5112,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
"integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/color": {
@@ -5286,7 +5291,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
"integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/types": "^29.6.3",
@@ -5468,7 +5473,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
@@ -5532,7 +5537,7 @@
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
"integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
@@ -5658,7 +5663,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
"integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5674,7 +5679,7 @@
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
@@ -5843,7 +5848,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
"integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -5893,7 +5898,7 @@
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
@@ -5903,7 +5908,7 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/error-stack-parser": {
@@ -6082,7 +6087,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
@@ -6106,7 +6111,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6116,7 +6121,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
@@ -6132,7 +6137,7 @@
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
"integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -6141,7 +6146,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
"integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/expect-utils": "^29.7.0",
@@ -6159,6 +6164,7 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.23",
@@ -6325,6 +6331,7 @@
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@expo/config": "~12.0.13",
"@expo/env": "~2.0.8"
@@ -6434,6 +6441,7 @@
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
"integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"expo": "*",
"react-native": "*"
@@ -6444,6 +6452,7 @@
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
"integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==",
"license": "MIT",
"peer": true,
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
@@ -6516,6 +6525,7 @@
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
"integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==",
"license": "MIT",
"peer": true,
"dependencies": {
"expo-constants": "~18.0.12",
"invariant": "^2.2.4"
@@ -6530,6 +6540,7 @@
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
"license": "MIT",
"peer": true,
"dependencies": {
"rtl-detect": "^1.0.2"
},
@@ -7465,7 +7476,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -7710,7 +7721,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/http-errors": {
@@ -7787,7 +7798,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
@@ -7860,7 +7871,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
"integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"pkg-dir": "^4.2.0",
@@ -7889,7 +7900,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8013,7 +8024,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
"integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -8101,7 +8112,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8179,7 +8190,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
@@ -8194,7 +8205,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
"integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"dependencies": {
"debug": "^4.1.1",
@@ -8209,7 +8220,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -8219,7 +8230,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
@@ -8248,8 +8259,9 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -8275,7 +8287,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz",
"integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"execa": "^5.0.0",
@@ -8290,7 +8302,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
"integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/environment": "^29.7.0",
@@ -8322,7 +8334,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz",
"integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/core": "^29.7.0",
@@ -8356,7 +8368,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz",
"integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.11.6",
@@ -8402,7 +8414,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -8414,7 +8426,7 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -8435,7 +8447,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -8448,7 +8460,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8461,7 +8473,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
"integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.0.0",
@@ -8477,7 +8489,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz",
"integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"detect-newline": "^3.0.0"
@@ -8490,7 +8502,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
"integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/types": "^29.6.3",
@@ -8622,7 +8634,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
"integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"jest-get-type": "^29.6.3",
@@ -8636,7 +8648,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz",
"integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.0.0",
@@ -8686,7 +8698,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz",
"integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -8713,7 +8725,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz",
"integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.0.0",
@@ -8734,7 +8746,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz",
"integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"jest-regex-util": "^29.6.3",
@@ -8748,7 +8760,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz",
"integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/console": "^29.7.0",
@@ -8781,7 +8793,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -8791,7 +8803,7 @@
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
"integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
@@ -8802,7 +8814,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz",
"integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/environment": "^29.7.0",
@@ -8836,7 +8848,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -8848,7 +8860,7 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -8869,7 +8881,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -8882,7 +8894,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
"integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.11.6",
@@ -8914,7 +8926,7 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -9103,7 +9115,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz",
"integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/test-result": "^29.7.0",
@@ -9287,7 +9299,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/json-schema-traverse": {
@@ -9844,7 +9856,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
@@ -9860,7 +9872,7 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -10329,7 +10341,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -10431,7 +10443,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/negotiator": {
@@ -10548,7 +10560,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
@@ -10855,7 +10867,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
@@ -11046,7 +11058,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
"integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"find-up": "^4.0.0"
@@ -11313,7 +11325,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -11397,6 +11409,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11437,6 +11450,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -11473,6 +11487,7 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.81.5",
@@ -11530,6 +11545,7 @@
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0",
@@ -11555,6 +11571,7 @@
"resolved": "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-9.10.5.tgz",
"integrity": "sha512-ofs33aTgdjyjUDZgCtCKYfvl4gBtJrOV77/SXyDzSxTttNjg+8BWGckF3ALfBg69uU65xrXsqfyU3U+Cl86IdQ==",
"license": "MIT",
"peer": true,
"workspaces": [
"examples/purchaseTesterTypescript",
"react-native-purchases-ui"
@@ -11627,6 +11644,7 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -11637,6 +11655,7 @@
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
"integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-freeze": "^1.0.0",
"react-native-is-edge-to-edge": "^1.2.1",
@@ -11652,6 +11671,7 @@
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz",
"integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
@@ -11667,6 +11687,7 @@
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
@@ -11808,6 +11829,7 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11885,8 +11907,9 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"react-is": "^19.1.0",
"scheduler": "^0.26.0"
@@ -11899,7 +11922,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
@@ -12039,7 +12062,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
"integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"resolve-from": "^5.0.0"
@@ -12655,7 +12678,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
"integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"char-regex": "^1.0.2",
@@ -12669,7 +12692,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -12769,7 +12792,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
"integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12779,7 +12802,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -12789,7 +12812,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
@@ -13439,7 +13462,7 @@
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",

View File

@@ -204,11 +204,11 @@ const resolveIdempotencyKey = (request) => {
return '';
};
const ensureNotGuest = (userId, requiredCredits) => {
// Guests use the client-side demo scan; the shared 'guest' billing account
// must never consume real credits or run free AI analyses.
if (isGuest(userId)) {
const error = new Error('Sign in to use scan credits.');
const ensureNotGuest = (userId, requiredCredits) => {
// Guests may use the limited pre-auth demo identification scan. Other
// endpoints must not consume credits from the shared 'guest' account.
if (isGuest(userId)) {
const error = new Error('Sign in to use scan credits.');
error.code = 'INSUFFICIENT_CREDITS';
error.status = 402;
error.metadata = { required: requiredCredits, available: 0 };
@@ -726,17 +726,20 @@ app.post('/v1/scan', async (request, response) => {
let modelUsed = null;
let modelFallbackCount = 0;
const [accountSnapshot, catalogEntries] = await Promise.all([
getAccountSnapshot(db, userId),
getCachedCatalogEntries(db),
]);
ensureNotGuest(userId, SCAN_PRIMARY_COST);
creditsCharged += await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('scan-primary', userId, idempotencyKey),
SCAN_PRIMARY_COST,
);
const [accountSnapshot, catalogEntries] = await Promise.all([
getAccountSnapshot(db, userId),
getCachedCatalogEntries(db),
]);
if (isGuest(userId)) {
modelPath.push('guest-demo-no-credit');
} else {
creditsCharged += await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('scan-primary', userId, idempotencyKey),
SCAN_PRIMARY_COST,
);
}
// Free tier gets the same model quality; quantity (3 credits/month) is the differentiator.
const scanPlan = 'pro';
@@ -764,8 +767,14 @@ app.post('/v1/scan', async (request, response) => {
usedOpenAi = true;
modelUsed = openAiPrimary.modelUsed || modelUsed;
modelPath.push('openai-primary');
if (grounded.grounded) modelPath.push('catalog-grounded-primary');
} else {
if (grounded.grounded) modelPath.push('catalog-grounded-primary');
} else {
if (isGuest(userId)) {
const error = new Error('AI demo scan failed. Please try again with a clearer plant photo.');
error.code = 'PROVIDER_ERROR';
error.status = 502;
throw error;
}
console.warn(`OpenAI primary identification returned null for user ${userId} — using catalog fallback.`, {
attemptedModels: openAiPrimary?.attemptedModels,
plant: result?.name,
@@ -773,8 +782,14 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-primary-failed');
modelPath.push('catalog-primary-fallback');
}
} else {
console.log(`OpenAI not configured, using catalog fallback for user ${userId}`);
} else {
if (isGuest(userId)) {
const error = new Error('AI demo scan is unavailable. Please configure OpenAI before enabling guest demo scans.');
error.code = 'PROVIDER_ERROR';
error.status = 502;
throw error;
}
console.log(`OpenAI not configured, using catalog fallback for user ${userId}`);
modelPath.push('openai-not-configured');
modelPath.push('catalog-primary-fallback');
}

View File

@@ -29,7 +29,7 @@ import { IdentificationResult, PlantHealthCheck } from '../../types';
const MOCK_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
const FREE_MONTHLY_CREDITS = 0;
const FREE_MONTHLY_CREDITS = 3;
const GUEST_TRIAL_CREDITS = 0;
const TRIAL_MONTHLY_CREDITS = 30;
const PRO_MONTHLY_CREDITS = 100;
@@ -827,38 +827,56 @@ export const mockBackendService = {
};
}
let creditsCharged = 0;
const modelPath: string[] = [];
creditsCharged += consumeCreditsWithIdempotency(
account,
stores.idempotency,
chargeKey('scan-primary', request.userId, request.idempotencyKey),
SCAN_PRIMARY_COST,
);
let creditsCharged = 0;
const modelPath: string[] = [];
if (request.userId === 'guest') {
modelPath.push('guest-demo-no-credit');
} else {
creditsCharged += consumeCreditsWithIdempotency(
account,
stores.idempotency,
chargeKey('scan-primary', request.userId, request.idempotencyKey),
SCAN_PRIMARY_COST,
);
}
let usedOpenAi = false;
let result: IdentificationResult = getMockPlantByImage(request.imageUri, request.language, false);
if (openAiScanService.isConfigured()) {
const openAiPrimary = await openAiScanService.identifyPlant(
request.imageUri,
request.language,
'primary',
account.plan === 'pro' ? 'pro' : 'free',
);
if (openAiPrimary) {
result = openAiPrimary;
usedOpenAi = true;
modelPath.push('openai-primary');
} else {
result = getMockPlantByImage(request.imageUri, request.language, false);
modelPath.push('openai-primary-failed');
modelPath.push('mock-primary-fallback');
}
} else {
modelPath.push('mock-primary');
}
if (openAiScanService.isConfigured()) {
const openAiPrimary = await openAiScanService.identifyPlant(
request.imageUri,
request.language,
'primary',
'pro',
);
if (openAiPrimary) {
result = openAiPrimary;
usedOpenAi = true;
modelPath.push('openai-primary');
} else {
if (request.userId === 'guest') {
throw new BackendApiError(
'PROVIDER_ERROR',
'AI demo scan failed. Please try again with a clearer plant photo.',
502,
);
}
result = getMockPlantByImage(request.imageUri, request.language, false);
modelPath.push('openai-primary-failed');
modelPath.push('mock-primary-fallback');
}
} else {
if (request.userId === 'guest') {
throw new BackendApiError(
'PROVIDER_ERROR',
'AI demo scan is unavailable. Please configure OpenAI before enabling guest demo scans.',
502,
);
}
modelPath.push('mock-primary');
}
const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD;
if (shouldReview && account.plan === 'pro') {

View File

@@ -7,6 +7,7 @@ export type PreAuthAnswers = {
acquisitionSource?: string;
primaryGoal?: string;
experienceLevel?: string;
lightLevel?: string;
};
export const PreAuthOnboardingService = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -0,0 +1,14 @@
Ready for email delivery
To send:
1. Use the boards below as copy for each platform.
2. QR Master image files:
- C:\Users\timo\Documents\greenlens\Greenlens\social_out\qrmaster_slides\slide_qr_1_dynamic.png
- C:\Users\timo\Documents\greenlens\Greenlens\social_out\qrmaster_slides\slide_qr_2_analytics.png
- C:\Users\timo\Documents\greenlens\Greenlens\social_out\qrmaster_slides\slide_qr_3_menu_update.png
3. GreenLens Pro image files:
- C:\Users\timo\Documents\greenlens\Greenlens\social_out\greenlens_slides\slide_greenlens_1_cover.png
Text:
- C:\Users\timo\Documents\greenlens\Greenlens\social_out\qrmaster_posts\qrmaster_posts_en.txt
- C:\Users\timo\Documents\greenlens\Greenlens\social_out\greenlens_posts\greenlens_posts_en.txt

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,37 @@
# QR Master — Social Posts
## TikTok / Instagram / Facebook / X
### Post A — Hook focused
Most QR codes are disposable.
One wrong print run means reprinting stickers, flyers and signs.
QR Master lets you keep the same QR code and change the destination any time.
No new artwork. No press shop. No waste.
One code, endless updates.
Perfect for menus, events, shop windows and campaigns.
Stop printing forever. Start managing smarter:
qrmaster.net
### Post B — Analytics focused
Dont just count scans.
Understand when people scan and where traffic drops off.
QR Master shows simple scan analytics inside your dashboard.
Track peaks. Spot time windows. Decide smarter.
Great for restaurants, clubs, pop-ups and retail.
Demo first, decide later:
qrmaster.net
### Post C — Use case: restaurant menu
Restaurants change menus constantly.
QR codes on tables shouldnt.
With QR Master:
- Same code stays on tables
- Update the menu URL in seconds
- Track which hours get the most scans
You focus on food. We keep the code working forever.
Try it on your menu today:
qrmaster.net

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -526,7 +526,7 @@ registerToSave: "Sign up to save",
welcomeHeadline: "Plant care\nstarts here",
welcomeSubheadline: "Scan a leaf, learn the plant, keep it healthy.",
welcomeFeatureIdentifyTitle: "AI plant identification",
welcomeFeatureIdentifyDesc: "Try up to 5 demo scans on this device.",
welcomeFeatureIdentifyDesc: "Try up to 3 demo scans on this device.",
welcomeFeatureReminderTitle: "Care plan & reminders",
welcomeFeatureReminderDesc: "Get clear guidance for water, light, and placement.",
welcomeFeatureLibraryTitle: "Save your plants",
@@ -798,7 +798,7 @@ registerToSave: "Regístrate para guardar",
welcomeHeadline: "El cuidado\nempieza aquí",
welcomeSubheadline: "Escanea una hoja, conoce la planta y mantenla sana.",
welcomeFeatureIdentifyTitle: "Identificación con IA",
welcomeFeatureIdentifyDesc: "Prueba hasta 5 escaneos demo en este dispositivo.",
welcomeFeatureIdentifyDesc: "Prueba hasta 3 escaneos demo en este dispositivo.",
welcomeFeatureReminderTitle: "Plan de cuidado y recordatorios",
welcomeFeatureReminderDesc: "Recibe consejos claros sobre riego, luz y ubicación.",
welcomeFeatureLibraryTitle: "Guardar tus plantas",