This commit is contained in:
2026-07-27 16:42:04 +02:00
parent 45a7704ea6
commit 018b751723
23 changed files with 2327 additions and 635 deletions

View File

@@ -13,58 +13,116 @@ 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) => {
const getWelcomeCopy = (language: Language, variant: WelcomeHeadlineVariant) => {
if (language === 'de') {
return {
headline: 'Willkommen bei GreenLens!',
subline: 'Pflanzen erkennen, verstehen und pflegen — ganz einfach.',
testimonial: '„Endlich überleben meine Pflanzen! Absolute Empfehlung."',
testimonialAuthor: 'Anna M.',
cta: "Los geht's",
login: 'Anmelden',
demoScan: 'Oder direkt eine Pflanze scannen',
// 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.',
rating: '4,8',
ratingValue: APP_STORE_RATING.value.toLocaleString('de-DE', { minimumFractionDigits: 1 }),
ratingCount: `${APP_STORE_RATING.count} Bewertungen`,
};
}
if (language === 'es') {
return {
headline: '¡Bienvenido a GreenLens!',
subline: 'Identifica, entiende y cuida tus plantas — sin esfuerzo.',
testimonial: '"¡Por fin mis plantas sobreviven! Muy recomendable."',
testimonialAuthor: 'Anna M.',
cta: 'Empezar',
login: 'Iniciar sesión',
demoScan: 'O escanea una planta ahora',
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.',
rating: '4.8',
ratingValue: APP_STORE_RATING.value.toLocaleString('es-ES', { minimumFractionDigits: 1 }),
ratingCount: `${APP_STORE_RATING.count} valoraciones`,
};
}
return {
headline: 'Welcome to GreenLens!',
subline: 'Identify, understand and care for your plants — effortlessly.',
testimonial: '"Finally my plants stay alive! Highly recommend."',
testimonialAuthor: 'Anna M.',
cta: "Let's Go",
login: 'Log in',
demoScan: 'Or scan a plant right now',
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.',
rating: '4.8',
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 } = useWindowDimensions();
const compact = height < 700;
const posthog = useSafeAnalytics();
const copy = getWelcomeCopy(language);
const insets = useSafeAreaInsets();
const [variant, setVariant] = React.useState<WelcomeHeadlineVariant | null>(null);
const copy = getWelcomeCopy(language, variant ?? 'symptom');
const testimonial = getTestimonialForLanguage(language);
const proofPoints = getProofPoints(language);
useEffect(() => {
posthog.capture('onboarding_welcome_viewed');
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 (
@@ -87,23 +145,43 @@ export default function OnboardingScreen() {
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}>{copy.rating}</Text>
<Text style={styles.ratingText}>{copy.ratingValue}</Text>
<Text style={styles.ratingCount}>· {copy.ratingCount}</Text>
</View>
</View>
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
<Text style={styles.testimonialText}>{copy.testimonial}</Text>
<View style={styles.testimonialMeta}>
<Text style={styles.testimonialAuthor}>{copy.testimonialAuthor}</Text>
<View style={styles.starsRow}>
{[0, 1, 2, 3, 4].map((i) => (
<Ionicons key={i} name="star" size={14} color="#f5c04e" />
))}
{testimonial ? (
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
<Text style={styles.testimonialText} numberOfLines={compact ? 3 : 4}>
{testimonial.quote}"
</Text>
<View style={styles.testimonialMeta}>
<Text style={styles.testimonialAuthor}>
{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>
</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}>{point}</Text>
</View>
))}
</View>
)}
</View>
</View>
@@ -111,15 +189,20 @@ export default function OnboardingScreen() {
<View style={styles.sheetHandle} />
<View style={styles.sheetContent}>
<View style={styles.topSpacer} />
<Text style={[styles.headline, compact && styles.headlineCompact]}>{copy.headline}</Text>
<Text style={styles.subline}>{copy.subline}</Text>
{/* 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 }}>
<Text style={[styles.headline, compact && styles.headlineCompact]}>{copy.headline}</Text>
<Text style={styles.subline}>{copy.subline}</Text>
</View>
<View style={styles.spacer} />
<TouchableOpacity
style={styles.cta}
onPress={() => {
posthog.capture('onboarding_started');
posthog.capture('onboarding_started', { welcome_variant: variant ?? 'unassigned' });
router.push('/onboarding/slides');
}}
activeOpacity={0.86}
@@ -131,7 +214,15 @@ export default function OnboardingScreen() {
<Text style={styles.loginText}>{copy.login}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/scanner')} style={styles.demoLink}>
<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}>{copy.demoScan}</Text>
</TouchableOpacity>
@@ -209,6 +300,24 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '700',
},
ratingCount: {
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,