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

@@ -69,20 +69,31 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
const sqliteMarker = AppMetaDb.get('install_marker_v2');
const secureMarker = await SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null);
if (sqliteMarker === '1' && secureMarker === '1') {
return false; // Alles gut, keine Neuinstallation
}
if (sqliteMarker === '1' || secureMarker === '1') {
// Teilweise vorhanden -> heilen, nicht löschen
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
// WICHTIG: Nur der SQLite-Marker taugt als Neuinstallations-Erkennung.
// SecureStore liegt auf iOS im Keychain, und Keychain-Eintraege ueberleben
// das Loeschen der App. Nach einer Neuinstallation gilt also immer
// sqliteMarker === null && secureMarker === '1' - das ist der Normalfall,
// nicht ein halb geschriebener Zustand.
//
// Vorher lief genau dieser Fall in den "heilen"-Zweig: die Session blieb
// bestehen, der Nutzer war nach der Neuinstallation weiter eingeloggt und
// hat das Onboarding nie wieder gesehen (Gate: !session && !hasCompletedOnboarding).
if (sqliteMarker === '1') {
// App-Daten vorhanden -> laufende Installation. Keychain-Marker nachziehen,
// falls er fehlt (z. B. nach einem Keychain-Reset).
if (secureMarker !== '1') {
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
}
return false;
}
// Fresh Install: Alles zurücksetzen
// Fresh Install: Alles zurücksetzen. Der Nutzer landet danach auf dem
// Welcome-Screen und durchläuft das Onboarding erneut - inklusive
// A/B-Zuweisung, die ebenfalls im geloeschten App-Storage lag.
await AuthService.logout();
await AsyncStorage.removeItem('greenlens_show_tour');
// Experiment-Zuweisungen gehoeren zur Installation, nicht zum Keychain.
await AsyncStorage.removeItem('greenlens_experiment_welcome_headline_v1');
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
return true;
@@ -152,6 +163,7 @@ function RootLayoutInner() {
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/slides" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/health-check" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/experience" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
@@ -182,6 +194,7 @@ function RootLayoutInner() {
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/slides" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/health-check" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/experience" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />

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,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { 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';
@@ -16,57 +16,84 @@ const ONBOARDING_BACKGROUND = {
const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
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.',
title: 'So sieht eine Antwort aus',
subtitle: 'Ein echtes Beispiel - damit du weißt, was du bekommst, bevor du etwas entscheidest.',
exampleSymptomLabel: 'Symptom',
exampleSymptom: 'Untere Blätter gelb, Erde feucht',
causeLabel: 'Wahrscheinlichste Ursache',
// Bewusst ein Fall MITTLERER Sicherheit, kein Vorzeige-Treffer - das ist
// der Beweis fuer Belief 3. Die Werte summieren sich absichtlich NICHT auf
// 100 %: der Backend-Prompt (server/lib/openai.js, likelyIssues) liefert je
// Ursache eine unabhaengige Sicherheit, keine Wahrscheinlichkeitsverteilung.
// Ein Beispiel, das sich auf 100 addiert, wuerde ein Verhalten zeigen, das
// das Produkt nicht hat.
causes: [
{ name: 'Überwässerung', level: '64 %', tone: 'high' as const },
{ name: 'Nährstoffmangel', level: '41 %', tone: 'mid' as const },
{ name: 'Lichtmangel', level: '22 %', tone: 'low' as const },
],
guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.',
// Schwelle 65 % entspricht der Kalibrierung im Backend-Prompt:
// 0.65-0.84 "sehr wahrscheinlich", 0.40-0.64 "mehrdeutig".
confidenceNote: 'Unter 65 % sagen wir dir das deutlich - dann sind die Prüfschritte wichtiger als die Ursache.',
checkLabel: 'Prüfe zuerst',
checks: ['Erde 3 cm tief anfassen', 'Hat der Topf Abzugslöcher?', 'Blattunterseiten ansehen'],
actionLabel: 'Tu jetzt',
action: 'Nicht gießen. In 5 Tagen erneut prüfen.',
followUpLabel: 'In 7 Tagen',
followUp: 'Wir fragen nach, ob es besser wird - und passen den Plan an, wenn nicht.',
limitNote: 'Ein Foto zeigt keine Wurzeln, keine Erdfeuchte und keine Vorgeschichte. Deshalb bekommst du Wahrscheinlichkeiten und Prüfschritte - keine Diagnose.',
guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto präziser der Plan.',
cta: 'Weiter',
skip: 'Später',
};
}
if (language === 'es') {
return {
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.',
title: 'Así se ve una respuesta',
subtitle: 'Un ejemplo real - para que sepas qué recibes antes de decidir nada.',
exampleSymptomLabel: 'Síntoma',
exampleSymptom: 'Hojas inferiores amarillas, sustrato húmedo',
causeLabel: 'Causa más probable',
causes: [
{ name: 'Exceso de riego', level: '64 %', tone: 'high' as const },
{ name: 'Falta de nutrientes', level: '41 %', tone: 'mid' as const },
{ name: 'Falta de luz', level: '22 %', tone: 'low' as const },
],
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.',
confidenceNote: 'Por debajo del 65 % te lo decimos claramente - entonces los pasos de revisión importan más que la causa.',
checkLabel: 'Revisa primero',
checks: ['Tocar el sustrato a 3 cm', '¿La maceta tiene drenaje?', 'Mirar el reverso de las hojas'],
actionLabel: 'Haz ahora',
action: 'No regar. Volver a revisar en 5 días.',
followUpLabel: 'En 7 días',
followUp: 'Preguntamos si va mejor - y ajustamos el plan si no.',
limitNote: 'Una foto no muestra las raíces, la humedad del sustrato ni el historial. Por eso recibes probabilidades y pasos de revisión - no un diagnóstico.',
guidanceNote: 'Consejo: fotografía la planta completa, el reverso de las hojas y el sustrato. Cuanto más clara sea la foto, más preciso será el plan.',
cta: 'Continuar',
skip: 'Más tarde',
};
}
return {
title: 'Where is the health scan?',
subtitle: 'It lives on every saved plant, directly below the plant description.',
buttonPreview: 'Start health scan',
title: 'This is what an answer looks like',
subtitle: 'A real example - so you know what you get before you decide anything.',
exampleSymptomLabel: 'Symptom',
exampleSymptom: 'Lower leaves yellow, soil damp',
causeLabel: 'Most likely cause',
causes: [
{ name: 'Overwatering', level: '64%', tone: 'high' as const },
{ name: 'Nutrient deficiency', level: '41%', tone: 'mid' as const },
{ name: 'Too little light', level: '22%', tone: 'low' as const },
],
confidenceNote: 'Below 65% we say so plainly - then the checks matter more than the cause.',
checkLabel: 'Check first',
checks: ['Feel the soil 3 cm down', 'Does the pot have drainage holes?', 'Look at the leaf undersides'],
actionLabel: 'Do now',
action: 'Do not water. Check again in 5 days.',
followUpLabel: 'In 7 days',
followUp: 'We ask whether it is improving - and adjust the plan if it is not.',
limitNote: 'A photo cannot show roots, soil moisture or history. So you get probabilities and things to check - not a diagnosis.',
guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.',
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.',
};
};
@@ -105,36 +132,73 @@ export default function HealthCheckOnboardingScreen() {
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<ImageBackground
source={require('../../assets/onboarding_health_scan_mockup.png')}
style={[styles.illustration, { borderColor: colors.border, backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]}
imageStyle={styles.illustrationImage}
resizeMode="cover"
>
<View style={[styles.illustrationOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.08)' : 'rgba(251, 250, 243, 0.04)' }]} />
</ImageBackground>
{/* Worked Example statt Wegbeschreibung: ein echtes Ergebnis schlaegt
jede Behauptung ueber die Qualitaet der Ergebnisse. */}
<View style={[styles.exampleCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.exampleEyebrow, { color: colors.textMuted }]}>{copy.exampleSymptomLabel}</Text>
<Text style={[styles.exampleSymptom, { color: colors.text }]}>{copy.exampleSymptom}</Text>
<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 style={[styles.divider, { backgroundColor: colors.border }]} />
<Text style={[styles.exampleEyebrow, { color: colors.textMuted }]}>{copy.causeLabel}</Text>
{copy.causes.map((cause) => (
<View key={cause.name} style={styles.causeRow}>
<View
style={[
styles.causeDot,
{
backgroundColor:
cause.tone === 'high' ? colors.danger : cause.tone === 'mid' ? colors.warning : colors.textMuted,
},
]}
/>
<Text
style={[
styles.causeName,
{ color: cause.tone === 'high' ? colors.text : colors.textSecondary },
cause.tone === 'high' && styles.causeNamePrimary,
]}
>
{cause.name}
</Text>
<Text style={[styles.causeLevel, { color: colors.textMuted }]}>{cause.level}</Text>
</View>
))}
<View style={[styles.confidenceNote, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="alert-circle-outline" size={14} color={colors.textMuted} />
<Text style={[styles.confidenceNoteText, { color: colors.textSecondary }]}>{copy.confidenceNote}</Text>
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<Text style={[styles.exampleEyebrow, { color: colors.textMuted }]}>{copy.checkLabel}</Text>
{copy.checks.map((check) => (
<View key={check} style={styles.outputRow}>
<Ionicons name="ellipse-outline" size={15} color={colors.primary} />
<Text style={[styles.outputText, { color: colors.textSecondary }]}>{check}</Text>
</View>
))}
<View style={[styles.actionBox, { backgroundColor: colors.primarySoft }]}>
<Text style={[styles.actionLabel, { color: colors.primaryDark }]}>{copy.actionLabel}</Text>
<Text style={[styles.actionText, { color: colors.primaryDark }]}>{copy.action}</Text>
</View>
<View style={styles.followUpRow}>
<Ionicons name="chatbubble-ellipses-outline" size={15} color={colors.textMuted} />
<View style={styles.followUpCopy}>
<Text style={[styles.followUpLabel, { color: colors.text }]}>{copy.followUpLabel}</Text>
<Text style={[styles.followUpText, { color: colors.textSecondary }]}>{copy.followUp}</Text>
</View>
</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>
))}
{/* Die Grenze des Verfahrens steht bewusst direkt unter dem Beispiel,
nicht im Kleingedruckten. Sie ist Teil des Verkaufsarguments. */}
<View style={[styles.limitCard, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
<Ionicons name="information-circle-outline" size={17} color={colors.textMuted} />
<Text style={[styles.limitText, { color: colors.textSecondary }]}>{copy.limitNote}</Text>
</View>
<View style={[styles.guidanceCard, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
@@ -170,30 +234,36 @@ const styles = StyleSheet.create({
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 },
outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9, marginBottom: 6 },
exampleCard: { borderRadius: 20, borderWidth: 1, padding: 18, gap: 4 },
exampleEyebrow: { fontSize: 10.5, fontWeight: '900', letterSpacing: 0.9, textTransform: 'uppercase', marginBottom: 6 },
exampleSymptom: { fontSize: 16, fontWeight: '700', lineHeight: 22 },
divider: { height: 1, marginVertical: 14 },
causeRow: { flexDirection: 'row', alignItems: 'center', gap: 9, marginBottom: 8 },
causeDot: { width: 8, height: 8, borderRadius: 4 },
causeName: { flex: 1, fontSize: 14.5, fontWeight: '600' },
causeNamePrimary: { fontWeight: '800' },
causeLevel: { fontSize: 12.5, fontWeight: '800', fontVariant: ['tabular-nums'] },
confidenceNote: { flexDirection: 'row', alignItems: 'flex-start', gap: 7, borderRadius: 10, padding: 10, marginTop: 4 },
confidenceNoteText: { flex: 1, fontSize: 12, lineHeight: 16.5, fontWeight: '500' },
actionBox: { borderRadius: 14, padding: 13, marginTop: 12, gap: 3 },
actionLabel: { fontSize: 10.5, fontWeight: '900', letterSpacing: 0.9, textTransform: 'uppercase' },
actionText: { fontSize: 14.5, fontWeight: '700', lineHeight: 20 },
followUpRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9, marginTop: 14 },
followUpCopy: { flex: 1, gap: 2 },
followUpLabel: { fontSize: 13, fontWeight: '800' },
followUpText: { fontSize: 13, lineHeight: 18, fontWeight: '500' },
limitCard: { borderRadius: 16, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
limitText: { flex: 1, fontSize: 12.5, lineHeight: 18, fontWeight: '500' },
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' },

View File

@@ -7,33 +7,116 @@ import Svg, { Circle } from 'react-native-svg';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { useSafeAnalytics } from '../../services/analytics';
import { PreAuthOnboardingService, PreAuthAnswers } from '../../services/preAuthOnboardingService';
import { Language } from '../../types';
const getCopy = (language: Language) => {
if (language === 'de') {
return {
status: 'Dein Pflegeplan wird personalisiert…',
steps: ['Antworten werden analysiert', 'Pflegeplan wird erstellt', 'Scan-Credits werden vorbereitet', 'Plan wird finalisiert'],
testimonial: '„GreenLens hat meine Geigenfeige gerettet. Die täglichen Routinen sind unglaublich präzise."',
author: 'Elena R.',
rating: '4,8 APP-STORE-BEWERTUNG',
status: 'Dein Plan wird zusammengestellt…',
// Schritte beschreiben den Nutzen fuer den Nutzer, nicht unsere Prozesse.
steps: [
'Deine Antworten werden ausgewertet',
'Mögliche Ursachen für dein Symptom werden gewichtet',
'Prüfschritte für deine Lichtsituation werden ausgewählt',
'Dein 7-Tage-Plan steht',
],
summaryTitle: 'Dein Plan basiert auf',
noAnswers: 'Deinen Angaben aus dem Gespräch',
situations: {
acute: 'Akutes Problem',
slow: 'Langsame Veränderung',
prevention: 'Vorbeugung',
} as Record<string, string>,
symptoms: {
yellow: 'Gelbe Blätter',
brown_tips: 'Braune Spitzen',
drooping: 'Hängende Blätter',
spots: 'Flecken oder Belag',
pests: 'Schädlinge',
other: 'Anderes Symptom',
} as Record<string, string>,
lights: {
bright_indirect: 'Helles, indirektes Licht',
low_light: 'Wenig Licht',
direct_sunlight: 'Direkte Sonne',
} as Record<string, string>,
experiences: {
beginner: 'Einsteigerin',
intermediate: 'Etwas Erfahrung',
advanced: 'Erfahren',
} as Record<string, string>,
};
}
if (language === 'es') {
return {
status: 'Personalizando tu plan de cuidados…',
steps: ['Analizando tus respuestas', 'Creando tu plan de cuidados', 'Preparando tus créditos de escaneo', 'Finalizando tu plan'],
testimonial: '"GreenLens salvó mi ficus lyrata. Las rutinas diarias son increíblemente precisas."',
author: 'Elena R.',
rating: '4.8 VALORACIÓN EN APP STORE',
status: 'Preparando tu plan…',
steps: [
'Analizando tus respuestas',
'Ponderando las causas posibles de tu síntoma',
'Eligiendo pasos de revisión para tu luz',
'Tu plan de 7 días está listo',
],
summaryTitle: 'Tu plan se basa en',
noAnswers: 'Lo que nos contaste en el chat',
situations: {
acute: 'Problema agudo',
slow: 'Cambio lento',
prevention: 'Prevención',
} as Record<string, string>,
symptoms: {
yellow: 'Hojas amarillas',
brown_tips: 'Puntas marrones',
drooping: 'Hojas caídas',
spots: 'Manchas o residuo',
pests: 'Plagas',
other: 'Otro síntoma',
} as Record<string, string>,
lights: {
bright_indirect: 'Luz brillante indirecta',
low_light: 'Poca luz',
direct_sunlight: 'Sol directo',
} as Record<string, string>,
experiences: {
beginner: 'Principiante',
intermediate: 'Algo de experiencia',
advanced: 'Con experiencia',
} as Record<string, string>,
};
}
return {
status: 'Personalizing your care plan…',
steps: ['Analyzing your answers', 'Building your care plan', 'Preparing your scan credits', 'Finalizing your plan'],
testimonial: '"GreenLens completely saved my Fiddle Leaf Fig. The daily routines feel incredibly precise."',
author: 'Elena R.',
rating: '4.8 APP STORE RATING',
status: 'Putting your plan together…',
steps: [
'Reviewing your answers',
'Weighing possible causes for your symptom',
'Picking checks for your light situation',
'Your 7-day plan is ready',
],
summaryTitle: 'Your plan is based on',
noAnswers: 'What you told us in the chat',
situations: {
acute: 'Acute problem',
slow: 'Slow change',
prevention: 'Prevention',
} as Record<string, string>,
symptoms: {
yellow: 'Yellow leaves',
brown_tips: 'Brown tips',
drooping: 'Drooping leaves',
spots: 'Spots or residue',
pests: 'Pests',
other: 'Other symptom',
} as Record<string, string>,
lights: {
bright_indirect: 'Bright, indirect light',
low_light: 'Low light',
direct_sunlight: 'Direct sun',
} as Record<string, string>,
experiences: {
beginner: 'Beginner',
intermediate: 'Some experience',
advanced: 'Experienced',
} as Record<string, string>,
};
};
@@ -52,8 +135,22 @@ export default function OnboardingPersonalizingScreen() {
const copy = getCopy(language);
const progress = useRef(new Animated.Value(0)).current;
const [percent, setPercent] = useState(0);
const [answers, setAnswers] = useState<PreAuthAnswers>({});
const navigated = useRef(false);
// Die eigenen Angaben zurueckspiegeln beweist "es geht um MEINE Pflanze"
// (Belief 4) durch Demonstration statt durch Behauptung.
useEffect(() => {
void PreAuthOnboardingService.getAnswers().then(setAnswers);
}, []);
const summaryChips = [
answers.situation ? copy.situations[answers.situation] : null,
answers.symptom ? copy.symptoms[answers.symptom] : null,
answers.lightLevel ? copy.lights[answers.lightLevel] : null,
answers.experienceLevel ? copy.experiences[answers.experienceLevel] : null,
].filter((chip): chip is string => Boolean(chip));
const strokeDashoffset = progress.interpolate({
inputRange: [0, 100],
outputRange: [RING_CIRCUMFERENCE, 0],
@@ -129,17 +226,18 @@ export default function OnboardingPersonalizingScreen() {
})}
</View>
<View style={[styles.testimonialCard, { backgroundColor: colors.surface }]}>
<View style={styles.testimonialHeader}>
<Text style={[styles.testimonialAuthor, { color: colors.text }]}>{copy.author}</Text>
<View style={styles.starsRow}>
{[0, 1, 2, 3, 4].map((i) => <Ionicons key={i} name="star" size={13} color="#f5c04e" />)}
</View>
<Text style={[styles.summaryTitle, { color: colors.textMuted }]}>{copy.summaryTitle}</Text>
<View style={styles.summaryChips}>
{summaryChips.length > 0 ? (
summaryChips.map((chip) => (
<View key={chip} style={[styles.summaryChip, { backgroundColor: colors.primarySoft }]}>
<Text style={[styles.summaryChipText, { color: colors.primaryDark }]}>{chip}</Text>
</View>
))
) : (
<Text style={[styles.testimonialText, { color: colors.textSecondary }]}>{copy.noAnswers}</Text>
)}
</View>
<Text style={[styles.testimonialText, { color: colors.textSecondary }]}>{copy.testimonial}</Text>
</View>
<View style={[styles.ratingBadge, { borderColor: colors.primary }]}>
<Ionicons name="ribbon-outline" size={16} color={colors.primary} />
<Text style={[styles.ratingText, { color: colors.primary }]}>{copy.rating}</Text>
</View>
</SafeAreaView>
);
@@ -156,10 +254,9 @@ const styles = StyleSheet.create({
checkRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
checkLabel: { fontSize: 16.5, fontWeight: '700' },
testimonialCard: { alignSelf: 'stretch', borderRadius: 18, padding: 16, marginBottom: 14 },
testimonialHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
testimonialAuthor: { fontSize: 14.5, fontWeight: '800' },
starsRow: { flexDirection: 'row', gap: 2 },
testimonialText: { fontSize: 14, lineHeight: 20, fontStyle: 'italic' },
ratingBadge: { flexDirection: 'row', alignItems: 'center', gap: 7, borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 9 },
ratingText: { fontSize: 12.5, fontWeight: '900', letterSpacing: 0.6 },
testimonialText: { fontSize: 14, lineHeight: 20 },
summaryTitle: { fontSize: 10.5, fontWeight: '900', letterSpacing: 0.9, textTransform: 'uppercase', marginBottom: 10 },
summaryChips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
summaryChip: { borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
summaryChipText: { fontSize: 12.5, fontWeight: '700' },
});

View File

@@ -15,24 +15,35 @@ const getSlidesCopy = (language: Language) => {
return {
slides: [
{
title: 'Scanne jede Pflanze',
body: 'Richte die Kamera auf eine Pflanze und GreenLens erkennt sie in Sekunden.',
// Slide 1 = Triage. Bewusst NICHT die Identifikation: die kann Google
// Lens gratis. Der Pitch startet erst nach der Identifikation.
title: 'Was ist mit meiner Pflanze los?',
body: 'Fotografiere das Symptom. Du bekommst die wahrscheinlichsten Ursachen - und was du prüfen sollst.',
},
{
title: 'Health Check & Pflegeplan',
body: 'GreenLens erkennt Probleme früh und erstellt deinen Rettungsplan.',
title: 'Ein konkreter Plan. Für diese Pflanze.',
body: 'Sofortmaßnahme, 7-Tage-Plan, und danach die Frage: Hat es gewirkt?',
},
{
title: 'Nie mehr Gießen vergessen',
body: 'Smarte Erinnerungen und deine Pflanzen-Bibliothek halten alles im Blick.',
// Der Screen, den keine Konkurrenz-App hat. Beantwortet den Einwand
// "Eine andere App hat mir selbstbewusst etwas Falsches gesagt."
title: 'Wir sagen dir auch, wenn wir unsicher sind.',
body: 'Ein Foto zeigt keine Wurzeln und keine Erdfeuchte. Deshalb Wahrscheinlichkeiten statt erfundener Gewissheit.',
},
],
resultChip: 'Monstera · 98%',
healthCheckLabel: 'Health Check',
overwateringDetected: 'Überwässerung erkannt',
rescuePlanReady: '7-Tage-Rettungsplan bereit',
waterReminder: 'Monstera gießen — heute',
fertilizeReminder: 'Basilikum düngen — in 3 Tagen',
causeTitle: 'Wahrscheinlichste Ursache',
causePrimary: 'Überwässerung',
causePrimaryLevel: '64 %',
causeSecondary: 'Nährstoffmangel',
causeSecondaryLevel: '41 %',
checkLabel: 'Prüfe zuerst',
checkItems: 'Erde 3 cm tief · Abzugslöcher · Blattunterseiten',
planLabel: '7-Tage-Plan',
planNow: 'Heute: nicht gießen',
planFollowUp: 'Tag 7: Wir fragen nach, ob es besser wird',
uncertainTitle: 'Was ein Foto nicht zeigt',
uncertainItems: ['Wurzeln', 'Erdfeuchte', 'Vorgeschichte'],
uncertainNote: 'Deshalb: Wahrscheinlichkeiten statt Diagnose.',
continueLabel: 'Weiter',
};
}
@@ -40,53 +51,70 @@ const getSlidesCopy = (language: Language) => {
return {
slides: [
{
title: 'Escanea cualquier planta',
body: 'Apunta la cámara a una planta y GreenLens la identifica en segundos.',
title: '¿Qué le pasa a mi planta?',
body: 'Fotografía el síntoma. Recibes las causas más probables - y qué revisar.',
},
{
title: 'Chequeo de salud y plan de cuidados',
body: 'GreenLens detecta problemas a tiempo y crea tu plan de rescate.',
title: 'Un plan concreto. Para esta planta.',
body: 'Acción inmediata, plan de 7 días, y luego la pregunta: ¿funcionó?',
},
{
title: 'No olvides regar nunca más',
body: 'Recordatorios inteligentes y tu biblioteca de plantas lo mantienen todo al día.',
title: 'También te decimos cuándo no estamos seguros.',
body: 'Una foto no muestra las raíces ni la humedad. Por eso probabilidades, no certezas inventadas.',
},
],
resultChip: 'Monstera · 98%',
healthCheckLabel: 'Chequeo de salud',
overwateringDetected: 'Exceso de riego detectado',
rescuePlanReady: 'Plan de rescate de 7 días listo',
waterReminder: 'Regar Monstera — hoy',
fertilizeReminder: 'Abonar albahaca — en 3 días',
causeTitle: 'Causa más probable',
causePrimary: 'Exceso de riego',
causePrimaryLevel: '64 %',
causeSecondary: 'Falta de nutrientes',
causeSecondaryLevel: '41 %',
checkLabel: 'Revisa primero',
checkItems: 'Sustrato a 3 cm · Drenaje · Reverso de las hojas',
planLabel: 'Plan de 7 días',
planNow: 'Hoy: no regar',
planFollowUp: 'Día 7: preguntamos si va mejor',
uncertainTitle: 'Lo que una foto no muestra',
uncertainItems: ['Raíces', 'Humedad', 'Historial'],
uncertainNote: 'Por eso: probabilidades, no diagnóstico.',
continueLabel: 'Continuar',
};
}
return {
slides: [
{
title: 'Scan Any Plant',
body: 'Point your camera at a plant and GreenLens identifies it in seconds.',
title: "What's wrong with my plant?",
body: 'Photograph the symptom. You get the most likely causes - and what to check.',
},
{
title: 'Health Check & Care Plan',
body: 'GreenLens spots problems early and builds a rescue plan for you.',
title: 'A concrete plan. For this plant.',
body: 'Immediate action, a 7-day plan, and then the question: did it work?',
},
{
title: 'Never Forget Watering',
body: 'Smart reminders and your personal plant library keep everything on track.',
title: "We also tell you when we're not sure.",
body: 'A photo cannot show roots or soil moisture. So: probabilities, not invented certainty.',
},
],
resultChip: 'Monstera · 98%',
healthCheckLabel: 'Health Check',
overwateringDetected: 'Overwatering detected',
rescuePlanReady: '7-day rescue plan ready',
waterReminder: 'Water Monstera — today',
fertilizeReminder: 'Fertilize Basil — in 3 days',
causeTitle: 'Most likely cause',
causePrimary: 'Overwatering',
causePrimaryLevel: '64%',
causeSecondary: 'Nutrient deficiency',
causeSecondaryLevel: '41%',
checkLabel: 'Check first',
checkItems: 'Soil 3 cm deep · Drainage holes · Leaf undersides',
planLabel: '7-day plan',
planNow: 'Today: do not water',
planFollowUp: 'Day 7: we ask whether it improved',
uncertainTitle: 'What a photo cannot show',
uncertainItems: ['Roots', 'Soil moisture', 'History'],
uncertainNote: 'So: probabilities, not diagnosis.',
continueLabel: 'Continue',
};
};
function ScanFrameOverlay({ resultChip, colors }: { resultChip: string; colors: ColorsType }) {
type SlidesCopy = ReturnType<typeof getSlidesCopy>;
/** Slide 1: Ursachen-Ranking statt Prozent-Behauptung. */
function CauseRankingOverlay({ copy, colors }: { copy: SlidesCopy; colors: ColorsType }) {
return (
<>
<View style={styles.scanFrameWrap} pointerEvents="none">
@@ -95,77 +123,75 @@ function ScanFrameOverlay({ resultChip, colors }: { resultChip: string; colors:
<View style={[styles.cornerBL, { borderColor: colors.primary }]} />
<View style={[styles.cornerBR, { borderColor: colors.primary }]} />
</View>
<View style={styles.resultChip}>
<Ionicons name="leaf" size={15} color={colors.primary} />
<Text style={styles.resultChipText}>{resultChip}</Text>
<View style={styles.healthCard}>
<Text style={styles.overlayEyebrow}>{copy.causeTitle}</Text>
<View style={[styles.healthRow, styles.healthRowWarning]}>
<Ionicons name="alert-circle" size={15} color="#C62828" />
<Text style={styles.healthRowWarningText}>
{copy.causePrimary} · {copy.causePrimaryLevel}
</Text>
</View>
<View style={[styles.healthRow, styles.healthRowNeutral]}>
<Ionicons name="help-circle-outline" size={15} color="#6b7280" />
<Text style={styles.healthRowNeutralText}>
{copy.causeSecondary} · {copy.causeSecondaryLevel}
</Text>
</View>
{/* In derselben Karte statt als zweites absolutes Element: zwei frei
positionierte Overlays ueberlappen sich, sobald Uebersetzungen die
Karte wachsen lassen. */}
<View style={styles.checkInline}>
<Text style={styles.checkChipLabel}>{copy.checkLabel}</Text>
<Text style={styles.checkChipText}>{copy.checkItems}</Text>
</View>
</View>
</>
);
}
function HealthCardOverlay({
label,
overwateringDetected,
rescuePlanReady,
}: {
label: string;
overwateringDetected: string;
rescuePlanReady: string;
}) {
/** Slide 2: der Plan inklusive Follow-up - das ist der Kaufgrund. */
function PlanOverlay({ copy }: { copy: SlidesCopy }) {
return (
<View style={styles.healthCard}>
<View style={styles.healthCardHeader}>
<View style={styles.healthCardIcon}>
<Ionicons name="medkit" size={16} color="#C62828" />
<Ionicons name="calendar" size={16} color="#2e7d32" />
</View>
<Text style={styles.healthCardTitle}>{label}</Text>
</View>
<View style={[styles.healthRow, styles.healthRowWarning]}>
<Ionicons name="warning" size={15} color="#C62828" />
<Text style={styles.healthRowWarningText}>{overwateringDetected}</Text>
<Text style={styles.healthCardTitle}>{copy.planLabel}</Text>
</View>
<View style={[styles.healthRow, styles.healthRowSuccess]}>
<Ionicons name="checkmark-circle" size={15} color="#2e7d32" />
<Text style={styles.healthRowSuccessText}>{rescuePlanReady}</Text>
<Ionicons name="water-outline" size={15} color="#2e7d32" />
<Text style={styles.healthRowSuccessText}>{copy.planNow}</Text>
</View>
<View style={[styles.healthRow, styles.healthRowNeutral]}>
<Ionicons name="chatbubble-ellipses-outline" size={15} color="#6b7280" />
<Text style={styles.healthRowNeutralText}>{copy.planFollowUp}</Text>
</View>
</View>
);
}
function ReminderChipsOverlay({ waterReminder, fertilizeReminder }: { waterReminder: string; fertilizeReminder: string }) {
const [waterLabel, waterMeta] = splitReminder(waterReminder);
const [fertilizeLabel, fertilizeMeta] = splitReminder(fertilizeReminder);
/** Slide 3: die Grenzen des Verfahrens, sichtbar gemacht. */
function UncertaintyOverlay({ copy }: { copy: SlidesCopy }) {
return (
<>
<View style={[styles.reminderChip, styles.reminderChipTop]}>
<View style={[styles.reminderIcon, { backgroundColor: '#dff2e6' }]}>
<Ionicons name="water" size={16} color="#2e7d32" />
</View>
<View>
<Text style={styles.reminderLabel}>{waterLabel}</Text>
<Text style={styles.reminderMeta}>{waterMeta}</Text>
<View style={styles.healthCard}>
<View style={styles.healthCardHeader}>
<View style={styles.healthCardIcon}>
<Ionicons name="eye-off-outline" size={16} color="#6b7280" />
</View>
<Text style={styles.healthCardTitle}>{copy.uncertainTitle}</Text>
</View>
<View style={[styles.reminderChip, styles.reminderChipBottom]}>
<View style={[styles.reminderIcon, { backgroundColor: '#e3f3c8' }]}>
<Ionicons name="leaf" size={16} color="#558b2f" />
{copy.uncertainItems.map((item) => (
<View key={item} style={[styles.healthRow, styles.healthRowNeutral]}>
<Ionicons name="close-circle-outline" size={15} color="#6b7280" />
<Text style={styles.healthRowNeutralText}>{item}</Text>
</View>
<View>
<Text style={styles.reminderLabel}>{fertilizeLabel}</Text>
<Text style={styles.reminderMeta}>{fertilizeMeta}</Text>
</View>
</View>
</>
))}
<Text style={styles.overlayNote}>{copy.uncertainNote}</Text>
</View>
);
}
// Splits "Water Monstera — today" into ["Water Monstera", "today"] for a two-line chip.
function splitReminder(text: string): [string, string] {
const parts = text.split('—').map((part) => part.trim());
if (parts.length === 2) return [parts[0], parts[1]];
return [text, ''];
}
export default function OnboardingSlidesScreen() {
const router = useRouter();
const { height } = useWindowDimensions();
@@ -178,7 +204,7 @@ export default function OnboardingSlidesScreen() {
const slide = copy.slides[page];
useEffect(() => {
posthog.capture('onboarding_slide_viewed', { index: page });
posthog.capture('onboarding_slide_viewed', { index: page, slide: ['triage', 'plan', 'uncertainty'][page] });
}, [page, posthog]);
const next = () => {
@@ -203,9 +229,9 @@ export default function OnboardingSlidesScreen() {
<Image
source={
page === 0
? require('../../assets/paywall_scan_background.png')
? require('../../assets/onboarding_health_scan_mockup_vertical.png')
: page === 1
? require('../../assets/onboarding_health_scan_mockup_vertical.png')
? require('../../assets/paywall_scan_background.png')
: require('../../assets/welcome_botanical_header.png')
}
style={styles.image}
@@ -216,17 +242,9 @@ export default function OnboardingSlidesScreen() {
<Ionicons name="arrow-back" size={20} color="#1f2520" />
</TouchableOpacity>
</SafeAreaView>
{page === 0 && <ScanFrameOverlay resultChip={copy.resultChip} colors={colors} />}
{page === 1 && (
<HealthCardOverlay
label={copy.healthCheckLabel}
overwateringDetected={copy.overwateringDetected}
rescuePlanReady={copy.rescuePlanReady}
/>
)}
{page === 2 && (
<ReminderChipsOverlay waterReminder={copy.waterReminder} fertilizeReminder={copy.fertilizeReminder} />
)}
{page === 0 && <CauseRankingOverlay copy={copy} colors={colors} />}
{page === 1 && <PlanOverlay copy={copy} />}
{page === 2 && <UncertaintyOverlay copy={copy} />}
</View>
<View style={[styles.sheet, { backgroundColor: colors.surface }]}>
<Text style={[styles.title, { color: colors.text }]}>{slide.title}</Text>
@@ -334,24 +352,6 @@ const styles = StyleSheet.create({
borderRightWidth: 4,
borderBottomRightRadius: 8,
},
resultChip: {
position: 'absolute',
bottom: '10%',
left: 20,
right: 20,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(255,255,255,0.94)',
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 12,
},
resultChipText: {
fontSize: 15,
fontWeight: '700',
color: '#1f2520',
},
// Health card overlay (slide 2)
healthCard: {
position: 'absolute',
@@ -406,43 +406,54 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: '#2e7d32',
},
// Reminder chips overlay (slide 3)
reminderChip: {
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: 'rgba(255,255,255,0.96)',
borderRadius: 999,
paddingVertical: 8,
paddingRight: 18,
paddingLeft: 8,
// Neutrale Zeile: bewusst grau, damit "weniger wahrscheinlich" und
// "was wir nicht sehen" optisch NICHT wie eine Zusage aussehen.
healthRowNeutral: {
backgroundColor: '#f1f2f0',
},
reminderChipTop: {
top: '24%',
right: 20,
healthRowNeutralText: {
fontSize: 13.5,
fontWeight: '600',
color: '#5b6158',
},
reminderChipBottom: {
top: '42%',
left: 20,
overlayEyebrow: {
fontSize: 10.5,
fontWeight: '900',
letterSpacing: 0.9,
textTransform: 'uppercase',
color: '#6b7280',
marginBottom: 8,
},
reminderIcon: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
overlayNote: {
fontSize: 11.5,
lineHeight: 15,
fontWeight: '600',
color: '#6b7280',
marginTop: 8,
},
reminderLabel: {
fontSize: 14,
fontWeight: '800',
checkInline: {
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.08)',
paddingTop: 9,
marginTop: 2,
},
// Ein Stack statt zwei absoluter Elemente: die Bloecke koennen sich nicht
// mehr ueberlappen, egal wie lang der uebersetzte Text wird.
checkChipLabel: {
fontSize: 10,
fontWeight: '900',
letterSpacing: 0.8,
textTransform: 'uppercase',
color: '#6b7280',
marginBottom: 3,
},
checkChipText: {
fontSize: 12.5,
lineHeight: 17,
fontWeight: '700',
color: '#1f2520',
},
reminderMeta: {
fontSize: 12,
fontWeight: '600',
color: '#5a8a3d',
},
// Reminder chips overlay (slide 3)
// Bottom sheet
sheet: {
flex: 1,

View File

@@ -37,35 +37,56 @@ interface Message {
const getChatCopy = (language: Language) => {
if (language === 'de') {
return {
title: 'Monstera',
title: 'GreenLens',
skip: 'Überspringen',
continue: 'Weiter',
today: 'Heute, 20:42 Uhr',
typing: 'schreibt...',
botGreeting: "Hi! Ich bin deine neue Monstera. 🌿 Ich freue mich riesig darauf, mit dir zu wachsen!",
botSource: "Als erstes: Wie hast du von GreenLens gehört?",
botGoal: "Verstanden! Und was ist dein Hauptziel mit deinen Pflanzen?",
botExperience: "Schön! Wie erfahren bist du in der Pflanzenpflege?",
botLight: "Damit ich glücklich bleibe und diese großen, schönen Blätter wachsen, muss ich ein bisschen über mein neues Zuhause wissen. Wie viel Licht bekomme ich hier?",
botComplete: "Perfekt! Ich bin bereit einzuziehen. Lass uns deinen Pflegeplan erstellen!",
// Reihenfolge folgt dem Nutzer, nicht dem Unternehmen: erst sein Problem,
// dann Kontext, zuletzt die Marketing-Frage.
botGreeting: 'Hi! Ich helfe dir herauszufinden, was mit deinen Pflanzen los ist. 🌿',
botSituation: 'Damit ich weiß, wo wir starten: Hat gerade eine deiner Pflanzen ein Problem?',
botSymptom: 'Verstanden. Was siehst du genau? Das grenzt die möglichen Ursachen am stärksten ein.',
botNoSymptom: 'Gut zu wissen - dann richten wir alles auf Vorbeugung aus.',
botLight: 'Licht entscheidet bei den meisten Problemen mit. Wie viel Licht bekommt deine Pflanze?',
botExperience: 'Fast geschafft. Wie sicher fühlst du dich bei Pflanzenpflege? Danach passe ich an, wie ausführlich ich erkläre.',
botSource: 'Letzte Frage, rein aus Neugier: Wie hast du von GreenLens gehört?',
botComplete: 'Perfekt. Ich stelle dir jetzt deinen Plan zusammen.',
stepSituation: 'Hat eine Pflanze gerade ein Problem?',
stepSymptom: 'Was siehst du?',
stepLight: 'Wie viel Licht bekommt sie?',
stepExperience: 'Wie sicher fühlst du dich?',
stepSource: 'Wie hast du von uns gehört?',
situations: {
acute: 'Ja - und es wird schnell schlechter',
slow: 'Ja - es verändert sich langsam',
prevention: 'Nein - ich will vorbeugen',
},
symptoms: {
yellow: 'Gelbe Blätter',
brown_tips: 'Braune Spitzen oder Ränder',
drooping: 'Hängt oder lässt die Blätter fallen',
spots: 'Flecken oder Belag',
pests: 'Krabbeltiere',
other: 'Etwas anderes',
},
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Freunde oder Familie',
search: 'Google oder Suche',
// Bewusst unwahrscheinliche Optionen: sie brechen das Muster der
// Standard-Attributionsfrage und machen den letzten Schritt leichter.
tv: 'Im Fernsehen (haben wir nicht - aber schön wärs)',
radio: 'Im Radio zwischen zwei Songs',
plant: 'Meine Pflanze hat es mir empfohlen',
other: 'Etwas anderes',
},
goals: {
identify: 'Pflanzen erkennen',
care: 'Pflegepläne',
collection: 'Sammlung vergrößern',
learn: 'Über Pflanzen lernen',
},
experiences: {
beginner: 'Anfänger (Ich bin neu)',
intermediate: 'Fortgeschritten (Ich habe Pflanzen)',
advanced: 'Experte (Ich weiß Bescheid)',
beginner: 'Unsicher - ich fange gerade an',
intermediate: 'Geht so - ich habe ein paar Pflanzen',
advanced: 'Sicher - ich kenne mich aus',
},
lights: {
bright_indirect: {
@@ -85,35 +106,52 @@ const getChatCopy = (language: Language) => {
}
if (language === 'es') {
return {
title: 'Monstera',
title: 'GreenLens',
skip: 'Omitir',
continue: 'Continuar',
today: 'Hoy, 8:42 PM',
typing: 'escribiendo...',
botGreeting: "¡Hola! Soy tu nueva Monstera. 🌿 ¡Estoy muy emocionada de crecer contigo!",
botSource: "Primero, ¿cómo te enteraste de GreenLens?",
botGoal: Entendido! ¿Y cuál es tu objetivo principal con tus plantas?",
botExperience: "¡Genial! ¿Qué experiencia tienes en el cuidado de plantas?",
botLight: "Para estar feliz y que crezcan esas hojas grandes y hermosas, necesito saber un poco sobre mi nuevo hogar. ¿Cuánta luz recibiré aquí?",
botComplete: "¡Perfecto! Estoy lista para mudarme. ¡Creemos tu plan de cuidados!",
botGreeting: '¡Hola! Te ayudo a averiguar qué les pasa a tus plantas. 🌿',
botSituation: 'Para saber por dónde empezamos: ¿alguna de tus plantas tiene un problema ahora mismo?',
botSymptom: 'Entendido. ¿Qué ves exactamente? Esto es lo que más acota las causas posibles.',
botNoSymptom: 'Bien saberlo - entonces lo enfocamos todo en la prevención.',
botLight: 'La luz influye en la mayoría de los problemas. ¿Cuánta luz recibe tu planta?',
botExperience: 'Casi listo. ¿Qué tan seguro te sientes cuidando plantas? Con eso ajusto cuánto explico.',
botSource: 'Última pregunta, por curiosidad: ¿cómo te enteraste de GreenLens?',
botComplete: 'Perfecto. Ahora preparo tu plan.',
stepSituation: '¿Alguna planta tiene un problema?',
stepSymptom: '¿Qué ves?',
stepLight: '¿Cuánta luz recibe?',
stepExperience: '¿Qué tan seguro te sientes?',
stepSource: '¿Cómo nos conociste?',
situations: {
acute: 'Sí - y empeora rápido',
slow: 'Sí - cambia poco a poco',
prevention: 'No - quiero prevenir',
},
symptoms: {
yellow: 'Hojas amarillas',
brown_tips: 'Puntas o bordes marrones',
drooping: 'Se cae o pierde hojas',
spots: 'Manchas o residuo',
pests: 'Bichos',
other: 'Otra cosa',
},
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Amigos o familia',
search: 'Google o búsqueda',
tv: 'En la tele (no tenemos anuncios - pero ojalá)',
radio: 'En la radio entre dos canciones',
plant: 'Me la recomendó mi planta',
other: 'Otra cosa',
},
goals: {
identify: 'Identificar plantas',
care: 'Horarios de cuidado',
collection: 'Crecer mi colección',
learn: 'Aprender sobre plantas',
},
experiences: {
beginner: 'Principiante (Soy nuevo)',
intermediate: 'Intermedio (Tengo plantas)',
advanced: 'Experto (Sé bastante)',
beginner: 'Inseguro - recién empiezo',
intermediate: 'Más o menos - tengo algunas plantas',
advanced: 'Seguro - sé bastante',
},
lights: {
bright_indirect: {
@@ -132,35 +170,52 @@ const getChatCopy = (language: Language) => {
};
}
return {
title: 'Monstera',
title: 'GreenLens',
skip: 'Skip',
continue: 'Continue',
today: 'Today, 8:42 PM',
typing: 'typing...',
botGreeting: "Hi! I'm your new Monstera. 🌿 I'm so excited to grow with you!",
botSource: "First, how did you hear about GreenLens?",
botGoal: "Got it! And what is your main goal with your plants?",
botExperience: "Nice! How experienced are you with plant care?",
botLight: "To stay happy and grow those big, beautiful leaves, I need to know a bit about my new home. How much light do I get here?",
botComplete: "Perfect! I'm ready to move in. Let's build your care plan!",
botGreeting: "Hi! I'll help you work out what's going on with your plants. 🌿",
botSituation: 'So I know where to start: is one of your plants having a problem right now?',
botSymptom: 'Got it. What exactly do you see? This narrows down the possible causes more than anything else.',
botNoSymptom: "Good to know - then we'll focus everything on prevention.",
botLight: 'Light plays a role in most problems. How much light does your plant get?',
botExperience: 'Almost done. How confident do you feel about plant care? I use that to adjust how much I explain.',
botSource: 'Last question, purely out of curiosity: how did you hear about GreenLens?',
botComplete: "Perfect. I'm putting your plan together now.",
stepSituation: 'Is a plant having a problem?',
stepSymptom: 'What do you see?',
stepLight: 'How much light does it get?',
stepExperience: 'How confident do you feel?',
stepSource: 'How did you hear about us?',
situations: {
acute: "Yes - and it's getting worse fast",
slow: "Yes - it's changing slowly",
prevention: 'No - I want to prevent problems',
},
symptoms: {
yellow: 'Yellow leaves',
brown_tips: 'Brown tips or edges',
drooping: 'Drooping or dropping leaves',
spots: 'Spots or residue',
pests: 'Bugs',
other: 'Something else',
},
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Friends or family',
search: 'Google or search',
tv: "On TV (we don't run ads - but a girl can dream)",
radio: 'On the radio between two songs',
plant: 'My plant recommended it',
other: 'Something else',
},
goals: {
identify: 'Identify plants',
care: 'Care schedules',
collection: 'Grow collection',
learn: 'Learn about plants',
},
experiences: {
beginner: 'Beginner (I\'m a new plant parent)',
intermediate: 'Intermediate (I have some plants)',
advanced: 'Advanced (I\'m a plant expert)',
beginner: "Not confident - I'm just starting",
intermediate: 'So-so - I have a few plants',
advanced: 'Confident - I know my way around',
},
lights: {
bright_indirect: {
@@ -179,6 +234,11 @@ const getChatCopy = (language: Language) => {
};
};
/** Fragenreihenfolge des Chats. Bewusst: Problem zuerst, Attribution zuletzt. */
const STEP_KEYS = ['situation', 'symptom', 'lightLevel', 'experienceLevel', 'acquisitionSource'] as const;
type StepKey = (typeof STEP_KEYS)[number];
const TOTAL_STEPS = STEP_KEYS.length;
const TypingIndicator = ({ colors }: { colors: any }) => {
const dot1 = useRef(new Animated.Value(0)).current;
const dot2 = useRef(new Animated.Value(0)).current;
@@ -233,7 +293,7 @@ export default function OnboardingChatScreen() {
const posthog = useSafeAnalytics();
const { session, colorPalette, language } = useApp();
// This screen is hardcoded dark (#131e14 backgrounds), so always use the
// dark color set theme-dependent text would be unreadable in light mode.
// dark color set - theme-dependent text would be unreadable in light mode.
const colors = useColors(true, colorPalette);
const copy = getChatCopy(language);
const { height } = useWindowDimensions();
@@ -244,10 +304,11 @@ export default function OnboardingChatScreen() {
const [isTyping, setIsTyping] = useState<boolean>(false);
const [showOptions, setShowOptions] = useState<boolean>(false);
const [answers, setAnswers] = useState({
acquisitionSource: null as string | null,
primaryGoal: null as string | null,
experienceLevel: null as string | null,
situation: null as string | null,
symptom: null as string | null,
lightLevel: null as string | null,
experienceLevel: null as string | null,
acquisitionSource: null as string | null,
});
const flatListRef = useRef<FlatList>(null);
@@ -296,9 +357,9 @@ export default function OnboardingChatScreen() {
setMessages((prev) => [
...prev,
{
id: 'question-source',
id: 'question-situation',
sender: 'bot',
text: copy.botSource,
text: copy.botSituation,
timestamp: copy.today,
},
]);
@@ -331,15 +392,17 @@ export default function OnboardingChatScreen() {
};
setMessages((prev) => [...prev, userMsg]);
const nextStep = currentStep + 1;
// Wer "vorbeugen" waehlt, hat kein Symptom - die Symptomfrage wird
// uebersprungen. Ohne das antwortet der Bot "dann beugen wir vor" und
// fragt im selben Atemzug "Was siehst du?".
const skipsSymptom = currentStep === 0 && id === 'prevention';
const nextStep = skipsSymptom ? 2 : currentStep + 1;
setCurrentStep(nextStep);
// Save answer
const nextAnswers = { ...answers };
if (currentStep === 0) nextAnswers.acquisitionSource = id;
else if (currentStep === 1) nextAnswers.primaryGoal = id;
else if (currentStep === 2) nextAnswers.experienceLevel = id;
else if (currentStep === 3) nextAnswers.lightLevel = id;
const stepKey: StepKey | undefined = STEP_KEYS[currentStep];
if (stepKey) nextAnswers[stepKey] = id;
setAnswers(nextAnswers);
// Trigger bot response typing
@@ -350,12 +413,16 @@ export default function OnboardingChatScreen() {
let botSubtext = undefined;
if (nextStep === 1) {
botText = copy.botGoal;
botText = copy.botSymptom;
} else if (nextStep === 2) {
botText = copy.botExperience;
// Nach dem uebersprungenen Symptomschritt zuerst die Ueberleitung,
// damit die Antwort des Nutzers nicht unkommentiert bleibt.
botText = skipsSymptom ? `${copy.botNoSymptom} ${copy.botLight}` : copy.botLight;
} else if (nextStep === 3) {
botText = copy.botLight;
botText = copy.botExperience;
} else if (nextStep === 4) {
botText = copy.botSource;
} else if (nextStep === TOTAL_STEPS) {
botText = copy.botComplete;
}
@@ -382,15 +449,17 @@ export default function OnboardingChatScreen() {
return;
}
const prevStep = currentStep - 1;
// Gegenstueck zum uebersprungenen Symptomschritt: von Schritt 2 zurueck
// landet ein Vorbeuger wieder bei der Situationsfrage, nicht bei einer
// Symptomfrage, die er nie gesehen hat.
const skippedSymptom = currentStep === 2 && answers.situation === 'prevention';
const prevStep = skippedSymptom ? 0 : currentStep - 1;
setCurrentStep(prevStep);
// Revert last answers
const nextAnswers = { ...answers };
if (prevStep === 0) nextAnswers.acquisitionSource = null;
else if (prevStep === 1) nextAnswers.primaryGoal = null;
else if (prevStep === 2) nextAnswers.experienceLevel = null;
else if (prevStep === 3) nextAnswers.lightLevel = null;
const prevKey: StepKey | undefined = STEP_KEYS[prevStep];
if (prevKey) nextAnswers[prevKey] = null;
setAnswers(nextAnswers);
// Remove user bubble and bot bubble from messages history
@@ -411,20 +480,22 @@ export default function OnboardingChatScreen() {
const onFinish = () => {
if (session?.userId) {
if (answers.acquisitionSource) OnboardingProgressService.setAcquisitionSource(session.userId, answers.acquisitionSource);
if (answers.primaryGoal) OnboardingProgressService.setPrimaryGoal(session.userId, answers.primaryGoal);
if (answers.experienceLevel) OnboardingProgressService.setExperienceLevel(session.userId, answers.experienceLevel);
}
if (answers.acquisitionSource) void PreAuthOnboardingService.setAnswer('acquisitionSource', answers.acquisitionSource);
if (answers.primaryGoal) void PreAuthOnboardingService.setAnswer('primaryGoal', answers.primaryGoal);
if (answers.experienceLevel) void PreAuthOnboardingService.setAnswer('experienceLevel', answers.experienceLevel);
if (answers.lightLevel) void PreAuthOnboardingService.setAnswer('lightLevel', answers.lightLevel);
// Situation und Symptom steuern spaeter die Personalisierung des Plans.
if (answers.situation) void PreAuthOnboardingService.setAnswer('situation', answers.situation);
if (answers.symptom) void PreAuthOnboardingService.setAnswer('symptom', answers.symptom);
posthog.capture('onboarding_chat_completed', {
source: answers.acquisitionSource,
goal: answers.primaryGoal,
experience: answers.experienceLevel,
situation: answers.situation,
symptom: answers.symptom,
light: answers.lightLevel,
experience: answers.experienceLevel,
source: answers.acquisitionSource,
});
router.replace('/onboarding/health-check');
@@ -434,18 +505,43 @@ export default function OnboardingChatScreen() {
const renderOptions = () => {
if (!showOptions || isTyping) return null;
// Schritt 1 - Situation. Erste Frage gehoert dem Nutzer, nicht dem Marketing.
if (currentStep === 0) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>How did you find GreenLens?</Text>
<Text style={styles.stepTitle}>{copy.stepSituation}</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'acute', label: copy.situations.acute, icon: 'alert-circle-outline' as const },
{ id: 'slow', label: copy.situations.slow, icon: 'trending-down-outline' as const },
{ id: 'prevention', label: copy.situations.prevention, icon: 'shield-checkmark-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.optionRow, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.optionRowLeft}>
<Ionicons name={opt.icon} size={18} color={colors.primary} style={{ marginRight: 10 }} />
<Text style={[styles.rowText, { color: colors.text }]}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
</View>
);
}
// Schritt 2 - Symptom. Der Moment, in dem sich der Nutzer wiedererkennt.
if (currentStep === 1) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>{copy.stepSymptom}</Text>
<View style={styles.optionsGrid}>
{([
{ id: 'app_store', label: copy.sources.app_store, icon: 'logo-apple' as const },
{ id: 'instagram', label: copy.sources.instagram, icon: 'logo-instagram' as const },
{ id: 'tiktok', label: copy.sources.tiktok, icon: 'logo-youtube' as const },
{ id: 'friend', label: copy.sources.friend, icon: 'people-outline' as const },
{ id: 'search', label: copy.sources.search, icon: 'search-outline' as const },
{ id: 'other', label: copy.sources.other, icon: 'sparkles-outline' as const },
{ id: 'yellow', label: copy.symptoms.yellow, icon: 'leaf-outline' as const },
{ id: 'brown_tips', label: copy.symptoms.brown_tips, icon: 'flame-outline' as const },
{ id: 'drooping', label: copy.symptoms.drooping, icon: 'trending-down-outline' as const },
{ id: 'spots', label: copy.symptoms.spots, icon: 'ellipse-outline' as const },
{ id: 'pests', label: copy.symptoms.pests, icon: 'bug-outline' as const },
{ id: 'other', label: copy.symptoms.other, icon: 'help-circle-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.pillBtn, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<Ionicons name={opt.icon} size={15} color={colors.primary} style={{ marginRight: 4 }} />
@@ -457,57 +553,11 @@ export default function OnboardingChatScreen() {
);
}
if (currentStep === 1) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>What is your main goal?</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'identify', label: copy.goals.identify, icon: 'search-outline' as const },
{ id: 'care', label: copy.goals.care, icon: 'water-outline' as const },
{ id: 'collection', label: copy.goals.collection, icon: 'leaf-outline' as const },
{ id: 'learn', label: copy.goals.learn, icon: 'book-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.optionRow, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.optionRowLeft}>
<Ionicons name={opt.icon} size={18} color={colors.primary} style={{ marginRight: 10 }} />
<Text style={[styles.rowText, { color: colors.text }]}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
</View>
);
}
// Schritt 3 - Licht.
if (currentStep === 2) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>What is your experience level?</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'beginner', label: copy.experiences.beginner, icon: 'leaf-outline' as const },
{ id: 'intermediate', label: copy.experiences.intermediate, icon: 'sunny-outline' as const },
{ id: 'advanced', label: copy.experiences.advanced, icon: 'flask-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.optionRow, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.optionRowLeft}>
<Ionicons name={opt.icon} size={18} color={colors.primary} style={{ marginRight: 10 }} />
<Text style={[styles.rowText, { color: colors.text }]}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
</View>
);
}
if (currentStep === 3) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>How much light do I get here?</Text>
<Text style={styles.stepTitle}>{copy.stepLight}</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'bright_indirect', label: copy.lights.bright_indirect.title, sub: copy.lights.bright_indirect.sub, icon: 'wb-twilight' },
@@ -527,7 +577,62 @@ export default function OnboardingChatScreen() {
);
}
// Schritt 4 - Erfahrung. Formuliert als Sicherheitsgefuehl, nicht als
// Wissenstest: Emma soll sich nicht schon hier als Anfaengerin abstempeln.
if (currentStep === 3) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>{copy.stepExperience}</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'beginner', label: copy.experiences.beginner, icon: 'leaf-outline' as const },
{ id: 'intermediate', label: copy.experiences.intermediate, icon: 'sunny-outline' as const },
{ id: 'advanced', label: copy.experiences.advanced, icon: 'flask-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.optionRow, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.optionRowLeft}>
<Ionicons name={opt.icon} size={18} color={colors.primary} style={{ marginRight: 10 }} />
<Text style={[styles.rowText, { color: colors.text }]}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
</View>
);
}
// Schritt 5 - Attribution. Ganz ans Ende verschoben: die Frage nuetzt uns,
// nicht dem Nutzer. Die unwahrscheinlichen Optionen (TV, Radio, "meine
// Pflanze") brechen das Muster und machen den letzten Tap leicht.
if (currentStep === 4) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>{copy.stepSource}</Text>
<View style={styles.optionsGrid}>
{([
{ id: 'app_store', label: copy.sources.app_store, icon: 'logo-apple' as const },
{ id: 'instagram', label: copy.sources.instagram, icon: 'logo-instagram' as const },
{ id: 'tiktok', label: copy.sources.tiktok, icon: 'musical-notes-outline' as const },
{ id: 'friend', label: copy.sources.friend, icon: 'people-outline' as const },
{ id: 'search', label: copy.sources.search, icon: 'search-outline' as const },
{ id: 'tv', label: copy.sources.tv, icon: 'tv-outline' as const },
{ id: 'radio', label: copy.sources.radio, icon: 'radio-outline' as const },
{ id: 'plant', label: copy.sources.plant, icon: 'leaf-outline' as const },
{ id: 'other', label: copy.sources.other, icon: 'sparkles-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.pillBtn, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<Ionicons name={opt.icon} size={15} color={colors.primary} style={{ marginRight: 4 }} />
<Text style={[styles.pillLabel, { color: colors.text }]}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
// Alle Fragen beantwortet -> Abschluss-CTA.
if (currentStep >= TOTAL_STEPS) {
return (
<View style={styles.footerWrap}>
<TouchableOpacity style={[styles.continueCta, { backgroundColor: colors.primary }]} onPress={onFinish} activeOpacity={0.86}>
@@ -562,7 +667,7 @@ export default function OnboardingChatScreen() {
);
};
const progressPercent = `${Math.min(currentStep * 25, 100)}%`;
const progressPercent = `${Math.min(Math.round((currentStep / TOTAL_STEPS) * 100), 100)}%`;
return (
<KeyboardAvoidingView

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, Switch, useWindowDimensions } from 'react-native';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, Platform, useWindowDimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter, useLocalSearchParams } from 'expo-router';
@@ -26,7 +26,6 @@ type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPacka
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
type PaywallPlanId = 'monthly' | 'yearly';
const PAYWALL_BACKGROUND = require('../../assets/paywall_scan_background.png');
const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = {
topup_small: 30,
@@ -121,27 +120,35 @@ const getBillingCopy = (language: Language) => {
freePlanPrice: '0 EUR / Monat',
proPlanName: 'Pro',
proPlanPrice: '4,99 € / Monat',
// Nackter Betrag: planCardPriceTrial/-Monthly haengen die Periode selbst an.
proPlanPriceBare: '4,99 €',
proYearlyPlanPriceBare: '39,99 €',
proBadgeText: 'EMPFOHLEN',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39,99 € / Jahr',
proYearlyBadgeText: 'SPAREN',
proBenefits: [
'100 Credits für AI-Scans und Follow-ups jeden Monat',
'Pro-Scans mit GPT-5.4',
'Unbegrenzte Historie & Galerie',
'KI-Pflanzendoktor inklusive',
'100 Scans und Nachfragen jeden Monat',
'Ursachen-Ranking mit Angabe, wie sicher wir sind',
'Konkrete Prüfschritte statt allgemeiner Pflegetipps',
'7-Tage-Plan pro Problem, plus Nachfrage danach',
'Komplette Historie & Galerie',
'Priorisierter Support'
],
topupTitle: 'Credits Aufladen',
topupHint: 'Für aktive Pro-Nutzer, wenn die Monatscredits nicht reichen.',
topupSmall: '30 Credits 2,99 €',
topupMedium: '100 Credits 6,99 €',
topupLarge: '250 Credits 12,99 €',
topupSmall: '30 Credits - 2,99 €',
topupMedium: '100 Credits - 6,99 €',
topupLarge: '250 Credits - 12,99 €',
topupBestValue: 'BESTES ANGEBOT',
cancelTitle: 'Schade, dass du gehst',
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
reasonTooExpensive: 'Es ist mir zu teuer',
reasonNotUsing: 'Ich nutze die App zu selten',
pauseTitle: 'Dann pausier es doch einfach.',
pauseText: 'Wir setzen dein Abo für 3 Monate aus. Deine Pflanzen, deine Historie und deine Pläne bleiben erhalten - es wird nichts abgebucht.',
pauseAccept: '3 Monate pausieren',
pauseDecline: 'Nein, trotzdem kündigen',
reasonOther: 'Ein anderer Grund',
offerTitle: 'Ein Geschenk für dich!',
offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).',
@@ -153,14 +160,55 @@ const getBillingCopy = (language: Language) => {
autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.',
manageInSettings: 'In iOS-Einstellungen verwalten',
paywallEyebrow: 'GreenLens Pro',
paywallHeadline: 'Unbegrenzter Zugriff',
paywallSub: 'Unbegrenzte Scans, Health-Checks und dein persönlicher Pflegeplan.',
// "Unbegrenzt" ist ersatzlos gestrichen: es stand im direkten
// Widerspruch zu "100 Credits/Monat" drei Zeilen weiter unten.
paywallHeadline: 'Nicht mehr raten, was deiner Pflanze fehlt.',
// Kurz gehalten: die Details stehen direkt darunter in den Bullets.
// Sub und Bullets doppelt zu bespielen kostet Aufmerksamkeit und
// liest sich, als traute man dem eigenen Argument nicht.
paywallSub: 'Ursache, Prüfschritte, Sofortmaßnahme - und ein Check nach 7 Tagen.',
planCardTitle: 'GreenLens Pro',
planCardBody: 'Unbegrenzte KI-Scans, Gesundheitsdiagnose, 7-Tage-Rettungspläne, 100 Credits/Monat',
// "Gesundheitsdiagnose" -> "Ursachen-Analyse": eine Diagnose koennen
// wir nicht versprechen, eine gewichtete Ursachenliste schon.
planCardBody: '100 Scans & Nachfragen pro Monat',
paywallBullets: [
'Ursachen-Ranking - inklusive dem, was unsicher ist',
'Konkrete Prüfschritte statt allgemeiner Pflegetipps',
'7-Tage-Plan, plus Nachfrage, ob es wirkt',
'Komplette Historie deiner Pflanzen',
],
// Belief 5 explizit: der Vergleich gegen die kostenlose Alternative
// gehoert an die Stelle, an der die Kaufentscheidung faellt.
paywallCompare: 'Kostenlose Tools sagen dir, wie deine Pflanze heißt. Sie sagen dir nicht, was zu prüfen ist, was zu tun ist oder ob es gewirkt hat.',
badgeMostPopular: 'BELIEBT',
badgeFlexible: 'FLEXIBEL',
planYearlyName: 'Jährlich - 7 Tage gratis',
planMonthlyName: 'Monatlich',
saveBadge: '33 % SPAREN',
perYear: '/ Jahr',
perMonth: '/ Monat',
breaksDownTo: 'Macht nur',
// Nur der Wert, kein ganzer Satz: das Label links sagt bereits
// "Macht nur" - der Satz "entspricht X pro Monat" doppelte das und
// lief aus der Zeile.
equivalentValue: (price: string) => `${price} / Monat`,
faqQ1: '"Kostenlose Tools gibt es doch."',
faqQ2: '"Eine andere App lag selbstbewusst falsch."',
faqA2: 'Wir zeigen dir zu jeder Ursache, wie sicher wir sind. Unter 65 % sagen wir das deutlich - und nennen dir die Prüfschritte, mit denen du es selbst bestätigst oder ausschließt.',
faqQ3: '"Und wenn es nicht hilft?"',
faqA3: 'Nach 7 Tagen fragen wir nach. Wird es nicht besser, passen wir den Plan an. Kündigen geht in 2 Taps über die iOS-Einstellungen - wir fragen kurz nach dem Grund, das wars.',
faqScope: 'Enthalten: 100 Scans und Nachfragen pro Monat, komplette Historie deiner Pflanzen.',
altMonthly: (price: string) => `oder ${price} monatlich, ohne Testphase`,
altYearly: (price: string) => `oder ${price} jährlich - mit 7 Tagen gratis`,
dueSummary: (today: string, later: string, date: string) =>
`Heute fällig: ${today} - dann ${later} am ${date}`,
detailsToggle: 'Fragen, die du jetzt vielleicht hast',
trialReminder: 'Wir erinnern dich 2 Tage vor Ablauf der Testphase.',
cancelPath: 'Kündigen in 2 Taps über die iOS-Einstellungen - Anleitung findest du in der App.',
planCardPriceTrial: (price: string) => `7 Tage gratis, dann ${price}/Jahr`,
planCardPriceMonthly: (price: string) => `${price}/Monat`,
trialToggleLabel: '7 Tage gratis testen',
dueTodayTrial: 'Fällig heute 7 Tage gratis',
dueTodayTrial: 'Fällig heute - 7 Tage gratis',
dueTodayAmount: '0,00 €',
dueLater: (date: string) => `Fällig am ${date}`,
ctaTrial: 'Gratis testen',
@@ -186,27 +234,34 @@ const getBillingCopy = (language: Language) => {
freePlanPrice: '0 EUR / Mes',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Mes',
proPlanPriceBare: '4,99 €',
proYearlyPlanPriceBare: '39,99 €',
proBadgeText: 'RECOMENDADO',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Año',
proYearlyBadgeText: 'AHORRAR',
proBenefits: [
'100 créditos para escaneos IA y seguimientos cada mes',
'Escaneos Pro con GPT-5.4',
'Historial y galería ilimitados',
'Doctor de plantas de IA incluido',
'100 escaneos y consultas cada mes',
'Ranking de causas indicando qué tan seguros estamos',
'Pasos concretos de revisión, no consejos genéricos',
'Plan de 7 días por problema, más el seguimiento',
'Historial y galería completos',
'Soporte prioritario'
],
topupTitle: 'Recargar Créditos',
topupHint: 'Para usuarios Pro activos cuando los créditos mensuales no alcanzan.',
topupSmall: '30 Créditos 2,99 €',
topupMedium: '100 Créditos 6,99 €',
topupLarge: '250 Créditos 12,99 €',
topupSmall: '30 Créditos - 2,99 €',
topupMedium: '100 Créditos - 6,99 €',
topupLarge: '250 Créditos - 12,99 €',
topupBestValue: 'MEJOR OFERTA',
cancelTitle: 'Lamentamos verte ir',
cancelQuestion: '¿Podemos saber por qué cancelas?',
reasonTooExpensive: 'Es muy caro',
reasonNotUsing: 'No lo uso suficiente',
pauseTitle: 'Entonces mejor ponlo en pausa.',
pauseText: 'Suspendemos tu suscripción 3 meses. Tus plantas, tu historial y tus planes se conservan - no se cobra nada.',
pauseAccept: 'Pausar 3 meses',
pauseDecline: 'No, cancelar igualmente',
reasonOther: 'Otra razón',
offerTitle: '¡Un regalo para ti!',
offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).',
@@ -218,14 +273,43 @@ const getBillingCopy = (language: Language) => {
autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.',
manageInSettings: 'Administrar en Ajustes de iOS',
paywallEyebrow: 'GreenLens Pro',
paywallHeadline: 'Acceso ilimitado',
paywallSub: 'Escaneos ilimitados, chequeos de salud y tu plan de cuidados personal.',
paywallHeadline: 'Deja de adivinar qué le pasa a tu planta.',
paywallSub: 'Causa, pasos de revisión, acción inmediata - y una comprobación a los 7 días.',
planCardTitle: 'GreenLens Pro',
planCardBody: 'Escaneos IA ilimitados, diagnóstico de salud, planes de rescate de 7 días, 100 créditos/mes',
planCardBody: '100 escaneos y consultas al mes',
paywallBullets: [
'Ranking de causas - incluyendo lo que es incierto',
'Pasos concretos de revisión, no consejos genéricos',
'Plan de 7 días, más la pregunta de si funciona',
'Historial completo de tus plantas',
],
paywallCompare: 'Las herramientas gratuitas te dicen cómo se llama tu planta. No te dicen qué revisar, qué hacer ni si funcionó.',
badgeMostPopular: 'POPULAR',
badgeFlexible: 'FLEXIBLE',
planYearlyName: 'Anual - 7 días gratis',
planMonthlyName: 'Mensual',
saveBadge: 'AHORRA 33 %',
perYear: '/ año',
perMonth: '/ mes',
breaksDownTo: 'Sale a solo',
equivalentValue: (price: string) => `${price} / mes`,
faqQ1: '"Pero hay herramientas gratuitas."',
faqQ2: '"Otra app se equivocó con mucha seguridad."',
faqA2: 'Te mostramos qué tan seguros estamos de cada causa. Por debajo del 65 % te lo decimos claramente, y te damos los pasos de revisión para confirmarlo o descartarlo tú mismo.',
faqQ3: '"¿Y si no funciona?"',
faqA3: 'A los 7 días preguntamos. Si no mejora, ajustamos el plan. Cancelar son 2 toques desde los ajustes de iOS - te preguntamos el motivo, y ya está.',
faqScope: 'Incluye: 100 escaneos y consultas al mes, historial completo de tus plantas.',
altMonthly: (price: string) => `o ${price} al mes, sin prueba gratuita`,
altYearly: (price: string) => `o ${price} al año - con 7 días gratis`,
dueSummary: (today: string, later: string, date: string) =>
`Hoy pagas: ${today} - luego ${later} el ${date}`,
detailsToggle: 'Preguntas que quizá tengas ahora',
trialReminder: 'Te avisamos 2 días antes de que termine la prueba.',
cancelPath: 'Cancela en 2 toques desde los Ajustes de iOS - te explicamos cómo en la app.',
planCardPriceTrial: (price: string) => `7 días gratis, luego ${price}/año`,
planCardPriceMonthly: (price: string) => `${price}/mes`,
trialToggleLabel: 'Probar 7 días gratis',
dueTodayTrial: 'Hoy 7 días gratis',
dueTodayTrial: 'Hoy - 7 días gratis',
dueTodayAmount: '0,00 €',
dueLater: (date: string) => `El ${date}`,
ctaTrial: 'Probar gratis',
@@ -251,27 +335,34 @@ const getBillingCopy = (language: Language) => {
freePlanPrice: '0 EUR / Month',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Month',
proPlanPriceBare: '€4.99',
proYearlyPlanPriceBare: '€39.99',
proBadgeText: 'RECOMMENDED',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Year',
proYearlyBadgeText: 'SAVE',
proBenefits: [
'100 credits for AI scans and follow-ups every month',
'Pro scans with GPT-5.4',
'Unlimited history & gallery',
'AI Plant Doctor included',
'100 scans and follow-ups every month',
'A ranking of causes, with how confident we are',
'Concrete things to check, not generic care tips',
'A 7-day plan per problem, plus the follow-up',
'Complete history & gallery',
'Priority support'
],
topupTitle: 'Topup Credits',
topupHint: 'For active Pro users when monthly credits are not enough.',
topupSmall: '30 Credits €2.99',
topupMedium: '100 Credits €6.99',
topupLarge: '250 Credits €12.99',
topupSmall: '30 Credits - €2.99',
topupMedium: '100 Credits - €6.99',
topupLarge: '250 Credits - €12.99',
topupBestValue: 'BEST VALUE',
cancelTitle: 'Sorry to see you go',
cancelQuestion: 'May we ask why you are cancelling?',
reasonTooExpensive: 'It is too expensive',
reasonNotUsing: 'I don\'t use it enough',
pauseTitle: 'Then just pause it.',
pauseText: 'We put your subscription on hold for 3 months. Your plants, history and plans stay - nothing gets charged.',
pauseAccept: 'Pause for 3 months',
pauseDecline: 'No, cancel anyway',
reasonOther: 'Other reason',
offerTitle: 'A gift for you!',
offerText: 'Stay with us and get your next month for just €2.49 (50% off).',
@@ -283,14 +374,43 @@ const getBillingCopy = (language: Language) => {
autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.',
manageInSettings: 'Manage in iOS Settings',
paywallEyebrow: 'GreenLens Pro',
paywallHeadline: 'Get Unlimited Access',
paywallSub: 'Unlimited scans, health checks and your personal care plan.',
paywallHeadline: 'Stop guessing what your plant needs.',
paywallSub: 'Cause, what to check, what to do now - and a follow-up after 7 days.',
planCardTitle: 'GreenLens Pro',
planCardBody: 'Unlimited AI scans, health diagnosis, 7-day rescue plans, 100 credits/month',
planCardBody: '100 scans & follow-ups per month',
paywallBullets: [
'A ranking of causes - including what is uncertain',
'Concrete things to check, not generic care tips',
'A 7-day plan, plus a follow-up on whether it works',
'Your complete plant history',
],
paywallCompare: 'Free tools tell you what your plant is called. They do not tell you what to check, what to do, or whether it worked.',
badgeMostPopular: 'MOST POPULAR',
badgeFlexible: 'FLEXIBLE',
planYearlyName: 'Yearly - 7 days free',
planMonthlyName: 'Monthly',
saveBadge: 'SAVE 33%',
perYear: '/ year',
perMonth: '/ month',
breaksDownTo: 'Breaks down to just',
equivalentValue: (price: string) => `${price} / month`,
faqQ1: '"But free tools exist."',
faqQ2: '"Another app was confidently wrong."',
faqA2: 'We show you how confident we are for every cause. Below 65% we say so plainly - and give you the checks to confirm or rule it out yourself.',
faqQ3: '"What if it does not help?"',
faqA3: 'After 7 days we ask. If it is not improving, we adjust the plan. Cancelling takes 2 taps in iOS settings - we ask why, that is it.',
faqScope: 'Included: 100 scans and follow-ups per month, your complete plant history.',
altMonthly: (price: string) => `or ${price} monthly, without trial`,
altYearly: (price: string) => `or ${price} yearly - with 7 days free`,
dueSummary: (today: string, later: string, date: string) =>
`Due today: ${today} - then ${later} on ${date}`,
detailsToggle: 'Questions you might have right now',
trialReminder: 'We remind you 2 days before the trial ends.',
cancelPath: 'Cancel in 2 taps via iOS Settings - we show you how inside the app.',
planCardPriceTrial: (price: string) => `Free for 7 days, then ${price}/year`,
planCardPriceMonthly: (price: string) => `${price}/month`,
trialToggleLabel: 'Try 7 days free',
dueTodayTrial: 'Due today 7 days free',
dueTodayTrial: 'Due today - 7 days free',
dueTodayAmount: '€0.00',
dueLater: (date: string) => `Due ${date}`,
ctaTrial: 'Try Free',
@@ -325,6 +445,13 @@ export default function BillingScreen() {
// Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const [cancelReason, setCancelReason] = useState<string | null>(null);
// Argumente eingeklappt: die Paywall passt so ohne Scrollen auf einen Screen.
// Wer mehr wissen will, klappt auf - wer kaufen will, wird nicht aufgehalten.
const [detailsOpen, setDetailsOpen] = useState(false);
// Wer die App zu selten nutzt, hat kein Preisproblem - ein Rabatt geht dort
// ins Leere und verschenkt Marge. Pause rettet die Beziehung stattdessen.
const isPauseOffer = cancelReason === 'not_using';
const planId = billingSummary?.entitlement?.plan || 'free';
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0);
@@ -404,8 +531,9 @@ export default function BillingScreen() {
const monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro;
const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice;
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
// Fallback ohne Periode - sonst entsteht "4.99 EUR / Month/month".
const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPriceBare;
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPriceBare;
const trialEndDate = useMemo(() => {
const date = new Date();
date.setDate(date.getDate() + 7);
@@ -496,7 +624,7 @@ export default function BillingScreen() {
posthog.capture('purchase_initiated', { product_id: productId });
try {
if (isExpoGo) {
// ExpoGo has no native RevenueCat use simulation for development only
// ExpoGo has no native RevenueCat - use simulation for development only
setIsUpdating(false);
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
@@ -558,7 +686,7 @@ export default function BillingScreen() {
return;
}
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED the Apple ID already
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED - the Apple ID already
// owns this subscription on a different GreenLens account. Silently dismiss;
// the current account stays free. The user can restore via "Käufe wiederherstellen".
const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record<string, unknown>).code : undefined;
@@ -606,6 +734,7 @@ export default function BillingScreen() {
try {
await simulateWebhookEvent('entitlement_revoked');
setCancelStep('none');
setCancelReason(null);
setSubModalVisible(false);
} catch (e) {
console.error('Downgrade failed', e);
@@ -614,109 +743,230 @@ export default function BillingScreen() {
}
};
// Jahrespreis -> Monatsaequivalent, mit Waehrungssymbol aus dem Store-Preis.
const yearlyMonthlyEquivalent = (() => {
const numeric = parseFloat(yearlyPrice.replace(/[^0-9.,]/g, '').replace(',', '.'));
if (!Number.isFinite(numeric) || numeric <= 0) return null;
const symbol = yearlyPrice.replace(/[0-9.,\s]/g, '') || '€';
const perMonth = (numeric / 12).toFixed(2).replace('.', language === 'en' ? '.' : ',');
return `${perMonth} ${symbol}`.trim();
})();
if (showPaywallPlans) {
return (
<View style={styles.hardPaywallScreen}>
<ImageBackground
source={PAYWALL_BACKGROUND}
style={styles.hardPaywallHero}
imageStyle={styles.hardPaywallHeroImage}
resizeMode="cover"
>
<View style={[styles.hardPaywallScreen, { backgroundColor: colors.background }]}>
<View style={styles.hardPaywallPlain}>
<SafeAreaView style={styles.hardPaywallSafe} edges={['top']}>
<View style={styles.heroTopBar}>
<TouchableOpacity onPress={handleBack} style={styles.heroIconButton}>
<Ionicons name="close" size={24} color="#FFFFFF" />
<TouchableOpacity onPress={handleBack} style={[styles.heroIconButton, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity onPress={handleRestore} disabled={isUpdating}>
<Text style={styles.heroRestoreText}>{copy.restorePurchases}</Text>
<Text style={[styles.heroRestoreText, { color: colors.textSecondary }]}>{copy.restorePurchases}</Text>
</TouchableOpacity>
</View>
<View style={[styles.paywallSheet, { backgroundColor: colors.background, marginTop: compact ? 110 : 190 }]}>
<View style={[styles.paywallSheet, { backgroundColor: colors.background, marginTop: 0 }]}>
<ScrollView
style={styles.paywallScroll}
contentContainerStyle={styles.paywallBody}
showsVerticalScrollIndicator={false}
>
<View style={[styles.sheetHandle, { backgroundColor: colors.border }]} />
<Text style={[styles.paywallEyebrow, { color: colors.primary }]}>{copy.paywallEyebrow.toUpperCase()}</Text>
<Text style={[styles.paywallHeadline, { color: colors.text }]}>{copy.paywallHeadline}</Text>
<Text style={[styles.paywallSub, { color: colors.textSecondary }]}>{copy.paywallSub}</Text>
<View style={[styles.planCard, { backgroundColor: colors.surfaceMuted }]}>
<Text style={[styles.planCardTitle, { color: colors.text }]}>{copy.planCardTitle}</Text>
<Text style={[styles.planCardBody, { color: colors.textSecondary }]}>{copy.planCardBody}</Text>
<View style={[styles.planCardDivider, { backgroundColor: colors.border }]} />
<Text style={[styles.planCardPrice, { color: colors.text }]}>
{trialEnabled ? copy.planCardPriceTrial(yearlyPrice) : copy.planCardPriceMonthly(monthlyPrice)}
</Text>
{/* Feature-Bullets ueber der Angebotskarte: kurz, drei
Stueck, ohne Kaestchen - sie sind Kontext, nicht die
Entscheidung. */}
<View style={styles.offerBullets}>
{copy.paywallBullets.slice(0, 3).map((bullet) => (
<View key={bullet} style={styles.offerBulletRow}>
<Ionicons name="checkmark-circle-outline" size={19} color={colors.primary} />
<Text style={[styles.offerBulletText, { color: colors.textSecondary }]}>{bullet}</Text>
</View>
))}
</View>
<View style={[styles.trialToggleRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.trialToggleLabel, { color: colors.text }]}>{copy.trialToggleLabel}</Text>
<Switch
value={trialEnabled}
onValueChange={(next) => setSelectedPaywallPlan(next ? 'yearly' : 'monthly')}
trackColor={{ true: colors.primary, false: colors.border }}
thumbColor="#FFFFFF"
/>
{/* EINE Angebotskarte statt Toggle + Preiskarte. Der Plan,
der Preis und der Kaufbutton stehen zusammen, die
Alternative darunter als Textlink. Damit entfaellt der
Switch, der die Preisaenderung nie sichtbar gemacht hat. */}
<View
style={[
styles.offerCardOuter,
{
backgroundColor: colors.surfaceMuted,
borderColor: colors.primary,
// Dezenter Glow statt Leuchtrahmen.
shadowColor: colors.primary,
},
]}
>
<View style={[styles.offerBadge, { backgroundColor: colors.primary }]}>
<Text style={[styles.offerBadgeText, { color: colors.onPrimary }]}>
{trialEnabled ? copy.badgeMostPopular : copy.badgeFlexible}
</Text>
</View>
<View style={styles.offerHeaderRow}>
<View style={styles.offerHeaderLeft}>
<Text style={[styles.offerPlanName, { color: colors.text }]}>
{trialEnabled ? copy.planYearlyName : copy.planMonthlyName}
</Text>
{trialEnabled ? (
<Text style={[styles.offerSaveText, { color: colors.primary }]}>{copy.saveBadge}</Text>
) : null}
</View>
<View style={styles.offerHeaderRight}>
<Text style={[styles.offerPrice, { color: colors.text }]}>
{trialEnabled ? yearlyPrice : monthlyPrice}
</Text>
<Text style={[styles.offerPricePeriod, { color: colors.textMuted }]}>
{trialEnabled ? copy.perYear : copy.perMonth}
</Text>
</View>
</View>
{trialEnabled && yearlyMonthlyEquivalent ? (
<View style={[styles.offerBreakdown, { backgroundColor: colors.surface }]}>
<Text style={[styles.offerBreakdownLabel, { color: colors.textSecondary }]}>
{copy.breaksDownTo}
</Text>
<Text
style={[styles.offerBreakdownValue, { color: colors.text }]}
numberOfLines={1}
adjustsFontSizeToFit
>
{copy.equivalentValue(yearlyMonthlyEquivalent)}
</Text>
</View>
) : null}
<TouchableOpacity
style={[
styles.offerCta,
{ backgroundColor: colors.primary },
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.86}
>
{isUpdating || !storeReady ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<>
<Text style={[styles.offerCtaText, { color: colors.onPrimary }]}>
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
<Ionicons name="arrow-forward" size={19} color={colors.onPrimary} />
</>
)}
</TouchableOpacity>
{/* Planwechsel als Textlink - kein Schalter, sondern eine
sichtbar formulierte Alternative inkl. Preis. */}
<TouchableOpacity
onPress={() => setSelectedPaywallPlan(trialEnabled ? 'monthly' : 'yearly')}
style={styles.offerAltLink}
>
<Text style={[styles.offerAltLinkText, { color: colors.textSecondary }]}>
{trialEnabled
? copy.altMonthly(monthlyPrice)
: copy.altYearly(yearlyPrice)}
</Text>
</TouchableOpacity>
</View>
{trialEnabled ? (
<View style={styles.dueTimeline}>
<View style={styles.dueRow}>
<View style={[styles.dueDot, { backgroundColor: colors.primary }]} />
<Text style={[styles.dueLabel, { color: colors.primary }]}>{copy.dueTodayTrial}</Text>
<Text style={[styles.dueAmount, { color: colors.text }]}>{copy.dueTodayAmount}</Text>
</View>
<View style={[styles.dueLine, { backgroundColor: colors.border }]} />
<View style={styles.dueRow}>
<View style={[styles.dueDot, { backgroundColor: colors.border }]} />
<Text style={[styles.dueLabel, { color: colors.textSecondary }]}>{copy.dueLater(trialEndDate)}</Text>
<Text style={[styles.dueAmount, { color: colors.text }]}>{yearlyPrice}</Text>
</View>
<View style={[styles.dueBanner, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="calendar-outline" size={16} color={colors.primary} />
<Text style={[styles.dueBannerText, { color: colors.textSecondary }]}>
{copy.dueSummary(copy.dueTodayAmount, yearlyPrice, trialEndDate)}
</Text>
</View>
) : null}
{storeError ? (
<Text style={[styles.paywallFooterText, { color: colors.danger, marginBottom: 8 }]}>{storeError}</Text>
) : null}
{/* Aufklappbar: eingeklappt bleibt die Kaufentscheidung
ueber der Falz, aufgeklappt stehen die Argumente
vollstaendig da. Beides ohne zweiten Screen. */}
<TouchableOpacity
style={[
styles.paywallCta,
{ backgroundColor: colors.primary },
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.86}
style={[styles.detailsToggle, { borderColor: colors.border }]}
onPress={() => {
setDetailsOpen((open) => {
if (!open) posthog.capture('paywall_details_expanded');
return !open;
});
}}
activeOpacity={0.8}
>
{isUpdating || !storeReady ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<Text style={[styles.paywallCtaText, { color: colors.onPrimary }]}>
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
)}
<Text style={[styles.detailsToggleLabel, { color: colors.text }]}>{copy.detailsToggle}</Text>
<Ionicons
name={detailsOpen ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.textSecondary}
/>
</TouchableOpacity>
<View style={styles.paywallFooter}>
<View style={styles.paywallFooterLinks}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>Privacy</Text>
</TouchableOpacity>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}> | </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>Terms</Text>
</TouchableOpacity>
{detailsOpen ? (
/* Bewusst KEINE Feature-Wiederholung: die drei
wichtigsten Punkte stehen bereits oben. Hier
stehen stattdessen die drei dokumentierten
Einwaende aus dem Offer Brief - Gratis-Alternativen,
selbstbewusst falsche Konkurrenz-Apps, und was
passiert, wenn es nicht wirkt. */
<View style={styles.faqBlock}>
<View style={styles.faqItem}>
<Text style={[styles.faqQuestion, { color: colors.text }]}>{copy.faqQ1}</Text>
<Text style={[styles.faqAnswer, { color: colors.textSecondary }]}>{copy.paywallCompare}</Text>
</View>
<View style={styles.faqItem}>
<Text style={[styles.faqQuestion, { color: colors.text }]}>{copy.faqQ2}</Text>
<Text style={[styles.faqAnswer, { color: colors.textSecondary }]}>{copy.faqA2}</Text>
</View>
<View style={styles.faqItem}>
<Text style={[styles.faqQuestion, { color: colors.text }]}>{copy.faqQ3}</Text>
<Text style={[styles.faqAnswer, { color: colors.textSecondary }]}>{copy.faqA3}</Text>
</View>
<Text style={[styles.faqScope, { color: colors.textMuted }]}>{copy.faqScope}</Text>
</View>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>{copy.cancelAnytime}</Text>
</View>
) : null}
</ScrollView>
{/* Nur noch Rechtstexte: der Kaufbutton sitzt in der
Angebotskarte, direkt neben Plan und Preis. Ein zweiter
CTA in einer Leiste waere eine doppelte Aufforderung
zur selben Handlung. */}
<View style={[styles.paywallActionBar, { backgroundColor: colors.background, borderTopColor: colors.border }]}>
{storeError ? (
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.danger, marginBottom: 6 }]}>
{storeError}
</Text>
) : null}
{/* Belief 6: Kuendigung und Verlaengerung stehen sichtbar
vor der Entscheidung, nicht im Kleingedruckten. */}
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}>
{trialEnabled ? `${copy.cancelAnytime} · ${copy.trialReminder}` : copy.cancelAnytime}
</Text>
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}>
{copy.cancelPath}
</Text>
<View style={styles.paywallFooterLinks}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>Privacy</Text>
</TouchableOpacity>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}> | </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>Terms</Text>
</TouchableOpacity>
</View>
</View>
</View>
</SafeAreaView>
</ImageBackground>
</View>
</View>
);
}
@@ -835,11 +1085,12 @@ export default function BillingScreen() {
<View style={[styles.modalContent, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>
{cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle}
{cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? (isPauseOffer ? copy.pauseTitle : copy.offerTitle) : copy.subscriptionTitle}
</Text>
<TouchableOpacity onPress={() => {
setSubModalVisible(false);
setCancelStep('none');
setCancelReason(null);
}}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
@@ -953,6 +1204,9 @@ export default function BillingScreen() {
key={reason.id}
style={[styles.reasonOption, { borderColor: colors.border }]}
onPress={() => {
// Grund merken: ein Rabatt hilft nur bei "zu teuer".
// Wer zu selten nutzt, hat kein Preisproblem.
setCancelReason(reason.id);
setCancelStep('offer');
}}
>
@@ -969,20 +1223,29 @@ export default function BillingScreen() {
<View style={styles.cancelFlowContainer}>
<View style={[styles.offerCard, { backgroundColor: colors.primarySoft }]}>
<View style={[styles.offerIconWrap, { backgroundColor: colors.primary }]}>
<Ionicons name="gift" size={28} color="#fff" />
<Ionicons name={isPauseOffer ? 'pause' : 'gift'} size={28} color="#fff" />
</View>
<Text style={[styles.offerText, { color: colors.text }]}>{copy.offerText}</Text>
<Text style={[styles.offerText, { color: colors.text }]}>
{isPauseOffer ? copy.pauseText : copy.offerText}
</Text>
<TouchableOpacity
style={[styles.offerAcceptBtn, { backgroundColor: colors.primary }]}
onPress={() => {
// Handle applying discount here (future implementation)
Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)');
posthog.capture('cancel_save_offer_accepted', {
reason: cancelReason,
offer: isPauseOffer ? 'pause_3_months' : 'discount_50',
});
// TODO(billing): Pause bzw. Rabatt am Store-Abo umsetzen.
Alert.alert('OK', isPauseOffer ? 'Abo pausiert (Mock)' : 'Rabatt angewendet (Mock)');
setCancelStep('none');
setCancelReason(null);
setSubModalVisible(false);
}}
>
<Text style={styles.offerAcceptBtnText}>{copy.offerAccept}</Text>
<Text style={styles.offerAcceptBtnText}>
{isPauseOffer ? copy.pauseAccept : copy.offerAccept}
</Text>
</TouchableOpacity>
</View>
@@ -991,7 +1254,9 @@ export default function BillingScreen() {
onPress={finalizeCancel}
disabled={isUpdating}
>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>{copy.offerDecline}</Text>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>
{isPauseOffer ? copy.pauseDecline : copy.offerDecline}
</Text>
</TouchableOpacity>
</View>
)}
@@ -1008,12 +1273,6 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#101411',
},
hardPaywallHero: {
flex: 1,
},
hardPaywallHeroImage: {
transform: [{ translateY: -38 }, { scale: 1.06 }],
},
hardPaywallSafe: {
flex: 1,
},
@@ -1040,43 +1299,108 @@ const styles = StyleSheet.create({
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
hardPaywallPlain: { flex: 1 },
paywallSheet: {
flex: 1,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
overflow: 'hidden',
zIndex: 5,
},
sheetHandle: {
alignSelf: 'center',
width: 42,
height: 5,
borderRadius: 999,
marginTop: 10,
marginBottom: 8,
},
paywallBody: { paddingHorizontal: 22, paddingTop: 10, paddingBottom: 24 },
paywallScroll: { flex: 1 },
paywallBody: { paddingHorizontal: 22, paddingTop: 10, paddingBottom: 16 },
paywallEyebrow: { fontSize: 12, fontWeight: '900', letterSpacing: 1.4, textAlign: 'center', marginBottom: 6 },
paywallHeadline: { fontSize: 32, fontWeight: '900', textAlign: 'center', marginBottom: 6 },
paywallSub: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18 },
planCard: { borderRadius: 16, padding: 18, marginBottom: 14 },
planCardTitle: { fontSize: 19, fontWeight: '800', marginBottom: 6 },
planCardBody: { fontSize: 14, lineHeight: 20 },
planCardDivider: { height: StyleSheet.hairlineWidth, marginVertical: 12 },
planCardPrice: { fontSize: 15, fontWeight: '800' },
trialToggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 14, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 12, marginBottom: 16 },
trialToggleLabel: { fontSize: 15, fontWeight: '800' },
dueTimeline: { marginBottom: 18, paddingHorizontal: 4 },
dueRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
dueDot: { width: 10, height: 10, borderRadius: 5 },
dueLine: { width: 2, height: 18, marginLeft: 4, marginVertical: 2 },
dueLabel: { flex: 1, fontSize: 14, fontWeight: '700' },
dueAmount: { fontSize: 14, fontWeight: '800' },
paywallCta: { height: 58, borderRadius: 14, alignItems: 'center', justifyContent: 'center', marginBottom: 12 },
paywallCtaText: { fontSize: 18, fontWeight: '800' },
paywallFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
paywallFooterLinks: { flexDirection: 'row', alignItems: 'center' },
// Bullets ueber der Karte: reiner Kontext, bewusst ohne eigenen Container.
offerBullets: { gap: 12, marginBottom: 22, paddingHorizontal: 4 },
offerBulletRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 11 },
offerBulletText: { flex: 1, fontSize: 14.5, lineHeight: 20, fontWeight: '500' },
// Angebotskarte. Der Glow ist bewusst zurueckhaltend: opacity 0.18 statt
// eines Leuchtrahmens, damit die Karte sich abhebt ohne zu schreien.
offerCardOuter: {
borderRadius: 22,
borderWidth: 1.5,
paddingHorizontal: 18,
paddingTop: 26,
paddingBottom: 16,
marginBottom: 16,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.42,
shadowRadius: 26,
elevation: 10,
},
offerBadge: {
position: 'absolute',
top: -12,
alignSelf: 'center',
left: 0,
right: 0,
marginHorizontal: 'auto',
paddingHorizontal: 14,
paddingVertical: 5,
borderRadius: 999,
maxWidth: 160,
},
offerBadgeText: { fontSize: 11, fontWeight: '900', letterSpacing: 0.8, textAlign: 'center' },
offerHeaderRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 },
offerHeaderLeft: { flex: 1, gap: 3 },
offerHeaderRight: { alignItems: 'flex-end' },
offerPlanName: { fontSize: 17, fontWeight: '800', lineHeight: 22 },
offerSaveText: { fontSize: 12.5, fontWeight: '800', letterSpacing: 0.4 },
offerPrice: { fontSize: 25, fontWeight: '900', letterSpacing: -0.4 },
offerPricePeriod: { fontSize: 12.5, fontWeight: '600' },
offerBreakdown: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 11,
marginTop: 16,
gap: 10,
},
offerBreakdownLabel: { fontSize: 13.5, fontWeight: '600' },
offerBreakdownValue: { fontSize: 15, fontWeight: '800' },
offerCta: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 9,
height: 56,
borderRadius: 14,
marginTop: 16,
},
offerCtaText: { fontSize: 17.5, fontWeight: '800' },
offerAltLink: { alignItems: 'center', paddingVertical: 13 },
offerAltLinkText: { fontSize: 13.5, fontWeight: '600', textDecorationLine: 'underline' },
dueBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 9,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 16,
},
dueBannerText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '600' },
detailsToggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 14, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 13, marginBottom: 14 },
detailsToggleLabel: { fontSize: 14.5, fontWeight: '700' },
faqBlock: { gap: 16, marginBottom: 18, paddingHorizontal: 2 },
faqItem: { gap: 5 },
faqQuestion: { fontSize: 14.5, fontWeight: '800', fontStyle: 'italic' },
faqAnswer: { fontSize: 13.5, lineHeight: 19, fontWeight: '500' },
faqScope: { fontSize: 12.5, lineHeight: 17, fontWeight: '600', marginTop: 2 },
paywallCompare: { borderLeftWidth: 3, paddingLeft: 12, paddingVertical: 4, marginBottom: 16 },
paywallCompareText: { fontSize: 13.5, lineHeight: 19, fontWeight: '500' },
paywallBullets: { gap: 10, marginBottom: 18, paddingHorizontal: 2 },
paywallBulletRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 },
paywallBulletText: { flex: 1, fontSize: 14, lineHeight: 19, fontWeight: '600' },
// Der Preis war mit 15px kleiner als der Fliesstext. Bei einem Avatar, dessen
// Kernangst Abo-Intransparenz ist, muss der Preis das Auffaelligste im Block sein.
paywallActionBar: { paddingHorizontal: 22, paddingTop: 14, paddingBottom: 18, borderTopWidth: 1, gap: 6 },
paywallFooterLinks: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
paywallFooterText: { fontSize: 12, fontWeight: '600' },
paywallFooterCentered: { textAlign: 'center' },
safeArea: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', padding: 16 },
backButton: { width: 40, height: 40, justifyContent: 'center' },

View File

@@ -5,9 +5,10 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { AuthService } from '../../services/authService';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { AuthService } from '../../services/authService';
import { resetToFreshInstall } from '../../services/devReset';
const getDataCopy = (language: Language) => {
if (language === 'de') {
@@ -115,23 +116,45 @@ export default function DataScreen() {
]);
};
// Nur im Dev-Build: setzt die App auf "frisch installiert" zurueck. Noetig,
// weil ein Expo-Reload weder Keychain noch SQLite loescht - man landet sonst
// immer wieder direkt im Dashboard statt im Onboarding.
const handleDevReset = () => {
Alert.alert(
'Onboarding neu starten?',
'Session, Onboarding-Status und A/B-Zuweisung werden zurückgesetzt. Du landest wieder auf dem Welcome-Screen.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Zurücksetzen',
style: 'destructive',
onPress: async () => {
await resetToFreshInstall();
await signOut();
router.replace('/onboarding');
},
},
],
);
};
const handleDeleteAccount = () => {
Alert.alert(copy.deleteConfirmTitle, copy.deleteConfirmMessage, [
{ text: 'Cancel', style: 'cancel' },
{
text: copy.deleteActionBtn,
style: 'destructive',
onPress: async () => {
try {
await AuthService.deleteAccount();
await signOut();
router.replace('/onboarding');
} catch {
Alert.alert(copy.genericErrorTitle, copy.genericErrorMessage);
}
},
},
]);
text: copy.deleteActionBtn,
style: 'destructive',
onPress: async () => {
try {
await AuthService.deleteAccount();
await signOut();
router.replace('/onboarding');
} catch {
Alert.alert(copy.genericErrorTitle, copy.genericErrorMessage);
}
},
},
]);
};
return (
@@ -177,6 +200,23 @@ export default function DataScreen() {
</View>
</TouchableOpacity>
{__DEV__ ? (
<TouchableOpacity
style={[styles.actionRow, { backgroundColor: colors.cardBg, borderColor: colors.border }]}
onPress={handleDevReset}
>
<View style={styles.actionIcon}>
<Ionicons name="refresh-circle-outline" size={24} color={colors.primary} />
</View>
<View style={styles.actionTextContainer}>
<Text style={[styles.actionTitle, { color: colors.text }]}>Onboarding neu starten (DEV)</Text>
<Text style={[styles.actionHint, { color: `${colors.text}80` }]}>
Setzt Session, Onboarding-Status und A/B-Variante zurück.
</Text>
</View>
</TouchableOpacity>
) : null}
<View style={{ marginTop: 24 }}>
<TouchableOpacity style={[styles.actionRow, { backgroundColor: '#FF3B3015', borderColor: '#FF3B3050', marginBottom: 0 }]} onPress={handleDeleteAccount}>
<View style={[styles.actionIcon, { backgroundColor: '#FF3B3020' }]}>