Onboarding

This commit is contained in:
2026-05-08 13:00:30 +02:00
parent d37b49f1f6
commit 9386ae1be7
37 changed files with 5606 additions and 2275 deletions

View File

@@ -0,0 +1,40 @@
jest.mock('../../server/lib/postgres', () => ({
get: jest.fn(),
run: jest.fn(),
}));
const { get, run } = require('../../server/lib/postgres');
const { deleteAccount, signUp } = require('../../server/lib/auth');
describe('server auth account deletion', () => {
beforeEach(() => {
jest.clearAllMocks();
get.mockResolvedValue(null);
run.mockResolvedValue({ lastId: null, changes: 1, rows: [] });
});
it('removes auth and billing rows so the same email can sign up again', async () => {
const email = 'same@example.com';
await signUp({}, email, 'First User', 'password-1');
await deleteAccount({}, 'usr_deleted');
await signUp({}, email, 'Second User', 'password-2');
const authDeletes = run.mock.calls.filter(([, sql]) => (
typeof sql === 'string' && sql.includes('DELETE FROM auth_users')
));
expect(authDeletes).toHaveLength(1);
const billingAccountDeletes = run.mock.calls.filter(([, sql]) => (
typeof sql === 'string' && sql.includes('DELETE FROM billing_accounts')
));
expect(billingAccountDeletes).toHaveLength(1);
const signupChecks = get.mock.calls.filter(([, sql], params) => (
typeof sql === 'string'
&& sql.includes('SELECT id FROM auth_users WHERE LOWER(email)')
&& params?.[0] === email
));
expect(signupChecks).toHaveLength(2);
});
});

View File

