This commit is contained in:
2026-07-30 10:03:37 +02:00
parent 018b751723
commit c9e776ae98
19 changed files with 1256 additions and 223 deletions

View File

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import {
Image,
ImageBackground,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
@@ -103,10 +104,14 @@ const getProofPoints = (language: Language): string[] => {
export default function OnboardingScreen() {
const { language } = useApp();
const { height } = useWindowDimensions();
const compact = height < 700;
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');
@@ -132,104 +137,138 @@ export default function OnboardingScreen() {
style={[{ position: 'absolute', top: 0, left: 0, right: 0, width: '100%' }, { height: compact ? '65%' : '70%' }]}
resizeMode="cover"
/>
<View style={[{ width: '100%' }, { height: compact ? '48%' : '52%' }]}>
<View style={[styles.heroSafe, { paddingTop: Math.max(insets.top, 16) }]}>
<View style={styles.heroTopRow}>
<View style={styles.brandRow}>
<Image
source={require('../assets/icon.png')}
style={styles.logo}
resizeMode="cover"
/>
<Text style={styles.brandName}>
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.ratingValue}</Text>
<Text style={styles.ratingCount}>· {copy.ratingCount}</Text>
</View>
</View>
{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)}` : ''}
<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 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}>{point}</Text>
{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>
)}
</View>
</View>
<View style={styles.sheet}>
<View style={styles.sheetHandle} />
<View style={styles.sheetContent}>
<View style={styles.topSpacer} />
{/* 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>
) : (
/* 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 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}>{copy.cta}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/auth/login')} style={styles.loginLink}>
<Text style={styles.loginText}>{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}>{copy.demoScan}</Text>
</TouchableOpacity>
<Text style={styles.legal}>{copy.legal}</Text>
</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>
);
}
@@ -239,6 +278,22 @@ const styles = StyleSheet.create({
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%',
},
@@ -269,6 +324,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 10,
flexShrink: 1,
},
logo: {
width: 36,
@@ -277,6 +333,7 @@ const styles = StyleSheet.create({
backgroundColor: '#fff',
},
brandName: {
flexShrink: 1,
color: '#ffffff',
fontSize: 24,
fontWeight: '900',
@@ -285,9 +342,11 @@ const styles = StyleSheet.create({
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)',
@@ -301,6 +360,7 @@ const styles = StyleSheet.create({
fontWeight: '700',
},
ratingCount: {
flexShrink: 1,
color: '#c9d3c2',
fontSize: 11,
fontWeight: '600',
@@ -347,6 +407,9 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
},
testimonialAuthor: {
flex: 1,
flexShrink: 1,
marginRight: 8,
color: '#a6d66f',
fontSize: 13,
fontWeight: '700',
@@ -356,7 +419,7 @@ const styles = StyleSheet.create({
gap: 1,
},
sheet: {
flex: 1,
flexShrink: 0,
backgroundColor: '#0c160d',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
@@ -374,14 +437,10 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
sheetContent: {
flex: 1,
paddingHorizontal: 24,
paddingTop: 8,
paddingTop: 12,
paddingBottom: 12,
},
topSpacer: {
height: 12,
},
headline: {
color: '#ffffff',
fontSize: 28,
@@ -402,15 +461,16 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
spacer: {
height: 18,
height: 14,
},
cta: {
height: 60,
minHeight: 58,
paddingVertical: 16,
borderRadius: 16,
backgroundColor: '#437824',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 6,
marginBottom: 4,
},
ctaText: {
color: '#f8f7ef',
@@ -419,7 +479,7 @@ const styles = StyleSheet.create({
},
loginLink: {
alignItems: 'center',
paddingVertical: 10,
paddingVertical: 9,
},
loginText: {
color: '#a6d66f',
@@ -431,13 +491,14 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
gap: 8,
height: 52,
minHeight: 50,
paddingVertical: 13,
borderRadius: 14,
borderWidth: 1.5,
borderColor: '#a6d66f',
backgroundColor: 'rgba(166, 214, 111, 0.05)',
marginVertical: 8,
marginBottom: 12,
marginTop: 6,
marginBottom: 10,
},
demoText: {
color: '#a6d66f',