Files
Greenlens/app/onboarding.tsx
2026-07-30 10:03:37 +02:00

516 lines
16 KiB
TypeScript

import React, { useEffect } from 'react';
import {
Image,
ImageBackground,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useApp } from '../context/AppContext';
import { useSafeAnalytics } from '../services/analytics';
import { getWelcomeHeadlineVariant, WelcomeHeadlineVariant } from '../services/experiments';
import { APP_STORE_RATING, getTestimonialForLanguage, translatedNote } from '../constants/socialProof';
import { Language } from '../types';
const getWelcomeCopy = (language: Language, variant: WelcomeHeadlineVariant) => {
if (language === 'de') {
return {
// Variante A (symptom): steigt direkt in Emmas akuten Moment ein.
// Variante B (contrast): grenzt gegen kostenlose Identifikations-Apps ab.
headline:
variant === 'symptom'
? 'Gelbe Blätter. Und jetzt?'
: 'Andere Apps sagen dir, wie deine Pflanze heißt.',
subline:
variant === 'symptom'
? 'GreenLens nennt dir die wahrscheinlichsten Ursachen, was du selbst prüfen solltest und die eine sichere Sache, die du jetzt tun kannst.'
: 'GreenLens sagt dir, was mit ihr los ist: wahrscheinliche Ursache, was du prüfen sollst, was du als Nächstes tust - und ein Check, ob es gewirkt hat.',
cta: 'Meine Pflanze prüfen',
login: 'Ich habe schon ein Konto',
demoScan: 'Erst ausprobieren - ohne Konto',
legal: 'Mit dem Fortfahren akzeptierst du unsere Datenschutzerklärung und AGB.',
ratingValue: APP_STORE_RATING.value.toLocaleString('de-DE', { minimumFractionDigits: 1 }),
ratingCount: `${APP_STORE_RATING.count} Bewertungen`,
};
}
if (language === 'es') {
return {
headline:
variant === 'symptom'
? 'Hojas amarillas. ¿Y ahora?'
: 'Otras apps te dicen cómo se llama tu planta.',
subline:
variant === 'symptom'
? 'GreenLens te da las causas más probables, qué revisar tú mismo y la única cosa segura que puedes hacer ahora.'
: 'GreenLens te dice qué le pasa: causa probable, qué revisar, qué hacer después - y una comprobación de si funcionó.',
cta: 'Revisar mi planta',
login: 'Ya tengo cuenta',
demoScan: 'Probar primero - sin cuenta',
legal: 'Al continuar aceptas nuestra Política de privacidad y Términos.',
ratingValue: APP_STORE_RATING.value.toLocaleString('es-ES', { minimumFractionDigits: 1 }),
ratingCount: `${APP_STORE_RATING.count} valoraciones`,
};
}
return {
headline:
variant === 'symptom'
? 'Yellow leaves. Now what?'
: 'Other apps tell you what your plant is called.',
subline:
variant === 'symptom'
? 'GreenLens gives you the most likely causes, what to check yourself, and the one safe thing to do right now.'
: "GreenLens tells you what's wrong with it: likely cause, what to check, what to do next - and a follow-up on whether it worked.",
cta: 'Check my plant',
login: 'I already have an account',
demoScan: 'Try it first - no account needed',
legal: 'By continuing you agree to our Privacy Policy and Terms.',
ratingValue: APP_STORE_RATING.value.toLocaleString('en-US', { minimumFractionDigits: 1 }),
ratingCount: `${APP_STORE_RATING.count} ratings`,
};
};
/**
* Proof-Punkte statt Testimonial: greifen, solange keine echten Store-Zitate
* hinterlegt sind. Beide Zeilen sind Produktaussagen, keine Behauptungen ueber
* Ergebnisse - sie halten damit die Positionierung "Triage statt Diagnose".
*/
const getProofPoints = (language: Language): string[] => {
if (language === 'de') {
return [
'Wahrscheinlichste Ursachen - mit Angabe, wie sicher wir sind',
'Konkrete Prüfschritte statt allgemeiner Pflegetipps',
'Nach 7 Tagen fragen wir nach, ob es gewirkt hat',
];
}
if (language === 'es') {
return [
'Causas más probables - indicando qué tan seguros estamos',
'Pasos concretos de revisión, no consejos genéricos',
'A los 7 días preguntamos si funcionó',
];
}
return [
'Most likely causes - with how confident we actually are',
'Concrete things to check, not generic care tips',
'After 7 days we ask whether it worked',
];
};
export default function OnboardingScreen() {
const { language } = useApp();
const { height, width } = useWindowDimensions();
const compact = height < 700 || width < 380;
const posthog = useSafeAnalytics();
const insets = useSafeAreaInsets();
// Der Hero bekommt den Rest der Hoehe, faellt aber nie unter diese Grenze.
// Vorher war er fest auf 52% - dadurch hat die laengere Headline-Variante
// den dritten Button aus dem Bildschirm geschoben.
const heroMinHeight = Math.round(height * (compact ? 0.34 : 0.40));
const [variant, setVariant] = React.useState<WelcomeHeadlineVariant | null>(null);
const copy = getWelcomeCopy(language, variant ?? 'symptom');
const testimonial = getTestimonialForLanguage(language);
const proofPoints = getProofPoints(language);
useEffect(() => {
let active = true;
void getWelcomeHeadlineVariant().then((assigned) => {
if (!active) return;
setVariant(assigned);
posthog.capture('onboarding_welcome_viewed', { welcome_variant: assigned });
});
return () => {
active = false;
};
}, [posthog]);
return (
<View style={styles.container}>
<Image
source={require('../assets/welcome_hero_vertical.png')}
style={[{ position: 'absolute', top: 0, left: 0, right: 0, width: '100%' }, { height: compact ? '65%' : '70%' }]}
resizeMode="cover"
/>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
bounces={false}
alwaysBounceVertical={false}
>
<View style={[styles.heroBlock, { minHeight: heroMinHeight }]}>
<View style={[styles.heroSafe, { paddingTop: Math.max(insets.top, 16) }]}>
<View style={styles.heroTopRow}>
<View style={styles.brandRow}>
<Image
source={require('../assets/icon.png')}
style={styles.logo}
resizeMode="cover"
/>
<Text
style={styles.brandName}
maxFontSizeMultiplier={1.2}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.82}
>
Green<Text style={styles.brandAccent}>Lens</Text>
</Text>
</View>
{/* Rating und Anzahl immer gemeinsam: 5,0 aus 6 Bewertungen ist
ehrlich und ueberpruefbar - eine nackte Sternezahl waere es nicht. */}
<View style={styles.ratingPill}>
<Ionicons name="star" size={13} color="#f5c04e" />
<Text style={styles.ratingText} maxFontSizeMultiplier={1.2} numberOfLines={1}>
{copy.ratingValue}
</Text>
<Text
style={styles.ratingCount}
maxFontSizeMultiplier={1.2}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.72}
>
· {copy.ratingCount}
</Text>
</View>
</View>
{testimonial ? (
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
<Text
style={styles.testimonialText}
numberOfLines={compact ? 3 : 4}
maxFontSizeMultiplier={1.3}
>
{testimonial.quote}"
</Text>
<View style={styles.testimonialMeta}>
<Text style={styles.testimonialAuthor} maxFontSizeMultiplier={1.2}>
{testimonial.author}
{testimonial.isTranslated ? ` · ${translatedNote(language)}` : ''}
</Text>
<View style={styles.starsRow}>
{Array.from({ length: testimonial.stars }).map((_, i) => (
<Ionicons key={i} name="star" size={14} color="#f5c04e" />
))}
</View>
</View>
</View>
) : (
/* Kein belegtes Zitat vorhanden → Argument statt erfundenem Social Proof. */
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
{proofPoints.map((point) => (
<View key={point} style={styles.proofRow}>
<Ionicons name="checkmark-circle" size={15} color="#a6d66f" />
<Text style={styles.proofText} maxFontSizeMultiplier={1.3}>{point}</Text>
</View>
))}
</View>
)}
</View>
</View>
<View style={[styles.sheet, { paddingBottom: Math.max(insets.bottom, 12) }]}>
<View style={styles.sheetHandle} />
<View style={styles.sheetContent}>
{/* Erst rendern, wenn die Variante feststeht. Sonst blitzt fuer die
Haelfte der Nutzer kurz die falsche Headline auf und wechselt dann -
das sieht kaputt aus und verfaelscht den Test. */}
<View style={{ opacity: variant ? 1 : 0 }}>
{/* maxFontSizeMultiplier: groessere Systemschrift darf die Headline
wachsen lassen, aber nicht die CTAs aus dem Screen schieben. */}
<Text
style={[styles.headline, compact && styles.headlineCompact]}
maxFontSizeMultiplier={1.25}
>
{copy.headline}
</Text>
<Text style={styles.subline} maxFontSizeMultiplier={1.3}>{copy.subline}</Text>
</View>
<View style={styles.spacer} />
<TouchableOpacity
style={styles.cta}
onPress={() => {
posthog.capture('onboarding_started', { welcome_variant: variant ?? 'unassigned' });
router.push('/onboarding/slides');
}}
activeOpacity={0.86}
>
<Text style={styles.ctaText} maxFontSizeMultiplier={1.2}>{copy.cta}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/auth/login')} style={styles.loginLink}>
<Text style={styles.loginText} maxFontSizeMultiplier={1.2}>{copy.login}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
posthog.capture('onboarding_demo_scan_started', {
welcome_variant: variant ?? 'unassigned',
});
router.push('/scanner');
}}
style={styles.demoLink}
>
<Ionicons name="scan-outline" size={18} color="#a6d66f" />
<Text style={styles.demoText} maxFontSizeMultiplier={1.2}>{copy.demoScan}</Text>
</TouchableOpacity>
<Text style={styles.legal} maxFontSizeMultiplier={1.3}>{copy.legal}</Text>
</View>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0a110b',
},
scroll: {
flex: 1,
},
scrollContent: {
// Fuellt mindestens den Bildschirm. Wird der Inhalt hoeher (lange
// Headline-Variante, grosse Systemschrift, kleines Geraet), scrollt der
// Screen - statt die unteren Buttons abzuschneiden.
flexGrow: 1,
},
heroBlock: {
width: '100%',
// Nimmt den Platz, den das Sheet uebrig laesst, faellt aber nie unter
// heroMinHeight. Vorher fest 52% - das hat nur bei kurzer Copy gepasst.
flexGrow: 1,
flexShrink: 0,
},
hero: {
width: '100%',
},
heroImageContent: {
backgroundColor: '#0a110b',
},
heroShadeTop: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: 120,
backgroundColor: 'rgba(10,17,11,0.4)',
},
heroSafe: {
flex: 1,
justifyContent: 'space-between',
paddingHorizontal: 24,
paddingBottom: 32,
},
heroTopRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
brandRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
flexShrink: 1,
},
logo: {
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: '#fff',
},
brandName: {
flexShrink: 1,
color: '#ffffff',
fontSize: 24,
fontWeight: '900',
},
brandAccent: {
color: '#8ba885',
},
ratingPill: {
flexShrink: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginLeft: 10,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.25)',
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 8,
},
ratingText: {
color: '#ffffff',
fontSize: 12,
fontWeight: '700',
},
ratingCount: {
flexShrink: 1,
color: '#c9d3c2',
fontSize: 11,
fontWeight: '600',
},
proofRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
marginBottom: 8,
},
proofText: {
flex: 1,
color: '#e6efe2',
fontSize: 13.5,
lineHeight: 18,
fontWeight: '600',
},
testimonialCard: {
backgroundColor: 'rgba(16, 26, 18, 0.75)',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 14,
elevation: 4,
marginBottom: 8,
},
testimonialCardCompact: {
padding: 12,
},
testimonialText: {
color: '#ffffff',
fontSize: 14.5,
lineHeight: 20,
fontStyle: 'italic',
marginBottom: 10,
},
testimonialMeta: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
testimonialAuthor: {
flex: 1,
flexShrink: 1,
marginRight: 8,
color: '#a6d66f',
fontSize: 13,
fontWeight: '700',
},
starsRow: {
flexDirection: 'row',
gap: 1,
},
sheet: {
flexShrink: 0,
backgroundColor: '#0c160d',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
marginTop: -20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.05)',
},
sheetHandle: {
alignSelf: 'center',
width: 44,
height: 5,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.2)',
marginTop: 12,
marginBottom: 8,
},
sheetContent: {
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 12,
},
headline: {
color: '#ffffff',
fontSize: 28,
lineHeight: 34,
fontWeight: '900',
textAlign: 'center',
marginBottom: 10,
},
headlineCompact: {
fontSize: 24,
lineHeight: 29,
},
subline: {
color: '#dae6d6',
fontSize: 16,
lineHeight: 22,
fontWeight: '500',
textAlign: 'center',
},
spacer: {
height: 14,
},
cta: {
minHeight: 58,
paddingVertical: 16,
borderRadius: 16,
backgroundColor: '#437824',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4,
},
ctaText: {
color: '#f8f7ef',
fontSize: 18,
fontWeight: '800',
},
loginLink: {
alignItems: 'center',
paddingVertical: 9,
},
loginText: {
color: '#a6d66f',
fontSize: 15,
fontWeight: '800',
},
demoLink: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
minHeight: 50,
paddingVertical: 13,
borderRadius: 14,
borderWidth: 1.5,
borderColor: '#a6d66f',
backgroundColor: 'rgba(166, 214, 111, 0.05)',
marginTop: 6,
marginBottom: 10,
},
demoText: {
color: '#a6d66f',
fontSize: 15,
fontWeight: '700',
},
legal: {
color: '#8d9382',
fontSize: 11,
lineHeight: 14,
fontWeight: '500',
textAlign: 'center',
},
});