@@ -1,5 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { mockBackendService } from '../../services/backend/mockBackendService'; import { mockBackendService } from '../../services/backend/mockBackendService';
import { openAiScanService } from '../../services/backend/openAiScanService';
jest.mock('@react-native-async-storage/async-storage', () => ({ jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(), getItem: jest.fn(),
@@ -28,6 +29,32 @@ const runScan = async (userId: string, idempotencyKey: string) => {
return settled.value; return settled.value;
}; };
const runHealthCheck = async (userId: string, idempotencyKey: string) => {
const settledPromise = mockBackendService.healthCheck({
userId,
idempotencyKey,
imageUri: `data:image/jpeg;base64,${idempotencyKey}`,
language: 'en',
plantContext: {
name: 'Monstera',
botanicalName: 'Monstera deliciosa',
careInfo: {
waterIntervalDays: 7,
light: 'Bright indirect light',
temp: '18-24C',
},
},
}).then(
value => ({ ok: true as const, value }),
error => ({ ok: false as const, error }),
);
await Promise.resolve();
await jest.runAllTimersAsync();
const settled = await settledPromise;
if (!settled.ok) throw settled.error;
return settled.value;
};
describe('mockBackendService billing simulation', () => { describe('mockBackendService billing simulation', () => {
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
@@ -50,6 +77,7 @@ describe('mockBackendService billing simulation', () => {
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers();
jest.restoreAllMocks();
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@@ -142,6 +170,40 @@ describe('mockBackendService billing simulation', () => {
expect(second.billing.credits.available).toBe(first.billing.credits.available); expect(second.billing.credits.available).toBe(first.billing.credits.available);
}); });
it('charges one credit for a normal scan after a two-credit health check', async () => {
const userId = 'test-user-health-then-scan-cost';
jest.spyOn(openAiScanService, 'isConfigured').mockReturnValue(true);
jest.spyOn(openAiScanService, 'analyzePlantHealth').mockResolvedValue({
overallHealthScore: 72,
status: 'watch',
analysisSummary: 'Mild stress signs are visible.',
likelyIssues: [
{
title: 'Watering stress',
confidence: 0.62,
details: 'The leaf texture suggests inconsistent watering.',
},
],
actionsNow: ['Check soil moisture before watering.'],
plan7Days: ['Take a comparison photo in one week.'],
});
await mockBackendService.simulatePurchase({
userId,
idempotencyKey: 'sub-health-then-scan-cost',
productId: 'monthly_pro',
});
const healthCheck = await runHealthCheck(userId, 'health-cost-1');
expect(healthCheck.creditsCharged).toBe(2);
expect(healthCheck.billing.credits.usedThisCycle).toBe(2);
const scan = await runScan(userId, 'scan-after-health-cost-1');
expect(scan.modelPath).toContain('mock-review');
expect(scan.creditsCharged).toBe(1);
expect(scan.billing.credits.usedThisCycle).toBe(3);
});
it('blocks free users from real scans', async () => { it('blocks free users from real scans', async () => {
const userId = 'test-user-credit-limit'; const userId = 'test-user-credit-limit';
let successfulScans = 0; let successfulScans = 0;

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "GreenLens", "name": "GreenLens",
"slug": "greenlens", "slug": "greenlens",
"version": "2.2.3", "version": "2.2.4",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@@ -19,7 +19,7 @@
"supportsTablet": true, "supportsTablet": true,
"usesAppleSignIn": true, "usesAppleSignIn": true,
"bundleIdentifier": "com.greenlens.app", "bundleIdentifier": "com.greenlens.app",
"buildNumber": "37", "buildNumber": "38",
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "GreenLens needs camera access to identify plants.", "NSCameraUsageDescription": "GreenLens needs camera access to identify plants.",
"NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.", "NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.",
@@ -32,7 +32,7 @@
"backgroundColor": "#111813" "backgroundColor": "#111813"
}, },
"package": "com.greenlens.app", "package": "com.greenlens.app",
"versionCode": 4, "versionCode": 5,
"permissions": [ "permissions": [
"android.permission.CAMERA", "android.permission.CAMERA",
"android.permission.RECORD_AUDIO" "android.permission.RECORD_AUDIO"
@@ -47,6 +47,17 @@
"plugins": [ "plugins": [
"expo-dev-client", "expo-dev-client",
"expo-router", "expo-router",
[
"expo-share-intent",
{
"iosActivationRules": {
"NSExtensionActivationSupportsImageWithMaxCount": 1
},
"androidIntentFilters": ["image/*"],
"iosShareExtensionName": "GreenLens Share",
"iosAppGroupIdentifier": "group.com.greenlens.app"
}
],
"expo-camera", "expo-camera",
"expo-apple-authentication", "expo-apple-authentication",
"expo-image-picker", "expo-image-picker",

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Animated, AppState, Easing, Image, StyleSheet, Text, View } from 'react-native'; import { Animated, AppState, Easing, Image, StyleSheet, Text, View } from 'react-native';
import { Redirect, Stack, usePathname } from 'expo-router'; import { Redirect, Stack, usePathname, useRouter } from 'expo-router';
import { useShareIntent } from 'expo-share-intent';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import Purchases, { LOG_LEVEL } from 'react-native-purchases'; import Purchases, { LOG_LEVEL } from 'react-native-purchases';
@@ -68,6 +69,8 @@ function RootLayoutInner() {
} = useApp(); } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent();
const [installCheckDone, setInstallCheckDone] = useState(false); const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false); const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo'); const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
@@ -156,6 +159,25 @@ function RootLayoutInner() {
}, [signOut]); }, [signOut]);
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants; const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
useEffect(() => {
if (!hasShareIntent || !isAppReady) return;
const sharedImage = shareIntent.files?.find((file) => file.mimeType?.startsWith('image/'));
if (!sharedImage) {
resetShareIntent();
return;
}
const uri = sharedImage.path;
if (!uri) {
resetShareIntent();
return;
}
resetShareIntent();
router.push({
pathname: '/scanner',
params: { sharedImageUri: uri },
});
}, [hasShareIntent, shareIntent, resetShareIntent, router, isAppReady]);
const hasActiveEntitlement = isActivatingEntitlement const hasActiveEntitlement = isActivatingEntitlement
|| (billingSummary?.entitlement?.plan === 'pro' || (billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active'); && billingSummary?.entitlement?.status === 'active');
@@ -202,7 +224,7 @@ function RootLayoutInner() {
); );
} }
} else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) { } else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
content = <Redirect href="/profile/billing" />; content = <Redirect href="/onboarding" />;
} else { } else {
content = ( content = (
<> <>

View File

@@ -16,16 +16,23 @@ import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native'; import { usePostHog } from 'posthog-react-native';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function LoginScreen() { export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog(); const posthog = usePostHog();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -112,7 +119,7 @@ export default function LoginScreen() {
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
} }
posthog.capture('apple_login_succeeded', { surface: 'login' }); posthog.capture('apple_login_succeeded', { surface: 'login' });
router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)'); router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') { if (e?.code === 'ERR_REQUEST_CANCELED') {
return; return;
@@ -131,10 +138,9 @@ export default function LoginScreen() {
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]} style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ThemeBackdrop colors={colors} />
<ScrollView <ScrollView
contentContainerStyle={styles.scroll} contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -142,6 +148,12 @@ export default function LoginScreen() {
> >
{/* Logo / Header */} {/* Logo / Header */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Image <Image
source={require('../../assets/icon.png')} source={require('../../assets/icon.png')}
style={styles.logoIcon} style={styles.logoIcon}
@@ -278,10 +290,21 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
marginBottom: 32, marginBottom: 32,
}, },
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
logoIcon: { logoIcon: {
width: 56, width: 84,
height: 56, height: 84,
borderRadius: 14, borderRadius: 20,
marginBottom: 16, marginBottom: 16,
}, },
appName: { appName: {

View File

@@ -15,18 +15,25 @@ import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native'; import { usePostHog } from 'posthog-react-native';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function SignupScreen() { export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog(); const posthog = usePostHog();
const pendingPlant = getPendingPlant(); const pendingPlant = getPendingPlant();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -78,7 +85,7 @@ export default function SignupScreen() {
await hydrateSession(session); await hydrateSession(session);
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen // Flag setzen: Tour beim nächsten App-Öffnen anzeigen
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/profile/billing'); router.replace('/onboarding/source');
} catch (e: any) { } catch (e: any) {
if (e.message === 'EMAIL_TAKEN') { if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken); setError(t.errEmailTaken);
@@ -127,7 +134,7 @@ export default function SignupScreen() {
await hydrateSession(session); await hydrateSession(session);
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
posthog.capture('apple_login_succeeded', { surface: 'signup' }); posthog.capture('apple_login_succeeded', { surface: 'signup' });
router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)'); router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') { if (e?.code === 'ERR_REQUEST_CANCELED') {
return; return;
@@ -146,10 +153,9 @@ export default function SignupScreen() {
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]} style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ThemeBackdrop colors={colors} />
<ScrollView <ScrollView
contentContainerStyle={styles.scroll} contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -392,9 +398,9 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
logoIcon: { logoIcon: {
width: 56, width: 84,
height: 56, height: 84,
borderRadius: 14, borderRadius: 20,
marginBottom: 16, marginBottom: 16,
}, },
appName: { appName: {

View File

@@ -1,154 +1,165 @@
import React, { useEffect, useRef } from 'react'; import React from 'react';
import { import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
Dimensions,
Image, Image,
ImageBackground,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import Svg, { Path } from 'react-native-svg';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { ThemeBackdrop } from '../components/ThemeBackdrop';
const { height: SCREEN_H, width: SCREEN_W } = Dimensions.get('window'); type Feature = {
icon: keyof typeof Ionicons.glyphMap;
title: string;
description: string;
};
export default function OnboardingScreen() { export default function OnboardingScreen() {
const { isDarkMode, colorPalette, t } = useApp(); const { t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const { height, width } = useWindowDimensions();
const compact = height < 760;
const sheetTop = compact ? 142 : 156;
const waveHeight = compact ? 148 : 170;
const bodyOffset = waveHeight - 2;
const contentTop = compact ? 94 : 108;
const FEATURES = [ const features: Feature[] = [
{ icon: 'camera-outline' as const, label: t.onboardingFeatureScan }, {
{ icon: 'notifications-outline' as const, label: t.onboardingFeatureReminder }, icon: 'scan-outline',
{ icon: 'book-outline' as const, label: t.onboardingFeatureLexicon }, title: t.welcomeFeatureIdentifyTitle,
description: t.welcomeFeatureIdentifyDesc,
},
{
icon: 'notifications-outline',
title: t.welcomeFeatureReminderTitle,
description: t.welcomeFeatureReminderDesc,
},
{
icon: 'book-outline',
title: t.welcomeFeatureLibraryTitle,
description: t.welcomeFeatureLibraryDesc,
},
]; ];
// Entrance animations
const logoAnim = useRef(new Animated.Value(0)).current;
const logoScale = useRef(new Animated.Value(0.85)).current;
const featuresAnim = useRef(new Animated.Value(0)).current;
const buttonsAnim = useRef(new Animated.Value(0)).current;
const featureAnims = useRef(FEATURES.map(() => new Animated.Value(0))).current;
useEffect(() => {
Animated.sequence([
Animated.parallel([
Animated.timing(logoAnim, { toValue: 1, duration: 700, useNativeDriver: true }),
Animated.spring(logoScale, { toValue: 1, tension: 50, friction: 8, useNativeDriver: true }),
]),
Animated.stagger(100, featureAnims.map(anim =>
Animated.timing(anim, { toValue: 1, duration: 400, useNativeDriver: true })
)),
Animated.timing(buttonsAnim, { toValue: 1, duration: 400, useNativeDriver: true }),
]).start();
}, []);
return ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <View style={styles.container}>
<ThemeBackdrop colors={colors} /> <ImageBackground
source={require('../assets/welcome_botanical_hero.png')}
{/* Logo-Bereich */} style={styles.heroImage}
<Animated.View imageStyle={styles.heroImageContent}
style={[ resizeMode="cover"
styles.heroSection,
{ opacity: logoAnim, transform: [{ scale: logoScale }] },
]}
> >
<View style={[styles.iconContainer, { shadowColor: colors.primary }]}> <View style={styles.heroShadeTop} />
<Image <View style={styles.heroShadeBottom} />
source={require('../assets/icon.png')} <SafeAreaView style={styles.safeArea}>
style={styles.appIcon} <View style={[styles.brandRow, compact && styles.brandRowCompact]}>
resizeMode="cover" <Image
source={require('../assets/icon.png')}
style={styles.logo}
resizeMode="cover"
/>
<Text style={styles.brandName}>
Green<Text style={styles.brandAccent}>Lens</Text>
</Text>
</View>
</SafeAreaView>
</ImageBackground>
<View style={[styles.sheet, { top: sheetTop }]}>
<Svg
width={width}
height={waveHeight}
viewBox={`0 0 ${width} ${waveHeight}`}
preserveAspectRatio="none"
style={styles.sheetWave}
>
<Path
d={`M0 34 C ${width * 0.08} 76 ${width * 0.14} 82 ${width * 0.24} 82 C ${width * 0.38} 82 ${width * 0.52} 82 ${width * 0.64} 82 C ${width * 0.78} 86 ${width * 0.88} 132 ${width} 156 L ${width} ${waveHeight} L 0 ${waveHeight} Z`}
fill="#fbfaf3"
/> />
</View> </Svg>
<View style={[styles.sheetBody, { top: bodyOffset }]} />
<View
style={[
styles.sheetContent,
{ top: contentTop },
compact && styles.sheetContentCompact,
]}
>
<Text style={[styles.headline, compact && styles.headlineCompact]}>
{t.welcomeHeadline}
</Text>
<Text style={styles.subheadline}>{t.welcomeSubheadline}</Text>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text> <View style={styles.features}>
<Text style={[styles.tagline, { color: colors.textSecondary }]}> {features.map((feature, index) => (
{t.onboardingTagline} <View
</Text> key={feature.title}
</Animated.View> style={[
styles.featureRow,
index === features.length - 1 && styles.featureRowLast,
]}
>
<View style={styles.featureIcon}>
<Ionicons name={feature.icon} size={22} color="#a6d66f" />
</View>
<View style={styles.featureCopy}>
<Text style={styles.featureTitle}>{feature.title}</Text>
<Text style={styles.featureDescription}>{feature.description}</Text>
</View>
</View>
))}
</View>
{/* Feature-Liste */} <TouchableOpacity
<View style={styles.featuresSection}> style={styles.demoButton}
{FEATURES.map((feat, i) => ( onPress={() => router.push('/scanner')}
<Animated.View activeOpacity={0.86}
key={feat.label}
style={[
styles.featureRow,
{
backgroundColor: colors.surface + '88', // Semi-transparent for backdrop effect
borderColor: colors.border,
opacity: featureAnims[i],
transform: [{
translateY: featureAnims[i].interpolate({
inputRange: [0, 1],
outputRange: [20, 0],
}),
}],
},
]}
> >
<View style={[styles.featureIcon, { backgroundColor: colors.primary + '15' }]}> <Ionicons name="scan" size={25} color="#f8f7ef" />
<Ionicons name={feat.icon} size={18} color={colors.primary} /> <Text style={styles.demoButtonText}>{t.welcomeDemoScan}</Text>
</View> <Ionicons name="chevron-forward" size={26} color="#f8f7ef" />
<Text style={[styles.featureText, { color: colors.text }]}>{feat.label}</Text> </TouchableOpacity>
</Animated.View>
))} <View style={styles.authRow}>
<TouchableOpacity
style={styles.authButton}
onPress={() => router.push('/auth/signup')}
activeOpacity={0.82}
>
<Text style={styles.authButtonText}>{t.onboardingRegister}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.authButton, styles.loginButton]}
onPress={() => router.push('/auth/login')}
activeOpacity={0.82}
>
<Text style={[styles.authButtonText, styles.loginButtonText]}>
{t.onboardingLogin}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.subscriptionLink}
onPress={() => router.push('/profile/billing')}
activeOpacity={0.8}
>
<Ionicons name="leaf-outline" size={21} color="#4b7c31" />
<Text style={styles.subscriptionText}>{t.welcomeSubscriptionPlans}</Text>
<Ionicons name="chevron-forward" size={20} color="#4b7c31" />
</TouchableOpacity>
<Text style={styles.legalText}>{t.welcomeLegal}</Text>
</View>
</View> </View>
{/* Buttons */}
<Animated.View style={[styles.buttonsSection, { opacity: buttonsAnim }]}>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary }]}
onPress={() => router.push('/scanner')}
activeOpacity={0.85}
>
<Ionicons name="scan" size={20} color={colors.onPrimary} />
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>
{t.onboardingScanBtn}
</Text>
</TouchableOpacity>
<View style={styles.authActions}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.primary, backgroundColor: colors.surface }]}
onPress={() => router.push('/auth/signup')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.primary }]}>
{t.onboardingRegister}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => router.push('/auth/login')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
{t.onboardingLogin}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.plansBtn, { borderColor: colors.primary }]}
onPress={() => router.push('/profile/billing')}
activeOpacity={0.82}
>
<Ionicons name="pricetag-outline" size={16} color={colors.primary} />
<Text style={[styles.plansBtnText, { color: colors.primary }]}>
View Subscription Plans & Pricing
</Text>
</TouchableOpacity>
<Text style={[styles.disclaimer, { color: colors.textMuted }]}>
{t.onboardingDisclaimer}
</Text>
</Animated.View>
</View> </View>
); );
} }
@@ -156,123 +167,201 @@ export default function OnboardingScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
paddingHorizontal: 32, backgroundColor: '#0a110b',
paddingTop: SCREEN_H * 0.12,
paddingBottom: 40,
}, },
heroSection: { heroImage: {
alignItems: 'center', height: '60%',
marginBottom: 40, minHeight: 430,
}, },
iconContainer: { heroImageContent: {
width: 120, backgroundColor: '#0a110b',
height: 120, transform: [{ scale: 1.04 }],
borderRadius: 28,
backgroundColor: '#fff',
elevation: 8,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
marginBottom: 24,
overflow: 'hidden',
}, },
appIcon: { heroShadeTop: {
width: '100%', ...StyleSheet.absoluteFillObject,
height: '100%', backgroundColor: 'rgba(0,0,0,0.08)',
}, },
appName: { heroShadeBottom: {
fontSize: 40, position: 'absolute',
fontWeight: '900', left: 0,
letterSpacing: -1.5, right: 0,
marginBottom: 4, bottom: 0,
height: 190,
backgroundColor: 'rgba(7,12,7,0.2)',
}, },
tagline: { safeArea: {
fontSize: 17,
fontWeight: '500',
opacity: 0.8,
},
featuresSection: {
gap: 8,
flex: 1, flex: 1,
justifyContent: 'center', },
brandRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 15,
paddingHorizontal: 30,
paddingTop: 62,
},
brandRowCompact: {
paddingTop: 42,
},
logo: {
width: 68,
height: 68,
borderRadius: 16,
backgroundColor: '#fff',
},
brandName: {
color: '#f8f7ef',
fontSize: 36,
fontWeight: '900',
},
brandAccent: {
color: '#9bc76e',
},
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
},
sheetWave: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
},
sheetBody: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#fbfaf3',
},
sheetContent: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 24,
paddingBottom: 10,
},
sheetContentCompact: {
paddingHorizontal: 22,
paddingBottom: 8,
},
headline: {
color: '#101c12',
fontSize: 40,
lineHeight: 43,
fontWeight: '900',
marginBottom: 6,
maxWidth: 310,
},
headlineCompact: {
fontSize: 34,
lineHeight: 37,
},
subheadline: {
color: '#5f625d',
fontSize: 15,
lineHeight: 19,
fontWeight: '500',
marginBottom: 11,
},
features: {
marginBottom: 10,
}, },
featureRow: { featureRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 12, gap: 11,
paddingHorizontal: 16, paddingVertical: 6,
paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth,
borderRadius: 16, borderBottomColor: 'rgba(16,28,18,0.14)',
borderWidth: 1, },
featureRowLast: {
borderBottomWidth: 0,
}, },
featureIcon: { featureIcon: {
width: 36, width: 46,
height: 36, height: 46,
borderRadius: 10, borderRadius: 10,
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#173817',
}, },
featureText: { featureCopy: {
flex: 1, flex: 1,
},
featureTitle: {
color: '#101c12',
fontSize: 16,
fontWeight: '800',
marginBottom: 2,
},
featureDescription: {
color: '#696b65',
fontSize: 13, fontSize: 13,
fontWeight: '600', lineHeight: 16,
letterSpacing: 0.1, fontWeight: '500',
}, },
buttonsSection: { demoButton: {
gap: 16, height: 60,
marginTop: 20, borderRadius: 7,
}, backgroundColor: '#437824',
primaryBtn: {
height: 58,
borderRadius: 20,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
gap: 12, justifyContent: 'space-between',
elevation: 4, paddingHorizontal: 18,
shadowColor: '#000', marginBottom: 8,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
}, },
primaryBtnText: { demoButtonText: {
color: '#f8f7ef',
fontSize: 21,
fontWeight: '800',
},
authRow: {
flexDirection: 'row',
gap: 8,
marginBottom: 8,
},
authButton: {
flex: 1,
height: 50,
borderRadius: 7,
borderWidth: 1.4,
borderColor: '#4b7c31',
alignItems: 'center',
justifyContent: 'center',
},
loginButton: {
borderColor: '#101c12',
},
authButtonText: {
color: '#4b7c31',
fontSize: 17, fontSize: 17,
fontWeight: '700', fontWeight: '700',
}, },
authActions: { loginButtonText: {
color: '#101c12',
},
subscriptionLink: {
minHeight: 24,
flexDirection: 'row', flexDirection: 'row',
gap: 12,
},
secondaryBtn: {
flex: 1,
height: 54,
borderRadius: 20,
borderWidth: 1.5,
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
},
secondaryBtnText: {
fontSize: 15,
fontWeight: '600',
},
plansBtn: {
height: 48,
borderRadius: 16,
borderWidth: 1.5,
flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center',
gap: 8, gap: 8,
marginBottom: 7,
}, },
plansBtnText: { subscriptionText: {
fontSize: 14, color: '#4b7c31',
fontWeight: '600', fontSize: 15,
}, fontWeight: '800',
disclaimer: { textAlign: 'center',
fontSize: 12, },
legalText: {
color: '#6b6d68',
fontSize: 11,
lineHeight: 14,
fontWeight: '500',
textAlign: 'center', textAlign: 'center',
opacity: 0.6,
marginTop: 8,
}, },
}); });

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -9,18 +9,61 @@ import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService'; import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const EXPERIENCE_OPTIONS = [ const EXPERIENCE_OPTIONS = [
{ id: 'beginner', icon: 'leaf-outline' as const }, { id: 'beginner', icon: 'leaf-outline' as const },
{ id: 'intermediate', icon: 'sunny-outline' as const }, { id: 'intermediate', icon: 'sunny-outline' as const },
{ id: 'advanced', icon: 'flask-outline' as const }, { id: 'advanced', icon: 'flask-outline' as const },
]; ];
const getExperienceScreenCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 3 von 4',
heroBadge: 'Pflege-Tiefe',
subtitles: {
beginner: 'Klare Sprache, sichere Defaults, weniger Fachbegriffe.',
intermediate: 'Praktische Schritte mit genug Kontext.',
advanced: 'Mehr botanische Details und engere Diagnose.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 3 de 4',
heroBadge: 'Nivel de cuidado',
subtitles: {
beginner: 'Lenguaje claro y recomendaciones seguras.',
intermediate: 'Pasos practicos con suficiente contexto.',
advanced: 'Mas detalle botanico y diagnostico preciso.',
},
};
}
return {
step: 'Step 3 of 4',
heroBadge: 'Care depth',
subtitles: {
beginner: 'Clear language, fewer assumptions, safer defaults.',
intermediate: 'Practical care steps with enough detail.',
advanced: 'More botanical context and tighter diagnosis.',
},
};
};
export default function OnboardingExperienceScreen() { export default function OnboardingExperienceScreen() {
const router = useRouter(); const router = useRouter();
const posthog = usePostHog(); const posthog = usePostHog();
const { session, isDarkMode, colorPalette, t } = useApp(); const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedLevel, setSelectedLevel] = useState<string | null>(null); const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
const copy = getExperienceScreenCopy(language);
const levelLabels = useMemo( const levelLabels = useMemo(
() => ({ () => ({
@@ -39,17 +82,29 @@ export default function OnboardingExperienceScreen() {
posthog.capture('onboarding_experience_completed', { posthog.capture('onboarding_experience_completed', {
experience_level: level ?? 'skipped', experience_level: level ?? 'skipped',
}); });
router.replace('/(tabs)'); router.replace('/onboarding/health-check');
}; };
return ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <View style={[styles.container, { backgroundColor: screenBackground }]}>
<ThemeBackdrop colors={colors} /> {isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}> <View style={styles.header}>
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}> <View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="sparkles-outline" size={26} color={colors.primaryDark} /> <Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View> </View>
<ImageBackground
source={require('../../assets/onboarding_experience_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.4)' : 'rgba(251, 250, 243, 0.24)' }]} />
<View style={[styles.heroMetric, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} />
<Text style={[styles.heroMetricText, { color: colors.text }]}>{copy.heroBadge}</Text>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.experienceOnboardingTitle}</Text> <Text style={[styles.title, { color: colors.text }]}>{t.experienceOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.experienceOnboardingSubtitle}</Text> <Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.experienceOnboardingSubtitle}</Text>
</View> </View>
@@ -73,7 +128,12 @@ export default function OnboardingExperienceScreen() {
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}> <View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} /> <Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View> </View>
<Text style={[styles.optionLabel, { color: colors.text }]}>{levelLabels[option.id as keyof typeof levelLabels]}</Text> <View style={styles.optionCopy}>
<Text style={[styles.optionLabel, { color: colors.text }]}>{levelLabels[option.id as keyof typeof levelLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />} {isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
</TouchableOpacity> </TouchableOpacity>
); );
@@ -104,26 +164,35 @@ export default function OnboardingExperienceScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }, safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 },
header: { alignItems: 'center', gap: 10, marginBottom: 28 }, header: { alignItems: 'center', gap: 9, marginBottom: 14 },
headerIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' }, stepPill: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
title: { fontSize: 28, fontWeight: '800', textAlign: 'center', lineHeight: 32 }, stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
subtitle: { fontSize: 14, textAlign: 'center', lineHeight: 20, maxWidth: 320 }, heroPreview: { width: '100%', height: 175, borderRadius: 24, borderWidth: 1, overflow: 'hidden', justifyContent: 'flex-end', alignItems: 'flex-start' },
options: { gap: 12, flex: 1 }, heroImage: { borderRadius: 24 },
heroOverlay: { ...StyleSheet.absoluteFillObject },
heroMetric: { margin: 12, borderRadius: 999, borderWidth: 1, paddingHorizontal: 11, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 6 },
heroMetricText: { fontSize: 12, fontWeight: '800' },
title: { fontSize: 25, fontWeight: '800', textAlign: 'center', lineHeight: 29 },
subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 18, maxWidth: 320 },
options: { gap: 8, flex: 1 },
optionCard: { optionCard: {
minHeight: 64, flex: 1,
borderRadius: 18, borderRadius: 15,
borderWidth: 1.5, borderWidth: 1.5,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 14,
gap: 12, paddingVertical: 16,
gap: 10,
}, },
optionIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, optionIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
optionLabel: { flex: 1, fontSize: 15, fontWeight: '600' }, optionCopy: { flex: 1, gap: 3 },
footer: { flexDirection: 'row', gap: 12, marginTop: 16 }, optionLabel: { fontSize: 14, fontWeight: '700' },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, optionSubtitle: { fontSize: 10.5, lineHeight: 14 },
footer: { flexDirection: 'row', gap: 12, marginTop: 10 },
secondaryBtn: { flex: 1, height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' }, secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.2, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, primaryBtn: { flex: 1.2, height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' }, primaryBtnText: { fontSize: 15, fontWeight: '700' },
}); });

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -9,6 +9,11 @@ import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService'; import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const GOAL_OPTIONS = [ const GOAL_OPTIONS = [
{ id: 'identify', icon: 'scan-outline' as const }, { id: 'identify', icon: 'scan-outline' as const },
{ id: 'care', icon: 'water-outline' as const }, { id: 'care', icon: 'water-outline' as const },
@@ -16,12 +21,53 @@ const GOAL_OPTIONS = [
{ id: 'learn', icon: 'book-outline' as const }, { id: 'learn', icon: 'book-outline' as const },
]; ];
const getGoalScreenCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 2 von 4',
heroBadge: 'Erstes Ziel',
subtitles: {
identify: 'Schnell erkennen, Pflege danach klaeren.',
care: 'Aus Symptomen konkrete Schritte machen.',
collection: 'Eine saubere Pflanzenbibliothek aufbauen.',
learn: 'Pflanzenwissen einfacher einsortieren.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 2 de 4',
heroBadge: 'Primer objetivo',
subtitles: {
identify: 'Respuesta rapida primero, cuidado despues.',
care: 'Convertir sintomas en pasos claros.',
collection: 'Crear una biblioteca de plantas ordenada.',
learn: 'Aprender plantas con explicaciones simples.',
},
};
}
return {
step: 'Step 2 of 4',
heroBadge: 'First goal',
subtitles: {
identify: 'Fast answer first, care details after.',
care: 'Turn symptoms into a clear next step.',
collection: 'Build a tidy plant library over time.',
learn: 'Browse plants with simpler explanations.',
},
};
};
export default function OnboardingGoalScreen() { export default function OnboardingGoalScreen() {
const router = useRouter(); const router = useRouter();
const posthog = usePostHog(); const posthog = usePostHog();
const { session, isDarkMode, colorPalette, t } = useApp(); const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedGoal, setSelectedGoal] = useState<string | null>(null); const [selectedGoal, setSelectedGoal] = useState<string | null>(null);
const copy = getGoalScreenCopy(language);
const goalLabels = useMemo( const goalLabels = useMemo(
() => ({ () => ({
@@ -45,13 +91,25 @@ export default function OnboardingGoalScreen() {
}; };
return ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <View style={[styles.container, { backgroundColor: screenBackground }]}>
<ThemeBackdrop colors={colors} /> {isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}> <View style={styles.header}>
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}> <View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="flag-outline" size={26} color={colors.primaryDark} /> <Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View> </View>
<ImageBackground
source={require('../../assets/onboarding_goal_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.22)' : 'rgba(251, 250, 243, 0.28)' }]} />
<View style={[styles.heroBadge, { backgroundColor: colors.primary }]}>
<Ionicons name="flag-outline" size={16} color={colors.onPrimary} />
<Text style={[styles.heroBadgeText, { color: colors.onPrimary }]}>{copy.heroBadge}</Text>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.goalOnboardingTitle}</Text> <Text style={[styles.title, { color: colors.text }]}>{t.goalOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.goalOnboardingSubtitle}</Text> <Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.goalOnboardingSubtitle}</Text>
</View> </View>
@@ -75,7 +133,12 @@ export default function OnboardingGoalScreen() {
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}> <View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} /> <Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View> </View>
<Text style={[styles.optionLabel, { color: colors.text }]}>{goalLabels[option.id as keyof typeof goalLabels]}</Text> <View style={styles.optionCopy}>
<Text style={[styles.optionLabel, { color: colors.text }]}>{goalLabels[option.id as keyof typeof goalLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />} {isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
</TouchableOpacity> </TouchableOpacity>
); );
@@ -106,26 +169,35 @@ export default function OnboardingGoalScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }, safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 },
header: { alignItems: 'center', gap: 10, marginBottom: 28 }, header: { alignItems: 'center', gap: 9, marginBottom: 14 },
headerIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' }, stepPill: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
title: { fontSize: 28, fontWeight: '800', textAlign: 'center', lineHeight: 32 }, stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
subtitle: { fontSize: 14, textAlign: 'center', lineHeight: 20, maxWidth: 320 }, heroPreview: { width: '100%', height: 175, borderRadius: 24, borderWidth: 1, overflow: 'hidden', justifyContent: 'flex-end', alignItems: 'flex-start' },
options: { gap: 12, flex: 1 }, heroImage: { borderRadius: 24 },
heroOverlay: { ...StyleSheet.absoluteFillObject },
heroBadge: { margin: 12, borderRadius: 999, paddingHorizontal: 11, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 6 },
heroBadgeText: { fontSize: 12, fontWeight: '800' },
title: { fontSize: 25, fontWeight: '800', textAlign: 'center', lineHeight: 29 },
subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 18, maxWidth: 320 },
options: { gap: 8, flex: 1 },
optionCard: { optionCard: {
minHeight: 64, flex: 1,
borderRadius: 18, borderRadius: 15,
borderWidth: 1.5, borderWidth: 1.5,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 14,
gap: 12, paddingVertical: 12,
gap: 10,
}, },
optionIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, optionIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
optionLabel: { flex: 1, fontSize: 15, fontWeight: '600' }, optionCopy: { flex: 1, gap: 3 },
footer: { flexDirection: 'row', gap: 12, marginTop: 16 }, optionLabel: { fontSize: 14, fontWeight: '700' },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, optionSubtitle: { fontSize: 10.5, lineHeight: 14 },
footer: { flexDirection: 'row', gap: 12, marginTop: 10 },
secondaryBtn: { flex: 1, height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' }, secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.2, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, primaryBtn: { flex: 1.2, height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' }, primaryBtnText: { fontSize: 15, fontWeight: '700' },
}); });

View File

@@ -0,0 +1,202 @@
import React from 'react';
import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { usePostHog } from 'posthog-react-native';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 4 von 4',
title: 'Wo ist der Health-Scan?',
subtitle: 'Du findest ihn auf jeder gespeicherten Pflanze, direkt unter der Beschreibung.',
buttonPreview: 'Health-Scan starten',
cta: 'Weiter',
skip: 'Spaeter',
flow: ['Pflanze scannen', 'Speichern', 'Detailseite oeffnen', 'Health-Scan starten'],
outputTitle: 'Was du danach bekommst',
outputs: [
'Gesundheits-Score mit Status: stabil, beobachten oder kritisch.',
'Ausfuehrliche Analyse mit sichtbaren Hinweisen und Unsicherheit.',
'Wahrscheinlichste Ursachen mit Confidence-Werten.',
'Sofortmassnahmen plus konkreter 7-Tage-Pflegeplan.',
],
guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.',
};
}
if (language === 'es') {
return {
step: 'Paso 4 de 4',
title: 'Donde esta el health-scan?',
subtitle: 'Lo encuentras en cada planta guardada, justo debajo de la descripcion.',
buttonPreview: 'Iniciar health-scan',
cta: 'Continuar',
skip: 'Mas tarde',
flow: ['Escanear planta', 'Guardar', 'Abrir detalle', 'Iniciar health-scan'],
outputTitle: 'Que recibes despues',
outputs: [
'Puntaje de salud con estado: estable, observar o critico.',
'Analisis detallado con senales visibles e incertidumbre.',
'Causas probables con valores de confianza.',
'Acciones inmediatas y plan concreto de 7 dias.',
],
guidanceNote: 'Consejo: fotografia la planta completa, el reverso de las hojas y el sustrato. Cuanto mas clara sea la foto, mas preciso sera el plan.',
};
}
return {
step: 'Step 4 of 4',
title: 'Where is the health scan?',
subtitle: 'It lives on every saved plant, directly below the plant description.',
buttonPreview: 'Start health scan',
cta: 'Continue',
skip: 'Later',
flow: ['Scan plant', 'Save', 'Open detail', 'Start health scan'],
outputTitle: 'What you get after',
outputs: [
'Health score with stable, watch, or critical status.',
'Detailed analysis with visible signals and uncertainty.',
'Most likely causes with confidence values.',
'Immediate actions plus a concrete 7-day care plan.',
],
guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.',
};
};
export default function HealthCheckOnboardingScreen() {
const router = useRouter();
const posthog = usePostHog();
const { isDarkMode, colorPalette, language, billingSummary } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const copy = getHealthOnboardingCopy(language);
const finish = (skipped = false) => {
posthog.capture('onboarding_health_check_explained', {
skipped,
plan: billingSummary?.entitlement?.plan ?? 'free',
});
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
router.replace(hasActiveEntitlement ? '/(tabs)' : '/profile/billing');
};
return (
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{copy.subtitle}</Text>
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<ImageBackground
source={require('../../assets/onboarding_health_scan_mockup.png')}
style={[styles.illustration, { borderColor: colors.border }]}
imageStyle={styles.illustrationImage}
resizeMode="cover"
>
<View style={[styles.illustrationOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.08)' : 'rgba(251, 250, 243, 0.04)' }]} />
</ImageBackground>
<View style={[styles.flowCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{copy.flow.map((item, index) => (
<View key={item} style={styles.flowRow}>
<View style={[styles.flowIndex, { backgroundColor: index === 3 ? colors.primary : colors.surfaceMuted }]}>
<Text style={[styles.flowIndexText, { color: index === 3 ? colors.onPrimary : colors.textMuted }]}>
{index + 1}
</Text>
</View>
<Text style={[styles.flowText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
<View style={[styles.outputCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.outputTitle, { color: colors.text }]}>{copy.outputTitle}</Text>
{copy.outputs.map((item) => (
<View key={item} style={styles.outputRow}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={[styles.outputText, { color: colors.textSecondary }]}>{item}</Text>
</View>
))}
</View>
<View style={[styles.guidanceCard, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="camera-outline" size={18} color={colors.primaryDark} />
<Text style={[styles.guidanceText, { color: colors.primaryDark }]}>{copy.guidanceNote}</Text>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => finish(true)}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{copy.skip}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={() => finish(false)}>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.cta}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
header: { gap: 9, marginBottom: 18 },
stepPill: { alignSelf: 'flex-start', borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
title: { fontSize: 30, lineHeight: 34, fontWeight: '900' },
subtitle: { fontSize: 14, lineHeight: 20 },
content: { gap: 14, paddingBottom: 12 },
illustration: { height: 230, borderRadius: 28, borderWidth: 1, justifyContent: 'center', overflow: 'hidden' },
illustrationImage: { borderRadius: 28 },
illustrationOverlay: { ...StyleSheet.absoluteFillObject },
phone: { width: 178, minHeight: 156, borderRadius: 26, borderWidth: 1, padding: 12, gap: 10, marginLeft: 16 },
phoneHeader: { height: 58, borderRadius: 18, justifyContent: 'flex-end', padding: 10 },
phoneTitle: { fontSize: 13, fontWeight: '800' },
phoneRows: { gap: 8 },
phoneRowLong: { height: 8, borderRadius: 999 },
phoneRowShort: { width: '66%', height: 8, borderRadius: 999 },
healthButtonPreview: { height: 34, borderRadius: 14, borderWidth: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 },
healthButtonText: { fontSize: 10, fontWeight: '800' },
scanCard: { position: 'absolute', right: 16, bottom: 20, width: 136, borderRadius: 20, borderWidth: 1, padding: 14, gap: 7 },
scanScore: { fontSize: 25, lineHeight: 29, fontWeight: '900' },
scanLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase' },
scanLine: { height: 8, borderRadius: 999 },
scanLineShort: { width: '68%', height: 8, borderRadius: 999 },
flowCard: { borderRadius: 18, borderWidth: 1, padding: 14, gap: 10 },
flowRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
flowIndex: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' },
flowIndexText: { fontSize: 12, fontWeight: '900' },
flowText: { flex: 1, fontSize: 14, fontWeight: '700' },
outputCard: { borderRadius: 18, borderWidth: 1, padding: 16, gap: 11 },
outputTitle: { fontSize: 15, fontWeight: '800' },
outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 },
outputText: { flex: 1, fontSize: 13, lineHeight: 18 },
guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' },
footer: { flexDirection: 'row', gap: 12, marginTop: 12 },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' },
});

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -9,21 +9,82 @@ import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService'; import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const SOURCE_OPTIONS = [ const SOURCE_OPTIONS = [
{ id: 'app_store', icon: 'phone-portrait-outline' as const }, { id: 'app_store', icon: 'storefront-outline' as const, signal: 'organic_store' },
{ id: 'instagram', icon: 'logo-instagram' as const }, { id: 'instagram', icon: 'logo-instagram' as const, signal: 'social_visual' },
{ id: 'tiktok', icon: 'musical-notes-outline' as const }, { id: 'tiktok', icon: 'musical-notes-outline' as const, signal: 'social_video' },
{ id: 'friend', icon: 'people-outline' as const }, { id: 'friend', icon: 'people-outline' as const, signal: 'referral' },
{ id: 'search', icon: 'search-outline' as const }, { id: 'search', icon: 'search-outline' as const, signal: 'high_intent_search' },
{ id: 'other', icon: 'ellipsis-horizontal-circle-outline' as const }, { id: 'other', icon: 'ellipsis-horizontal-circle-outline' as const, signal: 'unclassified' },
]; ];
const getSourceOnboardingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 1 von 4',
heroTitle: 'Dein Start wird danach personalisiert.',
heroMeta: 'Scan, Sammlung und Health-Check passen sich deinem Ziel an.',
valueTitle: 'Warum wir fragen',
valueBody: 'Die Antwort hilft, deinen Einstieg auf das auszurichten, was dich wirklich hierher gebracht hat.',
subtitles: {
app_store: 'Du hast aktiv nach Pflanzen- oder Pflegehilfe gesucht.',
instagram: 'Du kamst ueber visuelle Pflanzen-Inhalte.',
tiktok: 'Du kamst ueber kurze Videos oder Creator.',
friend: 'Persoenliche Empfehlung, hoher Vertrauens-Intent.',
search: 'Konkretes Problem oder schneller Pflanzen-Check.',
other: 'Passt nicht sauber in die anderen Quellen.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 1 de 4',
heroTitle: 'Tu inicio se adapta despues.',
heroMeta: 'Escaneo, coleccion y health-check segun tu objetivo.',
valueTitle: 'Por que preguntamos',
valueBody: 'La respuesta ayuda a adaptar el inicio a lo que realmente te trajo aqui.',
subtitles: {
app_store: 'Buscaste ayuda para plantas o cuidado.',
instagram: 'Llegaste desde contenido visual de plantas.',
tiktok: 'Llegaste desde videos cortos o creadores.',
friend: 'Recomendacion personal con alta confianza.',
search: 'Problema concreto o chequeo rapido.',
other: 'No encaja en las demas fuentes.',
},
};
}
return {
step: 'Step 1 of 4',
heroTitle: 'Your first run adapts next.',
heroMeta: 'Scanner, collection, and health check based on your goal.',
valueTitle: 'Why we ask',
valueBody: 'This helps tailor the first steps to what actually brought you here.',
subtitles: {
app_store: 'You actively searched for plant or care help.',
instagram: 'You came from visual plant content.',
tiktok: 'You came from short videos or creators.',
friend: 'Personal referral with high trust intent.',
search: 'Concrete problem or quick plant check intent.',
other: 'Does not fit the other sources cleanly.',
},
};
};
export default function OnboardingSourceScreen() { export default function OnboardingSourceScreen() {
const router = useRouter(); const router = useRouter();
const posthog = usePostHog(); const posthog = usePostHog();
const { session, isDarkMode, colorPalette, t } = useApp(); const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedSource, setSelectedSource] = useState<string | null>(null); const [selectedSource, setSelectedSource] = useState<string | null>(null);
const copy = getSourceOnboardingCopy(language);
const sourceLabels = useMemo( const sourceLabels = useMemo(
() => ({ () => ({
@@ -51,18 +112,40 @@ export default function OnboardingSourceScreen() {
posthog.capture('onboarding_source_completed', { posthog.capture('onboarding_source_completed', {
source: source ?? 'skipped', source: source ?? 'skipped',
revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped',
}); });
router.replace('/onboarding/goal'); router.replace('/onboarding/goal');
}; };
return ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <View style={[styles.container, { backgroundColor: screenBackground }]}>
<ThemeBackdrop colors={colors} /> {isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}> <View style={styles.header}>
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}> <View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="paper-plane-outline" size={26} color={colors.primaryDark} /> <Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View> </View>
<ImageBackground
source={require('../../assets/onboarding_source_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.46)' : 'rgba(251, 250, 243, 0.32)' }]} />
<View style={styles.heroContent}>
<View style={[styles.heroIcon, { backgroundColor: colors.primary }]}>
<Ionicons name="scan-outline" size={20} color={colors.onPrimary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: isDarkMode ? colors.textOnImage : colors.text }]}>
{copy.heroTitle}
</Text>
<Text style={[styles.heroMeta, { color: isDarkMode ? '#d7ded9' : colors.textSecondary }]}>
{copy.heroMeta}
</Text>
</View>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.sourceOnboardingTitle}</Text> <Text style={[styles.title, { color: colors.text }]}>{t.sourceOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.sourceOnboardingSubtitle}</Text> <Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.sourceOnboardingSubtitle}</Text>
</View> </View>
@@ -86,8 +169,13 @@ export default function OnboardingSourceScreen() {
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}> <View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} /> <Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View> </View>
<Text style={[styles.optionLabel, { color: colors.text }]}>{sourceLabels[option.id as keyof typeof sourceLabels]}</Text> <View style={styles.optionCopy}>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />} <Text style={[styles.optionLabel, { color: colors.text }]}>{sourceLabels[option.id as keyof typeof sourceLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} style={styles.optionCheck} />}
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
@@ -130,66 +218,123 @@ const styles = StyleSheet.create({
safeArea: { safeArea: {
flex: 1, flex: 1,
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 24, paddingTop: 12,
paddingBottom: 20, paddingBottom: 14,
justifyContent: 'space-between',
}, },
header: { header: {
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 9,
marginBottom: 28,
}, },
headerIcon: { stepPill: {
width: 64, borderWidth: 1,
height: 64, borderRadius: 999,
borderRadius: 32, paddingHorizontal: 12,
paddingVertical: 7,
},
stepLabel: {
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
heroPreview: {
width: '100%',
height: 175,
borderRadius: 24,
borderWidth: 1,
justifyContent: 'flex-end',
overflow: 'hidden',
},
heroImage: {
borderRadius: 24,
},
heroOverlay: {
...StyleSheet.absoluteFillObject,
},
heroContent: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 12,
padding: 12,
},
heroIcon: {
width: 36,
height: 36,
borderRadius: 14,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
heroCopy: {
flex: 1,
gap: 3,
},
heroTitle: {
fontSize: 15,
lineHeight: 18,
fontWeight: '800',
},
heroMeta: {
fontSize: 10.5,
lineHeight: 14,
fontWeight: '600',
},
title: { title: {
fontSize: 28, fontSize: 25,
fontWeight: '800', fontWeight: '800',
textAlign: 'center', textAlign: 'center',
lineHeight: 32, lineHeight: 29,
}, },
subtitle: { subtitle: {
fontSize: 14, fontSize: 13,
textAlign: 'center', textAlign: 'center',
lineHeight: 20, lineHeight: 18,
maxWidth: 320, maxWidth: 320,
}, },
options: { options: {
gap: 12, flexDirection: 'row',
flex: 1, flexWrap: 'wrap',
gap: 8,
}, },
optionCard: { optionCard: {
minHeight: 64, width: '48.8%',
borderRadius: 18, minHeight: 68,
borderRadius: 15,
borderWidth: 1.5, borderWidth: 1.5,
flexDirection: 'row', padding: 9,
alignItems: 'center', gap: 8,
paddingHorizontal: 16, position: 'relative',
gap: 12,
}, },
optionIcon: { optionIcon: {
width: 36, width: 34,
height: 36, height: 34,
borderRadius: 18, borderRadius: 17,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
optionCopy: {
gap: 3,
},
optionLabel: { optionLabel: {
flex: 1, fontSize: 13,
fontSize: 15, fontWeight: '700',
fontWeight: '600', },
optionSubtitle: {
fontSize: 10,
lineHeight: 13,
},
optionCheck: {
position: 'absolute',
right: 9,
top: 9,
}, },
footer: { footer: {
flexDirection: 'row', flexDirection: 'row',
gap: 12, gap: 12,
marginTop: 16,
}, },
secondaryBtn: { secondaryBtn: {
flex: 1, flex: 1,
height: 52, height: 50,
borderRadius: 16, borderRadius: 16,
borderWidth: 1.5, borderWidth: 1.5,
alignItems: 'center', alignItems: 'center',
@@ -201,7 +346,7 @@ const styles = StyleSheet.create({
}, },
primaryBtn: { primaryBtn: {
flex: 1.2, flex: 1.2,
height: 52, height: 50,
borderRadius: 16, borderRadius: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',

View File

@@ -58,9 +58,10 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') { if (language === 'de') {
return { return {
title: 'Health Check', title: 'Health Check',
action: 'Neues Foto + Health-Check', action: 'Health-Scan starten',
running: 'Neues Foto wird analysiert...', running: 'Neues Foto wird analysiert...',
cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`, cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`,
intro: 'Fotografiere die ganze Pflanze plus auffaellige Blaetter. Danach bekommst du Diagnose, Dringlichkeit und einen konkreten Pflegeplan.',
creditsLabel: 'Credits', creditsLabel: 'Credits',
managePlan: 'Plan verwalten', managePlan: 'Plan verwalten',
noCreditsTitle: 'Nicht genug Credits', noCreditsTitle: 'Nicht genug Credits',
@@ -68,7 +69,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
insufficientInline: 'Nicht genug Credits fuer den Health-Check.', insufficientInline: 'Nicht genug Credits fuer den Health-Check.',
timeoutInline: 'Health-Check Timeout. Bitte erneut versuchen.', timeoutInline: 'Health-Check Timeout. Bitte erneut versuchen.',
providerInline: 'Health-Check ist gerade nicht verfuegbar.', providerInline: 'Health-Check ist gerade nicht verfuegbar.',
issuesTitle: 'Moegliche Ursachen', analysisTitle: 'Analyse',
analysisFallback: 'Die Pflanze wirkt insgesamt beurteilbar, aber die gespeicherte Analyse enthaelt noch keine ausformulierte Zusammenfassung. Orientiere dich deshalb an Score, Ursachen und Sofortmassnahmen. Pruefe zuerst die auffaelligsten Blaetter, danach Substratfeuchte und Standort. Wenn die Blaetter innerhalb von 48 Stunden weiter haengen, gelb werden oder Flecken ausbreiten, solltest du ein neues Foto bei hellem indirektem Licht aufnehmen. Ein neuer Health-Scan kann dann genauer zwischen Wasserstress, Lichtstress, Schaedlingen und normaler Blattalterung unterscheiden.',
issuesTitle: 'Wahrscheinlichste Ursachen',
actionsTitle: 'Sofortmassnahmen', actionsTitle: 'Sofortmassnahmen',
planTitle: '7-Tage-Plan', planTitle: '7-Tage-Plan',
scoreLabel: 'Gesundheits-Score', scoreLabel: 'Gesundheits-Score',
@@ -82,9 +85,10 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'es') { if (language === 'es') {
return { return {
title: 'Health Check', title: 'Health Check',
action: 'Foto nuevo + Health-check', action: 'Iniciar health-scan',
running: 'Analizando foto nueva...', running: 'Analizando foto nueva...',
cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`, cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`,
intro: 'Fotografia la planta completa y las hojas llamativas. Luego recibes diagnostico, urgencia y un plan de cuidado concreto.',
creditsLabel: 'Creditos', creditsLabel: 'Creditos',
managePlan: 'Gestionar plan', managePlan: 'Gestionar plan',
noCreditsTitle: 'Creditos insuficientes', noCreditsTitle: 'Creditos insuficientes',
@@ -92,7 +96,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
insufficientInline: 'No hay creditos suficientes para el health-check.', insufficientInline: 'No hay creditos suficientes para el health-check.',
timeoutInline: 'Health-check agotado por tiempo. Intenta de nuevo.', timeoutInline: 'Health-check agotado por tiempo. Intenta de nuevo.',
providerInline: 'Health-check no disponible ahora.', providerInline: 'Health-check no disponible ahora.',
issuesTitle: 'Posibles causas', analysisTitle: 'Analisis',
analysisFallback: 'La planta se puede evaluar en general, pero este chequeo guardado todavia no contiene un resumen completo. Usa el puntaje, las causas y las acciones inmediatas como guia principal. Revisa primero las hojas mas llamativas, despues la humedad del sustrato y la ubicacion. Si las hojas empeoran en 48 horas, amarillean o las manchas se expanden, toma una foto nueva con luz indirecta clara. Un nuevo health-scan podra diferenciar mejor entre exceso o falta de agua, luz, plagas y envejecimiento normal.',
issuesTitle: 'Causas mas probables',
actionsTitle: 'Acciones inmediatas', actionsTitle: 'Acciones inmediatas',
planTitle: 'Plan de 7 dias', planTitle: 'Plan de 7 dias',
scoreLabel: 'Puntaje de salud', scoreLabel: 'Puntaje de salud',
@@ -105,9 +111,10 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
return { return {
title: 'Health Check', title: 'Health Check',
action: 'New Photo + Health Check', action: 'Start health scan',
running: 'Analyzing new photo...', running: 'Analyzing new photo...',
cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`, cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`,
intro: 'Photograph the full plant plus any suspicious leaves. You will get a diagnosis, urgency level, and a concrete care plan.',
creditsLabel: 'Credits', creditsLabel: 'Credits',
managePlan: 'Manage plan', managePlan: 'Manage plan',
noCreditsTitle: 'Not enough credits', noCreditsTitle: 'Not enough credits',
@@ -115,7 +122,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
insufficientInline: 'Not enough credits for the health check.', insufficientInline: 'Not enough credits for the health check.',
timeoutInline: 'Health check timed out. Please try again.', timeoutInline: 'Health check timed out. Please try again.',
providerInline: 'Health check is unavailable right now.', providerInline: 'Health check is unavailable right now.',
issuesTitle: 'Likely issues', analysisTitle: 'Analysis',
analysisFallback: 'The plant is still assessable, but this saved check does not include a full written summary yet. Use the score, likely causes, and immediate actions as the primary guide. Start by inspecting the most unusual leaves, then check soil moisture and placement. If leaves droop further, yellowing spreads, or spots expand within 48 hours, take a new photo in bright indirect light. A fresh health scan can separate watering stress, light stress, pests, and normal leaf aging more accurately.',
issuesTitle: 'Most likely causes',
actionsTitle: 'Actions now', actionsTitle: 'Actions now',
planTitle: '7-day plan', planTitle: '7-day plan',
scoreLabel: 'Health score', scoreLabel: 'Health score',
@@ -243,6 +252,9 @@ export default function PlantDetailScreen() {
: colors.danger : colors.danger
) )
: colors.textMuted; : colors.textMuted;
const latestAnalysisSummary = latestHealthCheck
? latestHealthCheck.analysisSummary || healthCopy.analysisFallback
: '';
const timelineEntries = useMemo(() => { const timelineEntries = useMemo(() => {
const history = plant.wateringHistory && plant.wateringHistory.length > 0 const history = plant.wateringHistory && plant.wateringHistory.length > 0
@@ -577,7 +589,7 @@ export default function PlantDetailScreen() {
<View style={styles.healthActionRow}> <View style={styles.healthActionRow}>
<View style={styles.healthActionInfo}> <View style={styles.healthActionInfo}>
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text> <Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.cost}</Text> <Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.intro}</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[ style={[
@@ -642,6 +654,15 @@ export default function PlantDetailScreen() {
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)} {healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
</Text> </Text>
{latestAnalysisSummary ? (
<View style={[styles.healthAnalysisBox, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.analysisTitle}</Text>
<Text style={[styles.healthAnalysisText, { color: colors.textSecondary }]}>
{latestAnalysisSummary}
</Text>
</View>
) : null}
<View style={styles.healthListBlock}> <View style={styles.healthListBlock}>
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.issuesTitle}</Text> <Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.issuesTitle}</Text>
{latestHealthCheck.likelyIssues.map((issue, index) => ( {latestHealthCheck.likelyIssues.map((issue, index) => (
@@ -1113,6 +1134,16 @@ const styles = StyleSheet.create({
healthListBlock: { healthListBlock: {
gap: 8, gap: 8,
}, },
healthAnalysisBox: {
borderRadius: 16,
borderWidth: 1,
padding: 12,
gap: 6,
},
healthAnalysisText: {
fontSize: 12,
lineHeight: 19,
},
healthListTitle: { healthListTitle: {
fontSize: 13, fontSize: 13,
fontWeight: '700', fontWeight: '700',

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler } from 'react-native'; import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, useWindowDimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@@ -23,6 +23,9 @@ type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>; type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>; type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>; type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
type PaywallPlanId = 'weekly' | 'yearly';
const PAYWALL_BACKGROUND = require('../../assets/paywall_scan_background.png');
const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = { const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = {
topup_small: 30, topup_small: 30,
@@ -93,6 +96,18 @@ const getBillingCopy = (language: Language) => {
paywallTitle: 'Vollstaendige Diagnose freischalten', paywallTitle: 'Vollstaendige Diagnose freischalten',
paywallHint: 'Starte Pro fuer echte GPT-5.4 Scans, deinen 7-Tage-Rettungsplan und 100 Credits fuer AI-Scans und Follow-ups.', paywallHint: 'Starte Pro fuer echte GPT-5.4 Scans, deinen 7-Tage-Rettungsplan und 100 Credits fuer AI-Scans und Follow-ups.',
startTrial: '7 Tage kostenlos testen', startTrial: '7 Tage kostenlos testen',
hardPaywallTitle: 'Pflanze scannen. Richtig pflegen.',
hardPaywallHint: 'GreenLens erkennt Art, Pflegebedarf und mögliche Probleme in Sekunden.',
identifiedChip: 'Monstera erkannt',
benefitUnlimitedScans: 'Unbegrenzte Scans',
benefitCarePlan: 'Pflegeplan + Erinnerungen',
weeklyPlanName: 'Monatlich',
yearlyPlanName: 'Jährlich',
weeklyPriceFallback: '5,99 € / Monat',
yearlyPriceFallback: '39,99 € / Jahr',
bestOffer: 'Bestes Angebot',
noPaymentToday: 'Keine Zahlung heute · jederzeit kündbar',
monthlyFooter: 'Monatlich kündbar · kein Gratis-Test',
monthlyCta: 'Monatlich starten', monthlyCta: 'Monatlich starten',
yearlyCta: 'Jaehrlich starten', yearlyCta: 'Jaehrlich starten',
yearlyTrialBadge: '7 TAGE GRATIS', yearlyTrialBadge: '7 TAGE GRATIS',
@@ -157,6 +172,18 @@ const getBillingCopy = (language: Language) => {
paywallTitle: 'Desbloquear diagnostico completo', paywallTitle: 'Desbloquear diagnostico completo',
paywallHint: 'Inicia Pro para escaneos reales con GPT-5.4, tu plan de rescate de 7 dias y 100 creditos para escaneos IA y seguimientos.', paywallHint: 'Inicia Pro para escaneos reales con GPT-5.4, tu plan de rescate de 7 dias y 100 creditos para escaneos IA y seguimientos.',
startTrial: 'Probar 7 dias gratis', startTrial: 'Probar 7 dias gratis',
hardPaywallTitle: 'Escanea plantas. Cuídalas bien.',
hardPaywallHint: 'GreenLens identifica la especie, los cuidados y posibles problemas en segundos.',
identifiedChip: 'Monstera identificada',
benefitUnlimitedScans: 'Escaneos ilimitados',
benefitCarePlan: 'Plan de cuidado + recordatorios',
weeklyPlanName: 'Mensual',
yearlyPlanName: 'Anual',
weeklyPriceFallback: '5,99 € / mes',
yearlyPriceFallback: '39,99 € / año',
bestOffer: 'Mejor oferta',
noPaymentToday: 'Sin pago hoy · cancela cuando quieras',
monthlyFooter: 'Cancela mensualmente · sin prueba gratis',
monthlyCta: 'Empezar mensual', monthlyCta: 'Empezar mensual',
yearlyCta: 'Empezar anual', yearlyCta: 'Empezar anual',
yearlyTrialBadge: '7 DIAS GRATIS', yearlyTrialBadge: '7 DIAS GRATIS',
@@ -221,6 +248,18 @@ const getBillingCopy = (language: Language) => {
paywallTitle: 'Unlock the full diagnosis', paywallTitle: 'Unlock the full diagnosis',
paywallHint: 'Start Pro for real GPT-5.4 scans, your 7-day rescue plan, and 100 credits for AI scans and follow-ups.', paywallHint: 'Start Pro for real GPT-5.4 scans, your 7-day rescue plan, and 100 credits for AI scans and follow-ups.',
startTrial: 'Start 7-day free trial', startTrial: 'Start 7-day free trial',
hardPaywallTitle: 'Scan plants. Care for them right.',
hardPaywallHint: 'GreenLens identifies the species, care needs, and possible problems in seconds.',
identifiedChip: 'Monstera identified',
benefitUnlimitedScans: 'Unlimited scans',
benefitCarePlan: 'Care plan + reminders',
weeklyPlanName: 'Monthly',
yearlyPlanName: 'Yearly',
weeklyPriceFallback: '$4.99 / month',
yearlyPriceFallback: '$39.99 / year',
bestOffer: 'Best offer',
noPaymentToday: 'No payment today · cancel anytime',
monthlyFooter: 'Cancel monthly · no free trial',
monthlyCta: 'Start monthly', monthlyCta: 'Start monthly',
yearlyCta: 'Start yearly', yearlyCta: 'Start yearly',
yearlyTrialBadge: '7 DAYS FREE', yearlyTrialBadge: '7 DAYS FREE',
@@ -283,19 +322,22 @@ export default function BillingScreen() {
const posthog = usePostHog(); const posthog = usePostHog();
const copy = getBillingCopy(language); const copy = getBillingCopy(language);
const isExpoGo = Constants.appOwnership === 'expo'; const isExpoGo = Constants.appOwnership === 'expo';
const { height: windowHeight } = useWindowDimensions();
const compactPaywall = windowHeight < 760;
const [subModalVisible, setSubModalVisible] = useState(false); const [subModalVisible, setSubModalVisible] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [storeReady, setStoreReady] = useState(isExpoGo); const [storeReady, setStoreReady] = useState(isExpoGo);
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({}); const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({}); const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('yearly');
// Cancel Flow State // Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const planId = billingSummary?.entitlement?.plan || 'free'; const planId = billingSummary?.entitlement?.plan || 'free';
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0); const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0);
const showPaywallPlans = !session || planId !== 'pro'; const showPaywallPlans = !session || (!isLoadingBilling && planId !== 'pro');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -358,6 +400,11 @@ export default function BillingScreen() {
const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice;
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
const weeklyDisplayPrice = copy.weeklyPriceFallback;
const yearlyDisplayPrice = copy.yearlyPriceFallback;
const selectedProductId: SubscriptionProductId = selectedPaywallPlan === 'yearly' ? 'yearly_pro' : 'monthly_pro';
const paywallCtaLabel = selectedPaywallPlan === 'yearly' ? copy.startTrial : copy.monthlyCta;
const paywallFooterLabel = selectedPaywallPlan === 'yearly' ? copy.noPaymentToday : copy.monthlyFooter;
const topupLabels = useMemo(() => ({ const topupLabels = useMemo(() => ({
topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
@@ -540,6 +587,138 @@ export default function BillingScreen() {
} }
}; };
if (showPaywallPlans) {
return (
<View style={styles.hardPaywallScreen}>
<ImageBackground
source={PAYWALL_BACKGROUND}
style={styles.hardPaywallHero}
imageStyle={styles.hardPaywallHeroImage}
resizeMode="cover"
>
<SafeAreaView
style={[styles.hardPaywallSafe, compactPaywall && styles.hardPaywallSafeCompact]}
edges={['top']}
>
<View style={styles.heroTopBar}>
<TouchableOpacity onPress={handleBack} style={styles.heroIconButton}>
<Ionicons name="close" size={24} color="#FFFFFF" />
</TouchableOpacity>
</View>
<View style={[styles.scanFrameOverlay, compactPaywall && styles.scanFrameOverlayCompact]}>
<View style={[styles.scanCorner, styles.scanCornerTopLeft]} />
<View style={[styles.scanCorner, styles.scanCornerTopRight]} />
<View style={[styles.scanCorner, styles.scanCornerBottomLeft]} />
<View style={[styles.scanCorner, styles.scanCornerBottomRight]} />
</View>
<View style={[styles.identifiedChip, compactPaywall && styles.identifiedChipCompact]}>
<Ionicons name="leaf-outline" size={17} color="#8FD19E" />
<Text style={styles.identifiedChipText}>{copy.identifiedChip}</Text>
</View>
<View style={[styles.hardPaywallSheet, compactPaywall && styles.hardPaywallSheetCompact]}>
<View style={[styles.sheetHandle, compactPaywall && styles.sheetHandleCompact]} />
<Text style={[styles.hardPaywallTitle, compactPaywall && styles.hardPaywallTitleCompact]}>
{copy.hardPaywallTitle}
</Text>
<Text style={[styles.hardPaywallHint, compactPaywall && styles.hardPaywallHintCompact]}>
{copy.hardPaywallHint}
</Text>
<View style={[styles.hardBenefits, compactPaywall && styles.hardBenefitsCompact]}>
{[
{ icon: 'infinite-outline' as const, title: copy.benefitUnlimitedScans },
{ icon: 'calendar-outline' as const, title: copy.benefitCarePlan },
].map((benefit) => (
<View key={benefit.title} style={[styles.hardBenefitRow, compactPaywall && styles.hardBenefitRowCompact]}>
<View style={[styles.hardBenefitIcon, compactPaywall && styles.hardBenefitIconCompact]}>
<Ionicons name={benefit.icon} size={compactPaywall ? 18 : 20} color="#1F5B34" />
</View>
<Text style={[styles.hardBenefitText, compactPaywall && styles.hardBenefitTextCompact]}>
{benefit.title}
</Text>
</View>
))}
</View>
<View style={styles.hardPlanRow}>
{[
{ id: 'weekly' as PaywallPlanId, name: copy.weeklyPlanName, price: weeklyDisplayPrice },
{ id: 'yearly' as PaywallPlanId, name: copy.yearlyPlanName, price: yearlyDisplayPrice, badge: copy.bestOffer },
].map((plan) => {
const selected = selectedPaywallPlan === plan.id;
return (
<TouchableOpacity
key={plan.id}
style={[
styles.hardPlanCard,
compactPaywall && styles.hardPlanCardCompact,
selected && styles.hardPlanCardSelected,
]}
onPress={() => setSelectedPaywallPlan(plan.id)}
activeOpacity={0.88}
>
<View style={[styles.planRadio, selected && styles.planRadioSelected]}>
{selected ? <Ionicons name="checkmark" size={15} color="#FFFFFF" /> : null}
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.hardPlanName, compactPaywall && styles.hardPlanNameCompact]}>{plan.name}</Text>
<Text style={[styles.hardPlanPrice, compactPaywall && styles.hardPlanPriceCompact]}>{plan.price}</Text>
{plan.badge ? (
<View style={styles.bestOfferBadge}>
<Text style={styles.bestOfferText}>{plan.badge}</Text>
</View>
) : null}
</View>
</TouchableOpacity>
);
})}
</View>
<TouchableOpacity
style={[
styles.hardPaywallCta,
compactPaywall && styles.hardPaywallCtaCompact,
(!storeReady || isUpdating) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(selectedProductId)}
disabled={isUpdating || !storeReady}
activeOpacity={0.9}
>
{isUpdating || !storeReady ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<>
<Text style={[styles.hardPaywallCtaText, compactPaywall && styles.hardPaywallCtaTextCompact]}>
{paywallCtaLabel}
</Text>
<Ionicons name="arrow-forward" size={23} color="#FFFFFF" />
</>
)}
</TouchableOpacity>
<Text style={styles.hardPaywallFooter}>{paywallFooterLabel}</Text>
<View style={styles.legalLinksRow}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: '#1F5B34' }]}>Privacy Policy</Text>
</TouchableOpacity>
<Text style={[styles.legalSep, { color: '#7A8079' }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: '#1F5B34' }]}>Terms of Use</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.hardRestoreBtn} onPress={handleRestore} disabled={isUpdating}>
<Text style={styles.hardRestoreText}>{copy.restorePurchases}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</ImageBackground>
</View>
);
}
return ( return (
<View style={{ flex: 1, backgroundColor: colors.background }}> <View style={{ flex: 1, backgroundColor: colors.background }}>
<ThemeBackdrop colors={colors} /> <ThemeBackdrop colors={colors} />
@@ -633,7 +812,7 @@ export default function BillingScreen() {
activeOpacity={0.9} activeOpacity={0.9}
> >
<View style={styles.planTopRow}> <View style={styles.planTopRow}>
<View> <View style={{ flex: 1 }}>
<Text style={[styles.guestPlanName, { color: colors.text }]}>Monatlich</Text> <Text style={[styles.guestPlanName, { color: colors.text }]}>Monatlich</Text>
<Text style={[styles.planSubline, { color: colors.textMuted }]}>{copy.monthlySubline}</Text> <Text style={[styles.planSubline, { color: colors.textMuted }]}>{copy.monthlySubline}</Text>
</View> </View>
@@ -906,6 +1085,296 @@ export default function BillingScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
hardPaywallScreen: {
flex: 1,
backgroundColor: '#101411',
},
hardPaywallHero: {
flex: 1,
},
hardPaywallHeroImage: {
transform: [{ translateY: -38 }, { scale: 1.06 }],
},
hardPaywallSafe: {
flex: 1,
justifyContent: 'space-between',
},
hardPaywallSafeCompact: {
justifyContent: 'flex-end',
},
heroTopBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 4,
},
heroIconButton: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#00000066',
},
heroRestoreText: {
color: '#FFFFFF',
fontSize: 13,
fontWeight: '700',
textShadowColor: '#00000066',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
scanFrameOverlay: {
position: 'absolute',
top: '14%',
left: 54,
right: 54,
height: '28%',
},
scanFrameOverlayCompact: {
display: 'none',
},
scanCorner: {
position: 'absolute',
width: 44,
height: 44,
borderColor: '#FFFFFF',
},
scanCornerTopLeft: {
top: 0,
left: 0,
borderTopWidth: 4,
borderLeftWidth: 4,
borderTopLeftRadius: 18,
},
scanCornerTopRight: {
top: 0,
right: 0,
borderTopWidth: 4,
borderRightWidth: 4,
borderTopRightRadius: 18,
},
scanCornerBottomLeft: {
bottom: 0,
left: 0,
borderBottomWidth: 4,
borderLeftWidth: 4,
borderBottomLeftRadius: 18,
},
scanCornerBottomRight: {
bottom: 0,
right: 0,
borderBottomWidth: 4,
borderRightWidth: 4,
borderBottomRightRadius: 18,
},
identifiedChip: {
position: 'absolute',
alignSelf: 'center',
top: '43%',
zIndex: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderRadius: 18,
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#16351FCC',
},
identifiedChipCompact: {
display: 'none',
},
identifiedChipText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '800',
},
hardPaywallSheet: {
backgroundColor: '#FAFAF5',
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
paddingHorizontal: 20,
paddingTop: 9,
paddingBottom: 18,
zIndex: 5,
},
hardPaywallSheetCompact: {
paddingHorizontal: 18,
paddingTop: 8,
paddingBottom: 16,
},
sheetHandle: {
alignSelf: 'center',
width: 42,
height: 5,
borderRadius: 999,
backgroundColor: '#C7C8C3',
marginBottom: 8,
},
sheetHandleCompact: {
marginBottom: 8,
},
hardPaywallTitle: {
color: '#101411',
fontSize: 23,
lineHeight: 28,
fontWeight: '900',
textAlign: 'center',
marginBottom: 5,
},
hardPaywallTitleCompact: {
fontSize: 22,
lineHeight: 26,
},
hardPaywallHint: {
color: '#676C66',
fontSize: 12,
lineHeight: 17,
textAlign: 'center',
marginBottom: 10,
},
hardPaywallHintCompact: {
display: 'none',
},
hardBenefits: {
gap: 5,
marginBottom: 7,
},
hardBenefitsCompact: {
marginBottom: 10,
},
hardBenefitRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
hardBenefitRowCompact: {
gap: 9,
},
hardBenefitIcon: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#E3EFD9',
},
hardBenefitIconCompact: {
width: 32,
height: 32,
borderRadius: 16,
},
hardBenefitText: {
color: '#101411',
fontSize: 13,
fontWeight: '800',
},
hardBenefitTextCompact: {
fontSize: 13,
},
hardPlanRow: {
flexDirection: 'row',
gap: 10,
marginBottom: 7,
},
hardPlanCard: {
flex: 1,
minHeight: 76,
borderRadius: 14,
borderWidth: 1.5,
borderColor: '#D8DAD3',
padding: 9,
flexDirection: 'row',
gap: 10,
backgroundColor: '#FFFFFF',
},
hardPlanCardCompact: {
minHeight: 76,
padding: 9,
gap: 8,
},
hardPlanCardSelected: {
borderColor: '#1F5B34',
borderWidth: 2,
backgroundColor: '#FBFFF8',
},
planRadio: {
width: 20,
height: 20,
borderRadius: 11,
borderWidth: 2,
borderColor: '#8C918A',
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
},
planRadioSelected: {
backgroundColor: '#1F5B34',
borderColor: '#1F5B34',
},
hardPlanName: {
color: '#101411',
fontSize: 14,
fontWeight: '900',
marginBottom: 4,
},
hardPlanNameCompact: {
fontSize: 13,
marginBottom: 2,
},
hardPlanPrice: {
color: '#626862',
fontSize: 11,
fontWeight: '700',
lineHeight: 18,
},
hardPlanPriceCompact: {
fontSize: 11,
lineHeight: 15,
},
bestOfferBadge: {
alignSelf: 'flex-start',
backgroundColor: '#DDEBCF',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 3,
marginTop: 5,
},
bestOfferText: {
color: '#1F5B34',
fontSize: 10,
fontWeight: '900',
},
hardPaywallCta: {
minHeight: 50,
borderRadius: 14,
backgroundColor: '#1F5B34',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 12,
marginBottom: 5,
},
hardPaywallCtaCompact: {
minHeight: 48,
marginBottom: 8,
},
hardPaywallCtaText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '900',
},
hardPaywallCtaTextCompact: {
fontSize: 14,
},
hardPaywallFooter: {
color: '#777C75',
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
marginBottom: 5,
},
safeArea: { flex: 1 }, safeArea: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, header: { flexDirection: 'row', alignItems: 'center', padding: 16 },
backButton: { width: 40, height: 40, justifyContent: 'center' }, backButton: { width: 40, height: 40, justifyContent: 'center' },
@@ -1208,6 +1677,7 @@ const styles = StyleSheet.create({
borderRadius: 999, borderRadius: 999,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 3, paddingVertical: 3,
flexShrink: 0,
}, },
secondaryBadgeText: { secondaryBadgeText: {
fontSize: 10, fontSize: 10,
@@ -1253,6 +1723,17 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingVertical: 8, paddingVertical: 8,
}, },
hardRestoreBtn: {
alignItems: 'center',
paddingVertical: 6,
marginTop: 4,
},
hardRestoreText: {
color: '#7A8079',
fontSize: 12,
fontWeight: '500',
textDecorationLine: 'underline',
},
autoRenewText: { autoRenewText: {
fontSize: 11, fontSize: 11,
marginTop: 2, marginTop: 2,

View File

@@ -7,6 +7,7 @@ import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types'; import { Language } from '../../types';
import { AuthService } from '../../services/authService';
const getDataCopy = (language: Language) => { const getDataCopy = (language: Language) => {
if (language === 'de') { if (language === 'de') {
@@ -121,9 +122,13 @@ export default function DataScreen() {
text: copy.deleteActionBtn, text: copy.deleteActionBtn,
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
// Future implementation: call backend to wipe user data and cancel active app subscriptions try {
await signOut(); await AuthService.deleteAccount();
router.replace('/onboarding'); await signOut();
router.replace('/onboarding');
} catch {
Alert.alert(copy.genericErrorTitle, copy.genericErrorMessage);
}
}, },
}, },
]); ]);

View File

@@ -21,8 +21,10 @@ import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeout
import { isBackendApiError } from '../services/backend/contracts'; import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency'; import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService } from '../services/authService'; import { AuthService } from '../services/authService';
import { getMockPlantByImage } from '../services/backend/mockCatalog';
const HEALTH_CHECK_CREDIT_COST = 2; const HEALTH_CHECK_CREDIT_COST = 2;
const DEMO_SCAN_LIMIT = 5;
const getBillingCopy = (language: 'de' | 'en' | 'es') => { const getBillingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') { if (language === 'de') {
@@ -50,6 +52,9 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
signupLabel: 'Registrieren', signupLabel: 'Registrieren',
demoTitle: 'Rettungsplan bereit', demoTitle: 'Rettungsplan bereit',
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.', 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.',
demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`,
appleCta: 'Mit Apple fortfahren', appleCta: 'Mit Apple fortfahren',
emailCta: 'Mit E-Mail fortfahren', emailCta: 'Mit E-Mail fortfahren',
unlockCta: 'Vollständige Diagnose freischalten', unlockCta: 'Vollständige Diagnose freischalten',
@@ -81,6 +86,9 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
signupLabel: 'Registrarse', signupLabel: 'Registrarse',
demoTitle: 'Plan de rescate listo', 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.', 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.',
demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`,
appleCta: 'Continuar con Apple', appleCta: 'Continuar con Apple',
emailCta: 'Continuar con email', emailCta: 'Continuar con email',
unlockCta: 'Desbloquear diagnóstico completo', unlockCta: 'Desbloquear diagnóstico completo',
@@ -111,6 +119,9 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
signupLabel: 'Sign Up', signupLabel: 'Sign Up',
demoTitle: 'Rescue plan ready', demoTitle: 'Rescue plan ready',
demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.', 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.',
demoCreditsRemaining: (count: number) => `${count} demo scans left`,
appleCta: 'Continue with Apple', appleCta: 'Continue with Apple',
emailCta: 'Continue with email', emailCta: 'Continue with email',
unlockCta: 'Unlock full diagnosis', unlockCta: 'Unlock full diagnosis',
@@ -118,7 +129,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
}; };
export default function ScannerScreen() { export default function ScannerScreen() {
const params = useLocalSearchParams<{ mode?: string; plantId?: string }>(); const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageUri?: string }>();
const posthog = usePostHog(); const posthog = usePostHog();
const { const {
isDarkMode, isDarkMode,
@@ -134,6 +145,8 @@ export default function ScannerScreen() {
session, session,
hydrateSession, hydrateSession,
setPendingPlant, setPendingPlant,
guestScanCount,
incrementGuestScanCount,
} = useApp(); } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const router = useRouter(); const router = useRouter();
@@ -144,10 +157,14 @@ export default function ScannerScreen() {
const healthPlant = isHealthMode && healthPlantId const healthPlant = isHealthMode && healthPlantId
? plants.find((item) => item.id === healthPlantId) ? plants.find((item) => item.id === healthPlantId)
: null; : null;
const sharedImageUri = Array.isArray(params.sharedImageUri)
? params.sharedImageUri[0]
: params.sharedImageUri;
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active'; && billingSummary?.entitlement?.status === 'active';
const isDemoMode = !hasActiveEntitlement; const isDemoMode = !hasActiveEntitlement;
const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0; const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0;
const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount);
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null);
@@ -180,6 +197,18 @@ export default function ScannerScreen() {
}; };
}, [isExpoGo]); }, [isExpoGo]);
const hasProcessedSharedImage = useRef(false);
useEffect(() => {
if (!sharedImageUri || hasProcessedSharedImage.current) return;
hasProcessedSharedImage.current = true;
(async () => {
const analysisUri = await resizeForAnalysis(sharedImageUri);
setDemoResultVisible(false);
setSelectedImage(sharedImageUri);
analyzeImage(analysisUri, sharedImageUri);
})();
}, [sharedImageUri]);
useEffect(() => { useEffect(() => {
if (!isAnalyzing) { if (!isAnalyzing) {
scanLineProgress.stopAnimation(); scanLineProgress.stopAnimation();
@@ -239,6 +268,21 @@ export default function ScannerScreen() {
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => { const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
if (isAnalyzing) return; 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'),
},
],
);
return;
}
if (!isDemoMode && availableCredits <= 0) { if (!isDemoMode && availableCredits <= 0) {
Alert.alert( Alert.alert(
billingCopy.noCreditsTitle, billingCopy.noCreditsTitle,
@@ -275,15 +319,20 @@ export default function ScannerScreen() {
posthog.capture('demo_scan_started', { posthog.capture('demo_scan_started', {
authenticated: Boolean(session), authenticated: Boolean(session),
scan_type: isHealthMode ? 'health_check' : 'identification', scan_type: isHealthMode ? 'health_check' : 'identification',
demo_scans_used: guestScanCount,
demo_scans_remaining: demoScansRemaining,
}); });
await new Promise(resolve => setTimeout(resolve, 2100)); await new Promise(resolve => setTimeout(resolve, 2100));
setAnalysisProgress(100); setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 350)); await new Promise(resolve => setTimeout(resolve, 350));
setDemoResultVisible(true); const demoResult = getMockPlantByImage(galleryImageUri || imageUri, language, true);
incrementGuestScanCount();
setAnalysisResult(demoResult);
posthog.capture('demo_scan_completed', { posthog.capture('demo_scan_completed', {
authenticated: Boolean(session), authenticated: Boolean(session),
latency_ms: Date.now() - startTime, latency_ms: Date.now() - startTime,
demo_scans_used_after: guestScanCount + 1,
}); });
return; return;
} }
@@ -517,7 +566,7 @@ export default function ScannerScreen() {
}); });
await hydrateSession(nextSession); await hydrateSession(nextSession);
posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' }); posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' });
router.replace(nextSession.isNewUser ? '/profile/billing' : '/(tabs)'); router.replace(nextSession.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (error: any) { } catch (error: any) {
if (error?.code === 'ERR_REQUEST_CANCELED') { if (error?.code === 'ERR_REQUEST_CANCELED') {
return; return;
@@ -603,7 +652,7 @@ export default function ScannerScreen() {
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}> <View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
<Ionicons name={isDemoMode ? 'sparkles-outline' : 'wallet-outline'} size={12} color={colors.text} /> <Ionicons name={isDemoMode ? 'sparkles-outline' : 'wallet-outline'} size={12} color={colors.text} />
<Text style={[styles.creditBadgeText, { color: colors.text }]}> <Text style={[styles.creditBadgeText, { color: colors.text }]}>
{isDemoMode ? 'Demo' : `${billingCopy.creditsLabel}: ${availableCredits}`} {isDemoMode ? billingCopy.demoCreditsRemaining(demoScansRemaining) : `${billingCopy.creditsLabel}: ${availableCredits}`}
</Text> </Text>
</View> </View>
</View> </View>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,312 @@
# GreenLens Ad Creatives
Datum: 2026-05-08
Sprache: Deutsch
Basis: GreenLens Pro als Premium-App fuer Pflanzen-Scan, Diagnose, Pflegeplan, Health Check, Erinnerungen, Sammlung und Standort-/Licht-Tipps.
## Annahmen
- Hauptziel: App-Install oder Landing-Page-Klick mit anschliessendem Pro-Upgrade.
- Zielgruppe: Zimmerpflanzen-Besitzer, Pflanzen-Anfaenger, Urban-Jungle-Fans, Nutzer mit kranken Pflanzen.
- Positionierung: Nicht nur Pflanzen bestimmen, sondern Ursache erkennen, naechsten Schritt bekommen und Pflege langfristig tracken.
- Wichtige Funnel-Regel: Nicht als dauerhaft kostenlose App positionieren. Demo-Scans nur als Einstieg, Pro ist das eigentliche Produkt.
## Creative Angles
1. Diagnose statt Raten: Symptome brauchen Kontext.
2. Rettungsplan: Foto machen, Ursache verstehen, 7-Tage-Plan bekommen.
3. Pflege-System: Scannen, tracken, erinnern lassen.
4. Standort/Licht: Der falsche Platz ist oft das Problem.
5. Premium Calm: Pflanzenpflege fuehlt sich ruhiger an, wenn der naechste Schritt klar ist.
## Meta / Instagram Feed Ads
Empfohlene Limits: Primary Text vorne stark halten, Headline ca. 40 Zeichen, Description ca. 30 Zeichen.
### Meta Ad 1 - Diagnose statt Raten
- Visual: Close-up einer gelben Monstera- oder Pothos-Blattspitze auf warmem cremefarbenem Hintergrund. Links kurze Overlays: "Gelb ist kein Befund." Darunter App-Screen mit Health Check Ergebnis.
- Primary Text: Gelbe Blaetter? GreenLens scannt deine Pflanze und zeigt dir moegliche Ursachen plus naechste Schritte.
- Headline: Pflanze scannen. Klarheit bekommen.
- Description: Diagnose mit KI
- CTA: App installieren
### Meta Ad 2 - Rettungsplan
- Visual: Split Frame. Links gestresste Pflanze, rechts GreenLens Ergebnis mit "Moegliche Ursachen", "Sofortmassnahmen", "7-Tage-Plan".
- Primary Text: Deine Pflanze sieht schlecht aus? Mach ein Foto und bekomme einen klaren Rettungsplan statt Bauchgefuehl.
- Headline: Dein Pflanzen-Rettungsplan
- Description: In Sekunden starten
- CTA: Mehr erfahren
### Meta Ad 3 - Pflege-System
- Visual: Drei App-Karten als Bento: Scan, Giesstermin, Pflanzen-Sammlung. Ruhiger Botanical-Archive Look.
- Primary Text: GreenLens ist dein ruhiges System fuer Pflanzenpflege: scannen, giessen, Health Checks und Wachstum tracken.
- Headline: Scan it. Track it. Grow it.
- Description: Pflege in einer App
- CTA: App installieren
### Meta Ad 4 - Standort-Check
- Visual: Pflanze am Fenster, Lichtstrahlen, dezenter Light-Meter/Standort-Check Screen.
- Primary Text: Manchmal ist nicht Wasser das Problem. GreenLens hilft dir, Licht, Standort und Pflege besser einzuordnen.
- Headline: Steht deine Pflanze falsch?
- Description: Standort besser pruefen
- CTA: Mehr erfahren
### Meta Ad 5 - Ueberwaesserung
- Visual: Droopende Pflanze neben Giesskanne. Text: "Mehr Wasser ist nicht immer Hilfe."
- Primary Text: Ueberwaesserung sieht oft aus wie Durst. GreenLens hilft dir, erst zu pruefen und dann zu handeln.
- Headline: Nicht sofort giessen
- Description: Erst Ursache finden
- CTA: App installieren
### Meta Ad 6 - Premium App Store
- Visual: App-Store-artiger Hero mit echtem Produktvideo im Phone Frame, Botanical-Archive Typografie, GreenLens Logo.
- Primary Text: Was waere, wenn jede Pflanze eine Anleitung haette? Oeffne GreenLens, scanne ein Blatt und bekomme den naechsten Schritt.
- Headline: Jede Pflanze mit Anleitung
- Description: GreenLens Pro
- CTA: App installieren
## TikTok / Reels Video Ads
Empfohlene Laenge: 12-20 Sekunden. Format: 9:16.
### TikTok Ad 1 - "Gelb ist kein Befund"
- Hook Text: Gelbe Blaetter sind nicht die Diagnose.
- Szene 1: Gelbes Blatt in Nahaufnahme, schnelle Textblende: "Nicht sofort giessen."
- Szene 2: App oeffnet Scanner, Blatt wird fotografiert.
- Szene 3: Ergebnis: moegliche Ursachen, Sofortmassnahmen, 7-Tage-Plan.
- VO: "Gelbe Blaetter koennen Wasser, Licht, Wurzeln oder Stress bedeuten. GreenLens hilft dir, erst die Ursache zu finden."
- CTA Text: Scanne deine Pflanze mit GreenLens.
- Ad Text: Gelbe Blaetter? Erst Ursache finden, dann handeln.
### TikTok Ad 2 - "Drooping Trap"
- Hook Text: Drooping heisst nicht automatisch Durst.
- Szene 1: Traurige Pflanze, Hand greift zur Giesskanne.
- Szene 2: Freeze Frame: "Stopp."
- Szene 3: GreenLens Scan und Checkliste: Licht, Wasser, Erde, Schädlinge.
- VO: "Viele Pflanzen sehen durstig aus, obwohl die Wurzeln gestresst sind. Scan sie, bevor du mehr Wasser gibst."
- CTA Text: Nicht raten. GreenLens nutzen.
- Ad Text: Drooping? GreenLens prueft mehr als nur Wasser.
### TikTok Ad 3 - "Plant ER"
- Hook Text: Deine Pflanze braucht Triage, keine Panik.
- Szene 1: Drei schnelle Symptome: gelb, braun, haengend.
- Szene 2: App erkennt Pflanze.
- Szene 3: Health Score, Ursachen, Aktionen jetzt.
- VO: "Wenn eine Pflanze kippt, brauchst du Reihenfolge. GreenLens macht aus Panik einen Plan."
- CTA Text: Starte deinen Health Check.
- Ad Text: Pflanzen-Health-Check direkt aus einem Foto.
### TikTok Ad 4 - "Every Plant Instructions"
- Hook Text: Was, wenn jede Pflanze eine Anleitung haette?
- Szene 1: Ruhiger Pflanzen-Hero.
- Szene 2: Scan-Moment mit App.
- Szene 3: Pflegeplan, Giesserinnerung, Sammlung.
- VO: "Oeffne GreenLens. Scanne ein Blatt. Bekomme Name, Pflegeplan und den naechsten Schritt."
- CTA Text: Scan it. Track it. Grow it.
- Ad Text: GreenLens macht Pflanzenpflege klarer.
### TikTok Ad 5 - "Falscher Standort"
- Hook Text: Vielleicht ist nicht Wasser das Problem.
- Szene 1: Pflanze in dunkler Ecke.
- Szene 2: Licht am Fenster, Standort-Check Visual.
- Szene 3: GreenLens zeigt Pflege- und Licht-Hinweise.
- VO: "Viele Pflanzen werden am falschen Platz gepflegt. GreenLens hilft dir, Standort und Pflege zusammen zu betrachten."
- CTA Text: Pruefe den Standort mit GreenLens.
- Ad Text: Wasser ist nicht immer die Antwort.
### TikTok Ad 6 - "Beginner Mistake"
- Hook Text: Der groesste Anfaengerfehler: zu viel tun.
- Szene 1: Wasser, Duenger, Umtopfen, alles schnell hintereinander.
- Szene 2: Text: "Mehr Pflege kann Stress machen."
- Szene 3: GreenLens Health Check und ruhiger Pflegeplan.
- VO: "Wenn du nicht weisst, was los ist, wird jede Massnahme zum Risiko. GreenLens zeigt dir den naechsten sinnvollen Schritt."
- CTA Text: Erst scannen. Dann handeln.
- Ad Text: Gute Pflanzenpflege beginnt mit Klarheit.
## Google Ads Responsive Search Ads
### Headlines
1. GreenLens Pflanzen App
2. Pflanze per Foto erkennen
3. Pflanzenkrankheit erkennen
4. Gelbe Blaetter? Scannen
5. Pflanzenpflege mit KI
6. Health Check fuer Pflanzen
7. Pflegeplan in Sekunden
8. Nie wieder Giessen raten
9. Scan it. Track it. Grow it.
10. Pflanze krank? Foto machen
11. Standort & Licht pruefen
12. Dein Pflanzen-Rettungsplan
13. Zimmerpflanzen bestimmen
14. KI Scan fuer Pflanzen
15. GreenLens Pro starten
### Descriptions
1. Scanne deine Pflanze und erhalte Name, Pflegeplan und naechste Schritte.
2. GreenLens hilft bei gelben Blaettern, Schädlingen, Licht und Pflegefehlern.
3. Tracke Giessen, Wachstum, Notizen und Health Checks in einer ruhigen App.
4. Starte GreenLens Pro und mach aus Pflanzenpflege einen klaren Plan.
### Display Paths
- pflanzen / scanner
- health / check
- pflege / plan
## Google Ad Groups
### Ad Group: Pflanzen Bestimmen
- Keywords: pflanzen bestimmen app, pflanze per foto erkennen, pflanzen scanner, zimmerpflanzen bestimmen, plant identifier app
- Best headlines: "Pflanze per Foto erkennen", "GreenLens Pflanzen App", "Zimmerpflanzen bestimmen"
- Best descriptions: 1, 3
### Ad Group: Pflanzen Krankheit
- Keywords: pflanze krank was tun, gelbe blaetter pflanze, pflanzenkrankheiten erkennen, plant disease identifier, pflanze haengt
- Best headlines: "Pflanzenkrankheit erkennen", "Gelbe Blaetter? Scannen", "Health Check fuer Pflanzen"
- Best descriptions: 2, 4
### Ad Group: Pflege App
- Keywords: pflanzenpflege app, giess erinnerung pflanzen, pflanzen pflegeplan, indoor plant care app
- Best headlines: "Pflanzenpflege mit KI", "Pflegeplan in Sekunden", "Nie wieder Giessen raten"
- Best descriptions: 1, 3
## Static Image Concepts
### Concept A - Symptom Is Not Diagnosis
- Format: 1080x1350 und 1080x1920
- Main Text: "Gelbe Blaetter sind nur ein Signal."
- Subtext: "GreenLens findet moegliche Ursachen und naechste Schritte."
- Visual: Premium Botanical Archive. Ein gelbes Blatt als Herbar-Beleg, daneben ein klarer App-Health-Check Screen. Creme-Papier, tiefe Gruentoene, wenig UI, hohe Lesbarkeit.
- CTA: "Jetzt scannen"
### Concept B - 7-Tage-Rettungsplan
- Format: 1080x1350 und 1080x1920
- Main Text: "Aus Pflanzen-Panik wird ein Plan."
- Subtext: "Foto machen. Ursache verstehen. 7 Tage handeln."
- Visual: Vorher/nachher Komposition mit App-Karten: Ursachen, Sofortmassnahmen, 7-Tage-Plan.
- CTA: "Health Check starten"
### Concept C - Light & Location
- Format: 1080x1350 und 1080x1920
- Main Text: "Vielleicht steht sie nur falsch."
- Subtext: "Pruefe Licht, Standort und Pflege zusammen."
- Visual: Pflanze am Fenster, Lichtverlauf, dezente Standort-Analyse im Phone Frame.
- CTA: "Standort pruefen"
### Concept D - Calm Plant OS
- Format: 1080x1350 und 1080x1920
- Main Text: "Eine App fuer deine Pflanzen."
- Subtext: "Scan. Pflegeplan. Erinnerungen. Health Check."
- Visual: App-Bento mit Scan, Timeline, Collection und Health Score.
- CTA: "GreenLens Pro starten"
## UGC Creator Briefs
### UGC 1 - Beginner Rescue
- Creator: Pflanzen-Anfaenger mit echter gestresster Pflanze.
- Opening line: "Ich dachte, meine Pflanze braucht einfach mehr Wasser."
- Beats: Symptom zeigen, falsche Annahme nennen, GreenLens Scan zeigen, Ergebnis/Plan zeigen, erster Schritt umsetzen.
- Must say: "Ich pruefe jetzt erst die Ursache, bevor ich irgendwas mache."
### UGC 2 - Plant Parent Routine
- Creator: Urban-Jungle / Home Decor.
- Opening line: "Das ist meine 2-Minuten-Routine fuer alle Pflanzen, die komisch aussehen."
- Beats: Foto, Scan, Health Check, Giesstermin, Notiz/Growth Photo.
- Must say: "GreenLens ist fuer mich nicht nur Scanner, sondern mein Pflege-System."
### UGC 3 - Light Mistake
- Creator: Pflanzenpflege Account.
- Opening line: "Diese Pflanze war nicht durstig. Sie stand einfach falsch."
- Beats: dunkler Standort, App-Check, Umstellen, Pflegeplan.
- Must say: "Wasser ist nicht immer die Antwort."
## Testing Matrix
Prioritaet 1:
- Angle: Diagnose statt Raten
- Plattform: Meta + TikTok
- KPI: Install CVR und Trial/Pro-Start
- Creatives: Meta Ad 1, TikTok Ad 1, Static Concept A
Prioritaet 2:
- Angle: Rettungsplan
- Plattform: Meta + Google Search
- KPI: Landing-Page CVR und Paywall-View-to-Purchase
- Creatives: Meta Ad 2, TikTok Ad 3, Static Concept B
Prioritaet 3:
- Angle: Pflege-System
- Plattform: Meta Retargeting
- KPI: Install-to-Onboarding-Complete
- Creatives: Meta Ad 3, TikTok Ad 4, Static Concept D
## Compliance / Copy Guardrails
- Keine garantierte Heilung versprechen.
- Nicht behaupten, Krankheiten "sicher" zu erkennen; besser: "moegliche Ursachen", "Health Check", "naechste Schritte".
- Nicht dauerhaft kostenlos positionieren.
- Keine medizinisch anmutende Sicherheitssprache wie "100% Diagnose" oder "rettet jede Pflanze".
- "In Sekunden" nur fuer Nutzererlebnis verwenden, nicht als technische Garantie fuer jedes Netzwerk.
## Zeichenlimit-Check
Maschinell geprueft am 2026-05-08. Ergebnis: 31 / 31 Texte innerhalb der Limits.
| Asset | Zeichen / Limit |
| --- | ---: |
| Meta H1 | 35 / 40 |
| Meta H2 | 26 / 40 |
| Meta H3 | 27 / 40 |
| Meta H4 | 27 / 40 |
| Meta H5 | 20 / 40 |
| Meta H6 | 26 / 40 |
| TikTok 1 | 50 / 80 |
| TikTok 2 | 47 / 80 |
| TikTok 3 | 44 / 80 |
| TikTok 4 | 38 / 80 |
| TikTok 5 | 35 / 80 |
| TikTok 6 | 41 / 80 |
| RSA H1 | 22 / 30 |
| RSA H2 | 25 / 30 |
| RSA H3 | 26 / 30 |
| RSA H4 | 23 / 30 |
| RSA H5 | 21 / 30 |
| RSA H6 | 26 / 30 |
| RSA H7 | 22 / 30 |
| RSA H8 | 24 / 30 |
| RSA H9 | 27 / 30 |
| RSA H10 | 26 / 30 |
| RSA H11 | 24 / 30 |
| RSA H12 | 26 / 30 |
| RSA H13 | 24 / 30 |
| RSA H14 | 21 / 30 |
| RSA H15 | 21 / 30 |
| RSA D1 | 72 / 90 |
| RSA D2 | 76 / 90 |
| RSA D3 | 73 / 90 |
| RSA D4 | 67 / 90 |

File diff suppressed because it is too large Load Diff

10
memory/project_summary.md Normal file
View File

@@ -0,0 +1,10 @@
# GreenLns Project Memory
## Product/Funnel Decisions
- There is no Free tier anymore.
- The app should be treated as Pro/paywalled by default.
- Demo/fake scan should not be positioned as a free product tier.
- Planned Aha moment: allow limited real Plant-ID starter scans if implemented, but not as an ongoing Free plan.
- Health Check remains paid/Pro gated.
- Paid use case of interest: Standort-Check / Light Meter as a Pro feature.

139
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"expo-notifications": "~0.32.16", "expo-notifications": "~0.32.16",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-share-intent": "^5.1.1",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10", "expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
@@ -6691,6 +6692,144 @@
"node": ">=20.16.0" "node": ">=20.16.0"
} }
}, },
"node_modules/expo-share-intent": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/expo-share-intent/-/expo-share-intent-5.1.1.tgz",
"integrity": "sha512-0sEf34+4w/ySQd7xZmnog/oOm1q+PUBHFoGU97mxTXIGijY4LNmSX806efrDkYMwCxgRA1iHxG/zBib4zBPmYw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/achorein"
},
"https://www.buymeacoffee.com/achorein"
],
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "~10.1.1",
"expo-constants": "~18.0.10",
"expo-linking": "~8.0.9"
},
"peerDependencies": {
"expo": "^54",
"expo-constants": ">=18.0.8",
"expo-linking": ">=8.0.8",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-share-intent/node_modules/@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"license": "MIT",
"dependencies": {
"@babel/highlight": "^7.10.4"
}
},
"node_modules/expo-share-intent/node_modules/@expo/config-plugins": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-10.1.2.tgz",
"integrity": "sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw==",
"license": "MIT",
"dependencies": {
"@expo/config-types": "^53.0.5",
"@expo/json-file": "~9.1.5",
"@expo/plist": "^0.3.5",
"@expo/sdk-runtime-versions": "^1.0.0",
"chalk": "^4.1.2",
"debug": "^4.3.5",
"getenv": "^2.0.0",
"glob": "^10.4.2",
"resolve-from": "^5.0.0",
"semver": "^7.5.4",
"slash": "^3.0.0",
"slugify": "^1.6.6",
"xcode": "^3.0.1",
"xml2js": "0.6.0"
}
},
"node_modules/expo-share-intent/node_modules/@expo/config-types": {
"version": "53.0.5",
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-53.0.5.tgz",
"integrity": "sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g==",
"license": "MIT"
},
"node_modules/expo-share-intent/node_modules/@expo/json-file": {
"version": "9.1.5",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.1.5.tgz",
"integrity": "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "~7.10.4",
"json5": "^2.2.3"
}
},
"node_modules/expo-share-intent/node_modules/@expo/plist": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.3.5.tgz",
"integrity": "sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"base64-js": "^1.2.3",
"xmlbuilder": "^15.1.1"
}
},
"node_modules/expo-share-intent/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"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",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo-share-intent/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/expo-share-intent/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo-share-intent/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/expo-splash-screen": { "node_modules/expo-splash-screen": {
"version": "31.0.13", "version": "31.0.13",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",

View File

@@ -48,6 +48,7 @@
"expo-notifications": "~0.32.16", "expo-notifications": "~0.32.16",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-share-intent": "^5.1.1",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10", "expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",

View File

@@ -27,6 +27,7 @@ loadEnvFiles([
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
const { const {
deleteAccount: authDeleteAccount,
ensureAuthSchema, ensureAuthSchema,
signUp: authSignUp, signUp: authSignUp,
login: authLogin, login: authLogin,
@@ -71,7 +72,7 @@ const port = Number(process.env.PORT || 3000);
const plantsPublicDir = path.join(__dirname, 'public', 'plants'); const plantsPublicDir = path.join(__dirname, 'public', 'plants');
const SCAN_PRIMARY_COST = 1; const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 1; const SCAN_REVIEW_COST = 0;
const SEMANTIC_SEARCH_COST = 2; const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2; const HEALTH_CHECK_COST = 2;
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
@@ -525,6 +526,7 @@ app.get('/', (_request, response) => {
'POST /auth/signup', 'POST /auth/signup',
'POST /auth/login', 'POST /auth/login',
'POST /auth/apple', 'POST /auth/apple',
'DELETE /auth/account',
'GET /v1/billing/summary', 'GET /v1/billing/summary',
'POST /v1/billing/sync-revenuecat', 'POST /v1/billing/sync-revenuecat',
'POST /v1/scan', 'POST /v1/scan',
@@ -913,6 +915,7 @@ app.post('/v1/health-check', async (request, response) => {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: 50, overallHealthScore: 50,
status: 'watch', status: 'watch',
analysisSummary: unavailableIssue,
likelyIssues: [{ likelyIssues: [{
title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable', title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable',
confidence: 0.1, confidence: 0.1,
@@ -948,6 +951,7 @@ app.post('/v1/health-check', async (request, response) => {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: analysis.overallHealthScore, overallHealthScore: analysis.overallHealthScore,
status: analysis.status, status: analysis.status,
analysisSummary: analysis.analysisSummary,
likelyIssues: analysis.likelyIssues, likelyIssues: analysis.likelyIssues,
actionsNow: analysis.actionsNow, actionsNow: analysis.actionsNow,
plan7Days: analysis.plan7Days, plan7Days: analysis.plan7Days,
@@ -1081,6 +1085,26 @@ app.post('/auth/apple', async (request, response) => {
// ─── Startup ─────────────────────────────────────────────────────────────── // ─── Startup ───────────────────────────────────────────────────────────────
app.delete('/auth/account', async (request, response) => {
try {
const authHeader = request.header('authorization') || request.header('Authorization') || '';
if (!authHeader.startsWith('Bearer ')) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing bearer token.' });
}
const payload = verifyJwt(authHeader.slice(7));
if (!payload?.sub) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid bearer token.' });
}
await authDeleteAccount(db, String(payload.sub));
response.status(204).send();
} catch (error) {
const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
}
});
const start = async () => { const start = async () => {
db = await openDatabase(); db = await openDatabase();
await ensurePlantSchema(db); await ensurePlantSchema(db);

View File

@@ -291,4 +291,55 @@ const signInWithApple = async (db, identityToken, profile = {}) => {
return { id, email: normalizedEmail, name, isNewUser: true }; return { id, email: normalizedEmail, name, isNewUser: true };
}; };
module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken }; const runInTransaction = async (db, worker) => {
const client = typeof db.connect === 'function' ? await db.connect() : db;
const release = typeof client.release === 'function' ? () => client.release() : () => {};
await run(client, 'BEGIN');
try {
const result = await worker(client);
await run(client, 'COMMIT');
return result;
} catch (error) {
try {
await run(client, 'ROLLBACK');
} catch (rollbackError) {
console.error('Failed to rollback account deletion transaction.', rollbackError);
}
throw error;
} finally {
release();
}
};
const deleteAccount = async (db, userId) => {
if (!userId || typeof userId !== 'string') {
const err = new Error('Valid user id is required.');
err.code = 'BAD_REQUEST';
err.status = 400;
throw err;
}
return runInTransaction(db, async (tx) => {
await run(tx, 'DELETE FROM billing_accounts WHERE user_id = $1', [userId]);
await run(
tx,
`DELETE FROM billing_idempotency
WHERE id LIKE $1 OR id LIKE $2`,
[`endpoint:%:${userId}:%`, `charge:%:${userId}:%`],
);
const result = await run(tx, 'DELETE FROM auth_users WHERE id = $1', [userId]);
return { deleted: result.changes > 0 };
});
};
module.exports = {
deleteAccount,
ensureAuthSchema,
signUp,
login,
signInWithApple,
issueToken,
verifyJwt,
verifyAppleIdentityToken,
};

View File

@@ -144,6 +144,7 @@ const normalizeIdentifyResult = (raw, language) => {
const normalizeHealthAnalysis = (raw, language) => { const normalizeHealthAnalysis = (raw, language) => {
const scoreRaw = getNumber(raw.overallHealthScore); const scoreRaw = getNumber(raw.overallHealthScore);
const statusRaw = getString(raw.status); const statusRaw = getString(raw.status);
const analysisSummary = getString(raw.analysisSummary);
const issuesRaw = raw.likelyIssues; const issuesRaw = raw.likelyIssues;
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
@@ -182,6 +183,7 @@ const normalizeHealthAnalysis = (raw, language) => {
return { return {
overallHealthScore: Math.round(clamp(score, 0, 100)), overallHealthScore: Math.round(clamp(score, 0, 100)),
status, status,
analysisSummary: analysisSummary || fallbackIssue,
likelyIssues: [ likelyIssues: [
{ {
title: language === 'de' title: language === 'de'
@@ -205,6 +207,7 @@ const normalizeHealthAnalysis = (raw, language) => {
return { return {
overallHealthScore: Math.round(clamp(score, 0, 100)), overallHealthScore: Math.round(clamp(score, 0, 100)),
status, status,
analysisSummary,
likelyIssues, likelyIssues,
actionsNow: actionsNowRaw, actionsNow: actionsNowRaw,
plan7Days: plan7DaysRaw, plan7Days: plan7DaysRaw,
@@ -260,11 +263,12 @@ const buildHealthPrompt = (language, plantContext) => {
'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.', 'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.',
'', '',
'Return strict JSON only in this exact shape:', 'Return strict JSON only in this exact shape:',
'{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}', '{"overallHealthScore":72,"status":"watch","analysisSummary":"...","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}',
'', '',
'Rules:', 'Rules:',
'- "overallHealthScore": integer 0100. 100=perfect health, 8099=minor cosmetic only, 6079=noticeable issues needing attention, 4059=significant stress, below 40=severe/critical.', '- "overallHealthScore": integer 0100. 100=perfect health, 8099=minor cosmetic only, 6079=noticeable issues needing attention, 4059=significant stress, below 40=severe/critical.',
'- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 5079, needs monitoring), "critical" (score<50, urgent action needed).', '- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 5079, needs monitoring), "critical" (score<50, urgent action needed).',
`- "analysisSummary": 6 to 9 precise sentences in ${getLanguageLabel(language)} describing visible condition, symptom pattern, likely root cause, urgency, confidence limits, and what the owner should monitor next.`,
'- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:', '- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:',
' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")', ' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")',
' - "confidence": float 0.050.99 reflecting visual certainty', ' - "confidence": float 0.050.99 reflecting visual certainty',

View File

@@ -50,6 +50,31 @@ const authPost = async (path: string, body: object): Promise<{ userId: string; e
return data as any; return data as any;
}; };
const authDelete = async (path: string, token: string): Promise<void> => {
const backendUrl = getConfiguredBackendRootUrl();
const hasBackendUrl = Boolean(backendUrl);
const url = hasBackendUrl ? `${backendUrl}${path}` : path;
let response: Response;
try {
response = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
} catch {
if (!hasBackendUrl) {
throw new Error('BACKEND_URL_MISSING');
}
throw new Error('NETWORK_ERROR');
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const code = (data as any).code || 'AUTH_ERROR';
const msg = (data as any).message || '';
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
throw new Error(code);
}
};
const buildSession = (data: { userId: string; email: string; name: string; token: string; isNewUser?: boolean }): AuthSession => { const buildSession = (data: { userId: string; email: string; name: string; token: string; isNewUser?: boolean }): AuthSession => {
const localUser = AuthDb.ensureLocalUser(data.email, data.name); const localUser = AuthDb.ensureLocalUser(data.email, data.name);
return { return {
@@ -112,6 +137,18 @@ export const AuthService = {
await clearStoredSession(); await clearStoredSession();
}, },
async deleteAccount(): Promise<void> {
const session = await this.getSession();
if (!session) {
await clearStoredSession();
return;
}
await authDelete('/auth/account', session.token);
AuthDb.deleteLocalUser(session.userId);
await clearStoredSession();
await SecureStore.deleteItemAsync('greenlens_first_run_complete');
},
async updateSessionName(name: string): Promise<void> { async updateSessionName(name: string): Promise<void> {
const session = await this.getSession(); const session = await this.getSession();
if (!session) return; if (!session) return;

View File

@@ -35,7 +35,7 @@ const TRIAL_MONTHLY_CREDITS = 30;
const PRO_MONTHLY_CREDITS = 100; const PRO_MONTHLY_CREDITS = 100;
const SCAN_PRIMARY_COST = 1; const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 1; const SCAN_REVIEW_COST = 0;
const SEMANTIC_SEARCH_COST = 2; const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2; const HEALTH_CHECK_COST = 2;
@@ -509,6 +509,11 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: score, overallHealthScore: score,
status, status,
analysisSummary: status === 'critical'
? 'Die Pflanze zeigt mehrere Stresssignale, die schnell stabilisiert werden sollten. Der wichtigste Verdacht ist zu viel Feuchtigkeit im Wurzelbereich, kombiniert mit schwacher Lichtversorgung. Achte besonders auf weiche gelbe Blaetter, dunkle Stellen am Stiel und Erde, die lange nass bleibt. Wenn diese Zeichen zunehmen, kann die Pflanze innerhalb weniger Tage weiter an Blattspannung verlieren. Die Diagnose ist ein Mock-Ergebnis, aber der Plan ist bewusst konkret. Pruefe zuerst Drainage und Substrat, bevor du Duenger oder einen kompletten Standortwechsel einsetzt.'
: status === 'watch'
? 'Die Pflanze wirkt nicht akut gefaehrdet, zeigt aber erkennbare Pflege-Signale, die beobachtet werden sollten. Wahrscheinlich spielen Giessrhythmus, Licht und leichte Naehrstoffversorgung zusammen. Einzelne gelbliche oder matte Blaetter sind noch kein Notfall, koennen aber ein fruehes Muster anzeigen. Entscheidend ist, ob neue Blaetter stabil bleiben und ob die Erde zwischen den Wassergaben gleichmaessig abtrocknet. Der Plan fokussiert auf konstante Bedingungen statt hektische Eingriffe. Ein Vergleichsfoto nach einer Woche zeigt, ob die Anpassungen wirken.'
: 'Die Pflanze wirkt insgesamt stabil und braucht eher Feintuning als Rettungsmassnahmen. Einzelne Blattreaktionen koennen normale Alterung oder leichte Standortanpassung sein. Der Score spricht dafuer, dass keine akute Ursache dominiert. Beobachte trotzdem neue Flecken, haengende Triebe und Veraenderungen an den unteren Blaettern. Halte die Routine konstant, damit du echte Veraenderungen leichter erkennst. Nutze den naechsten Check als Verlaufskontrolle statt als Notfallmassnahme.',
likelyIssues, likelyIssues,
actionsNow, actionsNow,
plan7Days, plan7Days,
@@ -608,6 +613,11 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: score, overallHealthScore: score,
status, status,
analysisSummary: status === 'critical'
? 'La planta muestra varias senales de estres que conviene estabilizar pronto. La sospecha principal es demasiada humedad en la zona de raices, combinada con luz insuficiente. Observa hojas amarillas blandas, manchas oscuras en tallos y sustrato que permanece mojado demasiado tiempo. Si estas senales aumentan, la planta puede perder firmeza en pocos dias. El diagnostico es simulado, pero el plan es concreto. Revisa drenaje y sustrato antes de fertilizar o cambiar toda la ubicacion.'
: status === 'watch'
? 'La planta no parece en peligro inmediato, pero muestra senales que deben observarse. Probablemente influyen el ritmo de riego, la luz y una nutricion ligera. Algunas hojas amarillas o apagadas no son una emergencia, pero pueden indicar un patron temprano. Lo importante es ver si las hojas nuevas se mantienen firmes y si el sustrato seca de forma regular. El plan prioriza condiciones constantes, no cambios bruscos. Una foto comparativa en una semana mostrara si los ajustes funcionan.'
: 'La planta parece estable y necesita pequenos ajustes mas que medidas de rescate. Algunas hojas pueden reflejar envejecimiento normal o adaptacion al lugar. El puntaje indica que no domina una causa urgente. Aun asi, observa manchas nuevas, tallos caidos y cambios en hojas inferiores. Mantén la rutina constante para detectar cambios reales. Usa el proximo chequeo como comparacion de evolucion.',
likelyIssues, likelyIssues,
actionsNow, actionsNow,
plan7Days, plan7Days,
@@ -706,6 +716,11 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: score, overallHealthScore: score,
status, status,
analysisSummary: status === 'critical'
? 'The plant shows multiple stress signals that should be stabilized soon. The main suspicion is excess moisture around the roots, possibly combined with weak light. Watch for soft yellow leaves, dark stem areas, and soil that stays wet too long. If those signs increase, the plant may lose more leaf firmness within a few days. This is a mock diagnosis, but the plan is intentionally concrete. Check drainage and substrate before fertilizing or changing the whole routine.'
: status === 'watch'
? 'The plant does not look like an immediate emergency, but it has visible care signals worth tracking. Watering cadence, light level, and mild nutrition are the most likely levers. A few yellow or dull leaves are not automatically severe, but they can show an early pattern. The key is whether new leaves stay firm and whether soil dries predictably between watering. The plan focuses on stable conditions instead of abrupt changes. A comparison photo after one week will show whether the adjustments are working.'
: 'The plant looks broadly stable and needs fine-tuning rather than rescue care. Minor leaf reactions may reflect normal aging or placement adjustment. The score suggests no urgent single cause is dominating. Still, monitor new spots, drooping stems, and changes on lower leaves. Keep the routine steady so real changes are easier to see. Use the next check as a trend comparison rather than an emergency intervention.',
likelyIssues, likelyIssues,
actionsNow, actionsNow,
plan7Days, plan7Days,
@@ -1010,6 +1025,7 @@ export const mockBackendService = {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: aiAnalysis.overallHealthScore, overallHealthScore: aiAnalysis.overallHealthScore,
status: aiAnalysis.status, status: aiAnalysis.status,
analysisSummary: aiAnalysis.analysisSummary,
likelyIssues: aiAnalysis.likelyIssues, likelyIssues: aiAnalysis.likelyIssues,
actionsNow: aiAnalysis.actionsNow, actionsNow: aiAnalysis.actionsNow,
plan7Days: aiAnalysis.plan7Days, plan7Days: aiAnalysis.plan7Days,

View File

@@ -12,6 +12,7 @@ export interface OpenAiHealthIssue {
export interface OpenAiHealthAnalysis { export interface OpenAiHealthAnalysis {
overallHealthScore: number; overallHealthScore: number;
status: 'healthy' | 'watch' | 'critical'; status: 'healthy' | 'watch' | 'critical';
analysisSummary?: string;
likelyIssues: OpenAiHealthIssue[]; likelyIssues: OpenAiHealthIssue[];
actionsNow: string[]; actionsNow: string[];
plan7Days: string[]; plan7Days: string[];
@@ -204,15 +205,17 @@ const buildHealthPrompt = (
return [ return [
`Analyze this plant photo for real health condition signs with focus on yellowing leaves, watering stress, pests, and light stress.`, `Analyze this plant photo for real health condition signs with focus on yellowing leaves, watering stress, pests, and light stress.`,
`Return strict JSON only in this shape:`, `Return strict JSON only in this shape:`,
`{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}`, `{"overallHealthScore":72,"status":"watch","analysisSummary":"...","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}`,
`Rules:`, `Rules:`,
`- "overallHealthScore" must be an integer between 0 and 100.`, `- "overallHealthScore" must be an integer between 0 and 100.`,
`- "status" must be one of: "healthy", "watch", "critical".`, `- "status" must be one of: "healthy", "watch", "critical".`,
`- "likelyIssues" must contain 1 to 4 items sorted by confidence descending.`, `- "analysisSummary" must be 6 to 9 precise sentences. Cover the visible condition, symptom pattern, likely root cause, urgency, what evidence is uncertain, and what the owner should monitor next.`,
`- "likelyIssues" must contain 2 to 4 items sorted by confidence descending.`,
`- "confidence" must be between 0 and 1.`, `- "confidence" must be between 0 and 1.`,
`- "title", "details", "actionsNow", and "plan7Days" must be written in ${getLanguageLabel(language)}.`, `- "title", "details", "analysisSummary", "actionsNow", and "plan7Days" must be written in ${getLanguageLabel(language)}.`,
`- "actionsNow" should be immediate steps for the next 24 hours.`, `- Each issue "details" value must be 2 to 4 sentences and explain visual evidence, likely cause, and risk if untreated.`,
`- "plan7Days" should be short actionable steps for the next week.`, `- "actionsNow" must contain 5 to 8 concrete steps for the next 24 to 48 hours.`,
`- "plan7Days" must contain 7 to 10 day-by-day or milestone steps for the next week.`,
`- Do not include markdown, explanations, or extra keys.`, `- Do not include markdown, explanations, or extra keys.`,
...contextLines, ...contextLines,
].join('\n'); ].join('\n');
@@ -231,6 +234,7 @@ const buildFallbackHealthAnalysis = (
return { return {
overallHealthScore: 58, overallHealthScore: 58,
status: 'watch', status: 'watch',
analysisSummary: `${plantContext?.name || 'Die Pflanze'} braucht eine erneute Bewertung mit einem scharfen Foto, weil die KI-Antwort nicht stabil genug war. Behandle sie bis dahin vorsichtig als Beobachtungsfall. Vermeide radikale Standortwechsel, grosse Wassermengen und starke Duengung. Pruefe zuerst, ob die auffaelligen Blaetter weich, trocken, fleckig oder eingerollt wirken. Kontrolliere danach die oberen 3 cm Erde und achte auf stehendes Wasser im Uebertopf. Die wichtigsten sichtbaren Signale sollten bei Tageslicht erneut geprueft werden. Wenn sich Blattfarbe oder Spannung innerhalb von 48 Stunden verschlechtern, starte einen neuen Health-Scan mit einem detailreicheren Foto.`,
likelyIssues: [ likelyIssues: [
{ {
title: 'Eingeschraenkte KI-Analyse', title: 'Eingeschraenkte KI-Analyse',
@@ -255,6 +259,7 @@ const buildFallbackHealthAnalysis = (
return { return {
overallHealthScore: 58, overallHealthScore: 58,
status: 'watch', status: 'watch',
analysisSummary: `${plantContext?.name || 'La planta'} necesita una nueva evaluacion con una foto mas nitida porque la respuesta de IA no fue suficientemente estable. Hasta entonces tratala como un caso de observacion. Evita cambios bruscos de ubicacion, exceso de agua y fertilizacion fuerte. Revisa si las hojas afectadas estan blandas, secas, manchadas o enrolladas. Comprueba despues los 3 cm superiores del sustrato y busca agua acumulada. Las senales visibles deben revisarse de nuevo con luz natural. Si el color o la firmeza empeoran en 48 horas, inicia otro health-scan con una foto mas detallada.`,
likelyIssues: [ likelyIssues: [
{ {
title: 'Analisis de IA limitado', title: 'Analisis de IA limitado',
@@ -278,6 +283,7 @@ const buildFallbackHealthAnalysis = (
return { return {
overallHealthScore: 58, overallHealthScore: 58,
status: 'watch', status: 'watch',
analysisSummary: `${plantContext?.name || 'This plant'} needs another assessment with a sharper photo because the AI response was not stable enough. Until then, treat it as a watch case. Avoid major placement changes, heavy watering, or strong fertilizing. First inspect whether the unusual leaves look soft, dry, spotted, or curled. Then check the top 3 cm of soil and look for standing water in the outer pot. Re-check the visible signals in daylight before making bigger care changes. If color or leaf firmness gets worse within 48 hours, run a new health scan with a more detailed photo.`,
likelyIssues: [ likelyIssues: [
{ {
title: 'Limited AI analysis', title: 'Limited AI analysis',
@@ -304,9 +310,10 @@ const normalizeHealthAnalysis = (
): OpenAiHealthAnalysis | null => { ): OpenAiHealthAnalysis | null => {
const scoreRaw = getNumber(raw.overallHealthScore); const scoreRaw = getNumber(raw.overallHealthScore);
const statusRaw = getString(raw.status); const statusRaw = getString(raw.status);
const analysisSummary = getString(raw.analysisSummary);
const issuesRaw = raw.likelyIssues; const issuesRaw = raw.likelyIssues;
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 6); const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 7); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
return null; return null;
@@ -342,6 +349,7 @@ const normalizeHealthAnalysis = (
return { return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
status, status,
analysisSummary: analysisSummary || fallbackIssue,
likelyIssues: [ likelyIssues: [
{ {
title: language === 'de' title: language === 'de'
@@ -365,6 +373,7 @@ const normalizeHealthAnalysis = (
return { return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
status, status,
analysisSummary,
likelyIssues, likelyIssues,
actionsNow: actionsNowRaw, actionsNow: actionsNowRaw,
plan7Days: plan7DaysRaw, plan7Days: plan7DaysRaw,

View File

@@ -156,6 +156,10 @@ export const AuthDb = {
); );
return user || null; return user || null;
}, },
deleteLocalUser(id: number): void {
getDb().runSync('DELETE FROM users WHERE id = ?', [id]);
},
}; };
// ─── Settings ────────────────────────────────────────────────────────────────── // ─── Settings ──────────────────────────────────────────────────────────────────

View File

@@ -37,6 +37,7 @@ export interface PlantHealthCheck {
generatedAt: string; generatedAt: string;
overallHealthScore: number; overallHealthScore: number;
status: 'healthy' | 'watch' | 'critical'; status: 'healthy' | 'watch' | 'critical';
analysisSummary?: string;
likelyIssues: PlantHealthIssue[]; likelyIssues: PlantHealthIssue[];
actionsNow: string[]; actionsNow: string[];
plan7Days: string[]; plan7Days: string[];

View File

@@ -251,6 +251,17 @@ export const translations = {
onboardingRegister: "Registrieren", onboardingRegister: "Registrieren",
onboardingLogin: "Anmelden", onboardingLogin: "Anmelden",
onboardingDisclaimer: "Deine Daten bleiben privat und lokal auf deinem Gerät.", onboardingDisclaimer: "Deine Daten bleiben privat und lokal auf deinem Gerät.",
welcomeHeadline: "Pflanzenpflege\nbeginnt hier",
welcomeSubheadline: "Scanne ein Blatt, erkenne die Pflanze und halte sie gesund.",
welcomeFeatureIdentifyTitle: "KI-Pflanzenerkennung",
welcomeFeatureIdentifyDesc: "Teste bis zu 5 Demo-Scans direkt auf diesem Gerät.",
welcomeFeatureReminderTitle: "Pflegeplan & Erinnerungen",
welcomeFeatureReminderDesc: "Erhalte klare Tipps für Gießen, Licht und Standort.",
welcomeFeatureLibraryTitle: "Pflanzen speichern",
welcomeFeatureLibraryDesc: "Registriere dich, um deine gescannten Pflanzen zu sichern.",
welcomeDemoScan: "Demo-Scan testen",
welcomeSubscriptionPlans: "Abo-Pläne & Preise ansehen",
welcomeLegal: "Abo-Details, Wiederherstellen, Nutzungsbedingungen und Datenschutzrichtlinie werden vor dem Kauf angezeigt.",
// Auth // Auth
createAccount: "Konto erstellen", createAccount: "Konto erstellen",
@@ -512,6 +523,17 @@ registerToSave: "Sign up to save",
onboardingRegister: "Sign Up", onboardingRegister: "Sign Up",
onboardingLogin: "Log In", onboardingLogin: "Log In",
onboardingDisclaimer: "Your data stays private and local on your device.", onboardingDisclaimer: "Your data stays private and local on your device.",
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.",
welcomeFeatureReminderTitle: "Care plan & reminders",
welcomeFeatureReminderDesc: "Get clear guidance for water, light, and placement.",
welcomeFeatureLibraryTitle: "Save your plants",
welcomeFeatureLibraryDesc: "Sign up to keep scanned plants in your collection.",
welcomeDemoScan: "Try Demo Scan",
welcomeSubscriptionPlans: "View Subscription Plans & Pricing",
welcomeLegal: "Subscription details, Restore, Terms of Use, and Privacy Policy are shown before purchase.",
// Auth // Auth
createAccount: "Create Account", createAccount: "Create Account",
@@ -773,6 +795,17 @@ registerToSave: "Regístrate para guardar",
onboardingRegister: "Registrarse", onboardingRegister: "Registrarse",
onboardingLogin: "Iniciar sesión", onboardingLogin: "Iniciar sesión",
onboardingDisclaimer: "Tus datos permanecen privados y locales en tu dispositivo.", onboardingDisclaimer: "Tus datos permanecen privados y locales en tu dispositivo.",
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.",
welcomeFeatureReminderTitle: "Plan de cuidado y recordatorios",
welcomeFeatureReminderDesc: "Recibe consejos claros sobre riego, luz y ubicación.",
welcomeFeatureLibraryTitle: "Guardar tus plantas",
welcomeFeatureLibraryDesc: "Regístrate para conservar las plantas escaneadas.",
welcomeDemoScan: "Probar escaneo demo",
welcomeSubscriptionPlans: "Ver planes y precios",
welcomeLegal: "Los detalles de suscripción, Restaurar, Términos de uso y Política de privacidad se muestran antes de comprar.",
// Auth // Auth
createAccount: "Crear cuenta", createAccount: "Crear cuenta",