App copy
This commit is contained in:
BIN
GreenLens_CTR_Optimierung.docx
Normal file
BIN
GreenLens_CTR_Optimierung.docx
Normal file
Binary file not shown.
BIN
GreenLens_OnPage_Rewrites.docx
Normal file
BIN
GreenLens_OnPage_Rewrites.docx
Normal file
Binary file not shown.
@@ -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' }} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 war’s.',
|
||||
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' },
|
||||
|
||||
@@ -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' }]}>
|
||||
|
||||
417
audits/Copy_Audit_Onboarding_Paywall.md
Normal file
417
audits/Copy_Audit_Onboarding_Paywall.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# GreenLens — Copy-Audit: Onboarding & Paywall
|
||||
|
||||
> **Status: umgesetzt am 27.07.2026.** Alle Empfehlungen aus diesem Dokument sind im Code implementiert
|
||||
> (DE/EN/ES). TypeScript-Check läuft fehlerfrei. Offene Punkte stehen in Abschnitt 6.
|
||||
>
|
||||
> Alle 6 echten App-Store-Bewertungen sind in `constants/socialProof.ts` hinterlegt.
|
||||
> Confidence-Werte im Worked Example spiegeln echtes Produktverhalten; die Kalibrierung im
|
||||
> Health-Prompt (`server/lib/openai.js`) wurde dafür ergänzt (Abschnitt 6.2).
|
||||
|
||||
**Audit:** 26.07.2026 · **Umsetzung:** 27.07.2026
|
||||
**Grundlage:** Deep Research Report, Avatar Sheet (Emma / "Anxious Beginner"), Offer Brief, Necessary Beliefs Doc
|
||||
**Geprüfte Screens:** `app/onboarding.tsx`, `app/onboarding/slides.tsx`, `source.tsx`, `health-check.tsx`, `personalizing.tsx`, `customize.tsx`, `app/profile/billing.tsx` (Paywall + Cancel-Flow), `components/OutOfCreditsSheet.tsx`, `utils/translations.ts`
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive Summary
|
||||
|
||||
Der Flow ist handwerklich sauber gebaut (Progress, Analytics, 3 Sprachen, Chat-Interaktion). **Das Problem ist nicht die Ausführung, sondern das Argument.**
|
||||
|
||||
Drei Befunde, alles andere ist Detail:
|
||||
|
||||
1. **Der gesamte Flow verkauft Identifikation. Das Produkt verkauft Triage.**
|
||||
Offer Brief, wörtlich: *"Start the pitch after identification, never on identification."* Der aktuelle Flow tut exakt das Gegenteil — Screen 1 bis 3 heißen „Pflanzen-Scanner", „Scanne jede Pflanze", „Monstera · 98%". Das ist genau das, was Google Lens gratis kann. Damit wird **Belief 5** („Ein Abo ist mir mehr wert als weiter googeln") nie adressiert — sie wird sogar aktiv widerlegt.
|
||||
|
||||
2. **Es gibt keinen einzigen Satz über Emmas Schmerz.**
|
||||
Der Avatar kommt mit einem *akuten Symptom*, Schuldgefühl und Angst („I'll be so discouraged if I kill another one"). Im gesamten Onboarding fällt kein Wort über gelbe Blätter, braune Spitzen, hängende Triebe oder „hab ich zu viel gegossen?". Der Flow ist Feature-Tour, nicht Argument. *Pain is the pitch* — der Pitch fehlt.
|
||||
|
||||
3. **Die Paywall kommt nach null erlebtem Wert.**
|
||||
`personalizing.tsx` → direkt `billing?view=paywall`. Der Nutzer hat zu diesem Zeitpunkt keinen Scan gemacht, keine Ausgabe gesehen, kein Symptom eingegeben. Offer Brief benennt das als **den einzelnen höchsten Hebel überhaupt**: *"A meaningful preview of the triage output before the paywall."*
|
||||
|
||||
Dazu kommen **fünf akute Credibility-Risiken** (Abschnitt 2), von denen mindestens zwei rechtlich und App-Store-relevant sind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Belief-Coverage-Matrix
|
||||
|
||||
Wo im Flow wird welcher der sechs notwendigen Beliefs bearbeitet?
|
||||
|
||||
### Vorher
|
||||
|
||||
| # | Notwendiger Belief | Wo adressiert? | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | Ein Foto kann die Ursache genug eingrenzen | nirgends | fehlt |
|
||||
| 2 | Die App sagt mir konkret, was zu prüfen und zu tun ist (**Anker**) | nur health-check, als UI-Tutorial | schwach |
|
||||
| 3 | Die App sagt ehrlich, wenn sie unsicher ist | nirgends — „98%" behauptet Sicherheit | kontraproduktiv |
|
||||
| 4 | Die Empfehlung gilt für *meine* Pflanze | behauptet, nie belegt | schwach |
|
||||
| 5 | Abo > weiter googeln | nirgends | fehlt |
|
||||
| 6 | Preis, Verlängerung, Kündigung vorab klar | Preis + „Jederzeit kündbar" | erfüllt |
|
||||
|
||||
### Nachher — alle sechs bedient
|
||||
|
||||
| # | Belief | Umgesetzt in | Konkret |
|
||||
|---|---|---|---|
|
||||
| 1 | Foto grenzt ein | `slides.tsx` Slide 1, `health-check.tsx` | „GreenLens gewichtet die wahrscheinlichsten Ursachen und sagt dir, was du prüfen sollst, um sie zu bestätigen oder auszuschließen." + Ursachen-Ranking im Beispiel |
|
||||
| 2 | Konkret prüfen & tun | `health-check.tsx`, `slides.tsx`, Paywall-Bullets | Worked Example mit „Prüfe zuerst" (3 Schritte) und „Tu jetzt" (eine Handlung) |
|
||||
| 3 | Ehrlich bei Unsicherheit | `slides.tsx` Slide 3, `health-check.tsx` limitNote, Paywall-Bullet 1 | Eigener Slide „Wir sagen dir auch, wenn wir unsicher sind" + graue Confidence-Stufen statt Prozentzahl |
|
||||
| 4 | Gilt für *meine* Pflanze | `slides.tsx` Slide 2, `personalizing.tsx` | „Ein konkreter Plan. Für diese Pflanze." + Rückspiegelung der eigenen Chat-Antworten als Chips |
|
||||
| 5 | Besser als googeln | Welcome-Variante B, **Paywall `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." — bewusst auch auf der Paywall, damit der Belief unabhängig von der A/B-Variante greift |
|
||||
| 6 | Preis & Kündigung vorab | `billing.tsx` | Monatsäquivalent des Jahrespreises, Trial-Erinnerung 2 Tage vorher, konkreter Kündigungsweg — alles vor dem CTA |
|
||||
|
||||
Der Anker-Belief (2) war ursprünglich an *einer* Stelle bedient — dort als Wegbeschreibung getarnt („Wo ist der Health-Scan?") statt als Kaufargument. Dieser Screen ist jetzt das Worked Example und trägt drei Beliefs gleichzeitig.
|
||||
|
||||
---
|
||||
|
||||
## 2. Akute Credibility- und Compliance-Risiken
|
||||
|
||||
**Diese fünf Punkte vor allen Copy-Verbesserungen klären.**
|
||||
|
||||
### 2.1 Testimonials ohne belegte Herkunft — `onboarding.tsx:23`, `personalizing.tsx:17`
|
||||
```
|
||||
„Endlich überleben meine Pflanzen! Absolute Empfehlung." — Anna M.
|
||||
„GreenLens hat meine Geigenfeige gerettet..." — Elena R.
|
||||
```
|
||||
Wenn diese Zitate nicht von realen, dokumentierten Nutzern stammen: UWG-Verstoß (§5 Irreführung), App-Store-Guideline-Risiko, und im Schadensfall genau die Vertrauensfrage, auf der die gesamte Positionierung ruht. **Entweder belegen oder entfernen.** Es gibt starke Alternativen ohne Testimonial (siehe 3.1).
|
||||
|
||||
### 2.2 „4,8 APP-STORE-BEWERTUNG" hardcoded — `personalizing.tsx:19`
|
||||
Der Deep Research Report stellt fest, dass das App-Store-Listing **keine Reviews** hat. Eine fest einprogrammierte Sternebewertung, die nicht aus einer realen Quelle stammt, ist derselbe Tatbestand wie 2.1 — nur prominenter platziert.
|
||||
|
||||
### 2.3 „Monstera · 98%" — `slides.tsx:30`
|
||||
Offer Brief, Verbotsliste: *"Not a specific accuracy percentage — self-reported figures read as marketing noise and are unverifiable."* Zusätzlich zerstört diese Zahl aktiv **Belief 3** (Ehrlichkeit über Unsicherheit), der die gesamte Differenzierung gegenüber „App XY hat mir selbstbewusst was Falsches gesagt" trägt.
|
||||
|
||||
### 2.4 Direkter Widerspruch auf der Paywall — `billing.tsx:156–159`
|
||||
```
|
||||
Headline: „Unbegrenzter Zugriff"
|
||||
Sub: „Unbegrenzte Scans, Health-Checks…"
|
||||
Card-Body: „…, 100 Credits/Monat"
|
||||
```
|
||||
„Unbegrenzt" und „100 Credits/Monat" stehen auf demselben Screen, drei Zeilen auseinander. Für einen Avatar, dessen dokumentierte Kernangst *Abo-Intransparenz* ist, ist das der schlimmstmögliche Fehler an der schlimmstmöglichen Stelle. Er zerstört den einzigen Belief, der aktuell erfüllt ist (Nr. 6).
|
||||
|
||||
### 2.5 Datenschutz-Aussage prüfen — `translations.ts:254`
|
||||
```
|
||||
„Deine Daten bleiben privat und lokal auf deinem Gerät."
|
||||
```
|
||||
Laut Architektur gehen Scans an OpenAI und Bilder an MinIO/S3. Diese Aussage ist dann **sachlich falsch**. Bitte durch etwas Wahres ersetzen, z. B.: *„Deine Fotos werden nur zur Analyse verarbeitet und nicht weiterverkauft."* (nur wenn zutreffend).
|
||||
|
||||
**Nebenbefund — Umlaute:** In `health-check.tsx` (DE) steht durchgängig ASCII-Ersatz: „Spaeter", „oeffnen", „Ausfuehrliche", „Sofortmassnahmen", „Wo ist der Health-Scan?" mit „Donde" statt „Dónde" im ES. Auf einem Screen, auf dem Zahlungsbereitschaft entsteht, liest sich das als unfertiges Produkt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Screen für Screen — Diagnose & Rewrite
|
||||
|
||||
### 3.1 Welcome — `app/onboarding.tsx`
|
||||
|
||||
**Aktuell**
|
||||
> Willkommen bei GreenLens!
|
||||
> Pflanzen erkennen, verstehen und pflegen — ganz einfach.
|
||||
> „Endlich überleben meine Pflanzen!" — Anna M.
|
||||
> [Los geht's]
|
||||
|
||||
**Diagnose**
|
||||
- „Willkommen bei GreenLens!" ist eine Begrüßung, keine Headline. Sie kostet den wertvollsten Platz im gesamten Produkt und sagt nichts.
|
||||
- „ganz einfach" ist ein subjektives Adjektiv ohne Beleg — *point, don't talk*.
|
||||
- Die Subline ist eine Kategoriebeschreibung, die auf jede der zwölf Konkurrenz-Apps passt. Keine Pattern Interruption.
|
||||
- One-Mississippi-Test: nach zwei Sekunden weiß der Nutzer, dass es um Pflanzen geht. Sonst nichts.
|
||||
|
||||
**Rewrite A — Symptom-Einstieg (empfohlen, adressiert Belief 1+2+5)**
|
||||
> **Gelbe Blätter. Und jetzt?**
|
||||
> GreenLens sagt dir die wahrscheinlichsten Ursachen, was du selbst prüfen sollst und die eine sichere Sache, die du jetzt tun kannst.
|
||||
> [Meine Pflanze prüfen] · Schon dabei? Anmelden
|
||||
|
||||
**Rewrite B — Abgrenzung zur Gratis-Alternative (adressiert Belief 5 frontal)**
|
||||
> **Andere Apps sagen dir, wie deine Pflanze heißt.**
|
||||
> **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.
|
||||
> [Pflanze prüfen]
|
||||
|
||||
Beide ersetzen das Testimonial durch Argument. Wenn belegte Testimonials existieren, gehören sie *nach* das Argument, nicht davor.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Slides — `app/onboarding/slides.tsx`
|
||||
|
||||
**Aktuell:** „Scanne jede Pflanze" → „Health Check & Pflegeplan" → „Nie mehr Gießen vergessen"
|
||||
|
||||
**Diagnose**
|
||||
- **Falsche Reihenfolge.** Slide 1 ist die Gratis-Funktion. Slide 2 ist das Produkt. Der stärkste Grund zu zahlen steht an Position 2 von 3 — und der Nutzer, der nach Slide 1 aussteigt, hat ihn nie gesehen.
|
||||
- „Nie mehr Gießen vergessen" verkauft an einen anderen Avatar (den organisierten Sammler). Emma hat kein Erinnerungsproblem, sie hat ein *Diagnoseproblem*. Slide 3 wechselt mitten im Flow das Publikum.
|
||||
- „Monstera · 98%" → siehe 2.3.
|
||||
- Slide 2 ist inhaltlich richtig, aber unterspezifiziert: „erkennt Probleme früh und erstellt deinen Rettungsplan" — was steht in dem Plan? Genau das weiß `health-check.tsx` bereits sehr gut. Der Text existiert schon, nur am falschen Screen.
|
||||
|
||||
**Rewrite — 3 Slides, neu geordnet**
|
||||
|
||||
**Slide 1 (war 2) — das Produkt**
|
||||
> **Was ist mit meiner Pflanze los?**
|
||||
> Fotografiere das Symptom. GreenLens rankt die wahrscheinlichsten Ursachen und sagt dir, was du prüfen sollst, um sie zu bestätigen oder auszuschließen.
|
||||
> *Visual-Chip:* „Wahrscheinlichste Ursache: Überwässerung · Prüfe: Erde 3 cm tief, Blattunterseiten"
|
||||
|
||||
**Slide 2 — der Plan (Belief 2 + 4)**
|
||||
> **Ein konkreter Plan. Für diese Pflanze.**
|
||||
> Sofortmaßnahme, 7-Tage-Plan, und nach 7 Tagen die Frage: Hat es gewirkt? Wenn nicht, geht es weiter.
|
||||
|
||||
**Slide 3 — Ehrlichkeit als Feature (Belief 3 — aktuell komplett unbesetzt)**
|
||||
> **Wir sagen dir auch, wenn wir unsicher sind.**
|
||||
> Ein Foto zeigt keine Wurzeln, keine Erdfeuchte, keine Vorgeschichte. Deshalb bekommst du Wahrscheinlichkeiten und Prüfschritte — keine erfundene Gewissheit.
|
||||
|
||||
Slide 3 ist der Screen, den keine Konkurrenz-App hat. Er beantwortet „Eine andere App hat mir selbstbewusst was Falsches gesagt" — laut Offer Brief eine der Top-Einwände.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Chat-Flow — `app/onboarding/source.tsx`
|
||||
|
||||
**Aktuell:** Monstera-Bot → „Wie hast du von GreenLens gehört?" → Ziel → Erfahrung → Licht
|
||||
|
||||
**Diagnose**
|
||||
- **Die erste Frage dient dir, nicht ihm.** Attribution („Wie hast du von GreenLens gehört?") ist eine Marketing-Frage an der Stelle mit der höchsten Abbruchgefahr. Sie signalisiert dem Nutzer: dieses Gespräch ist für die Firma. Verschieben — nach dem ersten Erfolgserlebnis oder in die Profil-Settings.
|
||||
- **Die Fiktion kollidiert mit der Positionierung.** „Hi! Ich bin deine neue Monstera 🌿" ist charmant, aber der Nutzer hat evtl. gar keine Monstera — und die gesamte Differenzierung basiert auf *Ehrlichkeit und Nüchternheit*. Eine sprechende Pflanze an der Stelle, an der Vertrauen entstehen soll, arbeitet gegen Belief 3.
|
||||
- **Es wird nie nach dem Symptom gefragt** — der einzigen Information, die für Emmas Job-to-be-done tatsächlich zählt.
|
||||
- Positiv: Die Lichtfrage ist gut motiviert („damit ich glücklich bleibe"). Genau dieses Muster — *warum ich das frage* — fehlt bei allen anderen Fragen.
|
||||
|
||||
**Rewrite — Fragenreihenfolge**
|
||||
|
||||
| # | Frage | Warum |
|
||||
|---|---|---|
|
||||
| 1 | „Hat gerade eine deiner Pflanzen ein Problem?" (Ja, akut / Ja, schleichend / Nein, vorbeugend) | Sofortige Relevanz, segmentiert Emma vs. Sammler |
|
||||
| 2 | „Was siehst du?" (Gelbe Blätter · Braune Spitzen · Hängt · Flecken/Belag · Krabbeltiere · Etwas anderes) | Das ist der P.I.G.-Moment. Hier erkennt sie sich wieder. |
|
||||
| 3 | „Wie viel Licht bekommt sie?" | bleibt — gut gemacht |
|
||||
| 4 | „Wie sicher fühlst du dich bei Pflanzenpflege?" | bleibt |
|
||||
| — | „Wie hast du von uns gehört?" | **raus aus dem Flow** |
|
||||
|
||||
Jede Frage mit einem Halbsatz begründen, warum sie gestellt wird — so wie es die Lichtfrage bereits tut.
|
||||
|
||||
Wenn der Monstera-Charakter bleiben soll: als *Beispielpflanze zur Demo* rahmen, nicht als Persona, die vorgibt, die Pflanze des Nutzers zu sein.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Health-Check-Screen — `app/onboarding/health-check.tsx`
|
||||
|
||||
**Das ist der stärkste Screen im Flow — und er ist als Bedienungsanleitung verkleidet.**
|
||||
|
||||
Diese vier Zeilen sind das gesamte Verkaufsargument:
|
||||
> Gesundheits-Score mit Status: stabil, beobachten oder kritisch.
|
||||
> Ausführliche Analyse mit sichtbaren Hinweisen **und Unsicherheit**.
|
||||
> Wahrscheinlichste Ursachen **mit Confidence-Werten**.
|
||||
> Sofortmaßnahmen plus konkreter 7-Tage-Pflegeplan.
|
||||
|
||||
Das bedient Belief 1, 2, 3 und 4 gleichzeitig. Es steht aber unter der Überschrift **„Wo ist der Health-Scan?"** — also unter einer Wegbeschreibung, an Position 5 von 6, kurz vor der Paywall.
|
||||
|
||||
**Empfehlung:** Inhalt hochziehen (Slides 1+2, siehe 3.2). Den Screen selbst zu einem **konkreten Beispiel** umbauen — laut Offer Brief *"the single highest-leverage proof asset available today"*:
|
||||
|
||||
> **So sieht eine Antwort aus**
|
||||
>
|
||||
> *Symptom:* Gelbe untere Blätter, Erde feucht
|
||||
> **Wahrscheinlichste Ursache: Überwässerung** (hoch)
|
||||
> Weniger wahrscheinlich: Nährstoffmangel (mittel) · Lichtmangel (niedrig)
|
||||
>
|
||||
> **Prüfe zuerst:** Erde 3 cm tief · Abzugslöcher im Topf · Blattunterseiten
|
||||
> **Tu jetzt:** Nicht gießen. In 5 Tagen erneut prüfen.
|
||||
> **In 7 Tagen:** Wir fragen nach, ob es besser wird.
|
||||
>
|
||||
> *Ein Foto zeigt keine Wurzeln. Deshalb Wahrscheinlichkeiten statt Diagnose.*
|
||||
|
||||
Ein echtes Beispiel schlägt jede Behauptung. Und es beantwortet den Einwand „Die Tipps sind eh generisch" ohne ein einziges Adjektiv.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Personalizing — `app/onboarding/personalizing.tsx`
|
||||
|
||||
**Diagnose**
|
||||
- Loading-Screens, die Arbeit simulieren, sind branchenüblich — aber „Antworten werden analysiert / Pflegeplan wird erstellt / Scan-Credits werden vorbereitet / Plan wird finalisiert" beschreibt **deine** Prozesse, nicht ihren Nutzen. Es ist Wartezeit ohne Überzeugungsarbeit an der Stelle direkt vor der Kaufentscheidung.
|
||||
- Testimonial + „4,8 App-Store-Bewertung": siehe 2.1 / 2.2.
|
||||
- „Scan-Credits werden vorbereitet" führt einen Begriff ein, der nie erklärt wurde — und der zwei Screens später zum Widerspruch mit „unbegrenzt" führt.
|
||||
|
||||
**Rewrite — Schritte auf ihren Nutzen umschreiben**
|
||||
> Deine Antworten werden ausgewertet
|
||||
> Passende Ursachen für dein Symptom werden gewichtet
|
||||
> Prüfschritte für deine Lichtsituation werden ausgewählt
|
||||
> Dein 7-Tage-Plan steht
|
||||
|
||||
Statt Testimonial an dieser Stelle: die **Zusammenfassung ihrer eigenen Angaben** einblenden („Wenig Licht · Gelbe Blätter · Anfängerin"). Das beweist Belief 4 (*es geht um meine Pflanze*) durch Demonstration statt Behauptung.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Paywall — `app/profile/billing.tsx`
|
||||
|
||||
**Aktuell**
|
||||
> GREENLENS PRO
|
||||
> **Unbegrenzter Zugriff**
|
||||
> Unbegrenzte Scans, Health-Checks und dein persönlicher Pflegeplan.
|
||||
> GreenLens Pro — Unbegrenzte KI-Scans, Gesundheitsdiagnose, 7-Tage-Rettungspläne, 100 Credits/Monat
|
||||
> 7 Tage gratis, dann 39,99 €/Jahr · [Gratis testen] · Jederzeit kündbar
|
||||
|
||||
**Diagnose**
|
||||
1. **„Unbegrenzter Zugriff" vs. „100 Credits/Monat"** — siehe 2.4. Muss weg, unabhängig von allem anderen.
|
||||
2. **Headline verkauft Menge, nicht Ergebnis.** „Unbegrenzt" ist eine Mengenaussage. Emma will kein Volumen, sie will *wissen, was zu tun ist*. Value Equation: Der Traumzustand wird nicht benannt.
|
||||
3. **„Gesundheitsdiagnose"** — laut Offer Brief explizit nicht versprechbar („Not a guaranteed or lab-grade diagnosis"). Wording: *Ursachen-Analyse* oder *Triage*.
|
||||
4. **Kein Risk Reversal jenseits von „Jederzeit kündbar".** Was honest garantierbar ist: transparente Abrechnung + klarer Kündigungsweg. Beides gehört *sichtbar auf den Screen*, nicht in eine Fußnote.
|
||||
5. **Kein Preisanker.** 39,99 €/Jahr steht allein im Raum. Vergleich fehlt (eine ersetzte Pflanze; ein Nursery-Besuch).
|
||||
6. **Nichts erinnert an den Schmerz.** Zwischen Symptomfrage und Kaufentscheidung liegen drei Screens, auf denen ihr Problem nie wieder erwähnt wurde.
|
||||
|
||||
**Rewrite**
|
||||
> **GREENLENS PRO**
|
||||
> ## Nicht mehr raten, was deiner Pflanze fehlt.
|
||||
> Bei jedem Symptom: wahrscheinlichste Ursache, was du prüfen sollst, was du jetzt tust — und ein Check nach 7 Tagen, ob es gewirkt hat.
|
||||
>
|
||||
> ✓ Ursachen-Ranking statt einer Behauptung — inklusive dem, was unsicher ist
|
||||
> ✓ Konkrete Prüfschritte, keine allgemeinen Pflegetexte
|
||||
> ✓ 7-Tage-Plan pro Problem, plus Nachfrage, ob es besser wurde
|
||||
> ✓ 100 Scans & Nachfragen im Monat *(Pro-Modell)*
|
||||
> ✓ Deine komplette Pflanzen-Historie
|
||||
>
|
||||
> **7 Tage gratis. Danach 39,99 €/Jahr (3,33 €/Monat).**
|
||||
> Wir erinnern dich 2 Tage vor Ablauf. Kündigen in 2 Taps in den iOS-Einstellungen — Anleitung in der App.
|
||||
>
|
||||
> [7 Tage gratis testen]
|
||||
> *Heute fällig: 0,00 € · Erste Abbuchung am 2. August*
|
||||
|
||||
Änderungen im Detail:
|
||||
- „Unbegrenzt" ersatzlos gestrichen → interner Widerspruch aufgelöst
|
||||
- Headline benennt den Zustand, nicht die Menge
|
||||
- „Gesundheitsdiagnose" → „Ursachen-Ranking" (compliant mit Offer Brief)
|
||||
- Unsicherheits-Bullet als **Feature** → Belief 3 zum ersten Mal im ganzen Flow bedient
|
||||
- Trial-Erinnerung + konkreter Kündigungsweg → Belief 6 von „erfüllt" auf „übererfüllt"
|
||||
- Monatsäquivalent des Jahrespreises sichtbar (3,33 €) → senkt die wahrgenommene Hürde bei einem preissensiblen Avatar
|
||||
|
||||
**Zusätzlich, höchster Hebel:** Vor die Paywall einen echten Scan setzen. Ein Foto, ein Ergebnis, dann Paywall. Der Offer Brief nennt das die wirksamste verfügbare Einzelmaßnahme — und der aktuelle Flow ist genau eine `router.replace`-Zeile davon entfernt (`personalizing.tsx:75`).
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Out-of-Credits Sheet — `components/OutOfCreditsSheet.tsx`
|
||||
|
||||
**Aktuell**
|
||||
> Deine Gratis-Scans sind aufgebraucht.
|
||||
> Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.
|
||||
|
||||
**Diagnose:** Reine Verlustmeldung. Der Nutzer wollte gerade etwas tun und wird blockiert. Das ist der Moment mit der höchsten *Intention* im ganzen Produkt und er wird mit einer Fehlermeldung eröffnet. Außerdem wieder „unbegrenzt" (siehe 2.4).
|
||||
|
||||
**Rewrite**
|
||||
> **Diese Pflanze schauen wir uns noch an.**
|
||||
> Mit Pro läuft dein Scan sofort — plus Ursachen-Ranking, Prüfschritte und dein 7-Tage-Plan.
|
||||
> [Diesen Scan mit Pro starten] · [Einzelne Credits kaufen] · Später
|
||||
|
||||
Der CTA soll **die angefangene Aufgabe zu Ende bringen**, nicht ein Abo verkaufen. Der Kauf ist das Mittel, nicht das Angebot.
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Cancel-Flow — `billing.tsx:141–150`
|
||||
|
||||
**Gut gemacht:** Grund vor Angebot abfragen, gestaffeltes Save-Offer, klarer Abbruchweg. Struktur stimmt.
|
||||
|
||||
**Zwei Verbesserungen**
|
||||
- „Schade, dass du gehst" / „Ein Geschenk für dich!" — der Ton ist unternehmensseitig („wir sind traurig"). Besser nutzerseitig: *„Bevor du gehst — was hat nicht gepasst?"*
|
||||
- Das Save-Offer ist grundsegmentiert falsch: Wer „Ich nutze die App zu selten" wählt, bekommt einen Rabatt, obwohl der Preis nicht sein Problem ist. Bei *zu selten* wäre die passende Antwort **Pause statt Rabatt** („Abo für 3 Monate pausieren") — und das rettet die Beziehung, ohne Marge zu verschenken.
|
||||
|
||||
---
|
||||
|
||||
## 4. Umsetzungsstand
|
||||
|
||||
### Erledigt (27.07.2026)
|
||||
|
||||
| # | Maßnahme | Datei |
|
||||
|---|---|---|
|
||||
| 1 | Erfundene Testimonials entfernt, echte Store-Zitate als einzige Quelle | `constants/socialProof.ts` (neu), `onboarding.tsx`, `personalizing.tsx` |
|
||||
| 2 | Rating auf belegbare **5,0 · 6 Bewertungen** — Wert und Anzahl immer gemeinsam | `constants/socialProof.ts`, `onboarding.tsx` |
|
||||
| 3 | „Unbegrenzt / unlimited / ilimitado" überall entfernt, wo Credits limitiert sind | `billing.tsx`, `OutOfCreditsSheet.tsx` |
|
||||
| 4 | „98%" ersetzt durch Ursachen-Ranking mit hoch/mittel/niedrig | `slides.tsx`, `health-check.tsx` |
|
||||
| 5 | Datenschutz-Aussage korrigiert (kein „bleibt lokal auf deinem Gerät" mehr) | `translations.ts` (DE/EN/ES) |
|
||||
| 6 | Umlaute und Akzente in `health-check.tsx` korrigiert | `health-check.tsx` |
|
||||
| 7 | Welcome-Rewrite mit A/B-Test (Symptom vs. Abgrenzung) | `onboarding.tsx`, `services/experiments.ts` (neu) |
|
||||
| 8 | Slides neu geordnet: Triage → Plan → Unsicherheit | `slides.tsx` |
|
||||
| 9 | Chat: Attributionsfrage ans Ende, Symptomfrage nach vorn | `source.tsx` |
|
||||
| 10 | Health-Check-Screen zum Worked Example umgebaut | `health-check.tsx` |
|
||||
| 11 | Personalizing: Nutzensprache + Rückspiegelung der eigenen Antworten | `personalizing.tsx` |
|
||||
| 12 | Paywall-Rewrite inkl. Bullets, Vergleich, Monatsäquivalent, Trial-Erinnerung, Kündigungsweg | `billing.tsx` |
|
||||
| 13 | Out-of-Credits als „Aufgabe zu Ende bringen" reframed | `OutOfCreditsSheet.tsx` |
|
||||
| 14 | Cancel-Flow: Pause statt Rabatt bei „nutze zu selten" | `billing.tsx` |
|
||||
| 15 | „Pro scans with GPT-5.4" entfernt — Modellname ist kein Nutzen | `billing.tsx` |
|
||||
| 16 | Hardcodierte englische Fragen-Überschriften im Chat lokalisiert | `source.tsx` |
|
||||
|
||||
**Verifikation:** `npx tsc --noEmit` → 0 Fehler. Grep-Gegenprobe auf „unbegrenzt/unlimited/ilimitado", „Anna M.", „Elena R.", „4,8", „98%", „GPT-5" → keine Treffer außer erklärenden Kommentaren.
|
||||
|
||||
### Neue Dateien
|
||||
|
||||
- **`constants/socialProof.ts`** — einzige Quelle für Sterne, Anzahl und Zitate. Regel im Kopf der Datei: nur was im Store nachweisbar ist. Solange `TESTIMONIALS` leer ist, rendert das UI Argument-Copy statt eines Zitats.
|
||||
- **`services/experiments.ts`** — lokale, persistierte 50/50-Zuweisung. Siehe Abschnitt 5.
|
||||
|
||||
---
|
||||
|
||||
## 5. A/B-Test: wie er funktioniert und wie du ihn auswertest
|
||||
|
||||
**Warum nicht PostHog Feature Flags:** Der PostHog-Client läuft bewusst ohne Provider (`services/analytics.ts` — die Provider-Variante hat früher den App-Start zerlegt). Flags wären erst nach einem Netzwerk-Roundtrip da, der Welcome-Screen rendert aber sofort. Die Zuweisung passiert deshalb lokal, wird in AsyncStorage persistiert und an PostHog gemeldet.
|
||||
|
||||
**Was passiert:**
|
||||
|
||||
1. Beim ersten Öffnen des Welcome-Screens wird 50/50 gewürfelt: `symptom` oder `contrast`.
|
||||
2. Die Variante wird persistiert — derselbe Nutzer sieht immer dieselbe.
|
||||
3. `experiment_assigned` wird gefeuert, plus `register()` auf dem PostHog-Client. Dadurch hängt `experiment_welcome_headline_v1` **an jedem folgenden Event** dieses Nutzers.
|
||||
|
||||
**Auswertung in PostHog:** Funnel bauen über `onboarding_welcome_viewed` → `onboarding_started` → `onboarding_chat_completed` → `paywall_opened` → `subscription_started`/`trial_started`, dann *Breakdown by* `experiment_welcome_headline_v1`. Kein Backend-Umbau nötig — die Property liegt auf allen Events.
|
||||
|
||||
**Aussagekraft:** Bei 6 Bewertungen im Store bist du noch in einer Größenordnung, in der ein Headline-Test lange braucht, um signifikant zu werden. Bis dahin ist der Test vor allem eine saubere Infrastruktur — die Entscheidung darüber, welche Variante gewinnt, sollte nicht auf den ersten 50 Nutzern getroffen werden.
|
||||
|
||||
**QA:** `forceVariant('welcome_headline_v1', 'contrast')` erzwingt eine Variante zum Testen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Offene Punkte
|
||||
|
||||
### 6.1 Store-Bewertungen — eingetragen ✅
|
||||
|
||||
Alle 6 echten Bewertungen liegen in `constants/socialProof.ts`, Wortlaut und Anzeigename unverändert. Jede hat ein `priority`-Feld — die Auswahl im UI richtet sich danach, wie stark eine Bewertung die Positionierung stützt, nicht nach Reihenfolge.
|
||||
|
||||
**Priorität 1 ist „Exactly what I needed" (Chrispetersssss, EN)** und die ist ein Glücksfall: *"Snap a photo and it tells you what's wrong and how to fix it - no more guessing why the leaves are turning yellow."* Das ist die Triage-Positionierung, formuliert von einem echten Nutzer — inklusive „gelbe Blätter", also exakt dem Symptom aus der Welcome-Variante A. Diese Bewertung ist wertvoller als jede Headline, die wir selbst schreiben könnten.
|
||||
|
||||
Priorität 2 („Die Pflanzenanalyse toppt echt jede andere App") stützt Belief 5, Priorität 3 („weil ich keinen grünen Daumen habe") trifft den Avatar wörtlich.
|
||||
|
||||
Alle Bewertungen haben freigegebene Übersetzungen für die jeweils anderen Sprachen. Spanische Nutzer sehen die englische Top-Bewertung übersetzt — mit sichtbarem „Traducido"-Hinweis, weil ein fremdsprachiges Original nie unkommentiert als muttersprachlich ausgegeben werden darf.
|
||||
|
||||
**Pflege:** `APP_STORE_RATING` bei jeder Listing-Änderung nachziehen; `verifiedOn` dokumentiert die letzte Gegenprüfung.
|
||||
|
||||
### 6.2 Belief 3 — gedeckt, nach einem Fund im Backend-Prompt ✅
|
||||
|
||||
Bei der Gegenprüfung in `server/lib/openai.js` sind zwei Dinge aufgefallen, die die Copy betroffen hätten.
|
||||
|
||||
**Fund 1: Die Confidence-Skala galt nur für die Arterkennung.**
|
||||
|
||||
`buildScanPrompt` (Arterkennung) hatte eine saubere Kalibrierung:
|
||||
|
||||
```
|
||||
0.85–0.95 Art klar erkennbar
|
||||
0.65–0.84 sehr wahrscheinlich, einzelne Merkmale verdeckt
|
||||
0.40–0.64 mehrdeutig, mehrere Arten passen
|
||||
< 0.40 überwiegend geraten
|
||||
```
|
||||
|
||||
`buildHealthPrompt` (die Ursachen — also genau das, was das Worked Example zeigt) hatte **keine**. Dort stand nur: `"confidence": float 0.05–0.99 reflecting visual certainty`. Eine freie Zahl ohne definierte Bedeutung. Die App zeigt sie aber als Prozentwert an, und die neue Copy verspricht, dass wir Unsicherheit ehrlich ausweisen — dieses Versprechen hing damit an einem uncalibrierten Wert.
|
||||
|
||||
**Behoben:** Dieselbe Skala ist jetzt auch im Health-Prompt hinterlegt, plus die explizite Anweisung *"Do not inflate confidence to appear decisive. If the photo cannot settle it, a value below 0.65 is the correct and expected answer."* Damit ist Belief 3 nicht nur Copy, sondern Modellverhalten.
|
||||
|
||||
**Die Schwelle liegt bei 65 %, nicht 70 %.** Deine Einschätzung war der Sache nach richtig — 65–84 % ist der „sehr wahrscheinlich"-Bereich, darunter wird es mehrdeutig. Die exakte Kante liegt aber bei 0,65. Die Beispiel-Notiz sagt jetzt „Unter 65 %" und stimmt damit mit dem Prompt überein. Wenn du die Schwelle im Prompt änderst, ist die Copy in `health-check.tsx` (`confidenceNote`, alle drei Sprachen) mitzuziehen.
|
||||
|
||||
**Fund 2: Die Beispielwerte hätten ein Verhalten gezeigt, das das Produkt nicht hat.**
|
||||
|
||||
Meine ersten Zahlen waren 58 / 27 / 11 — das summiert sich auf ~96 % und liest sich damit als Wahrscheinlichkeitsverteilung. Das Produkt liefert aber **unabhängige** Sicherheiten je Ursache; zwei Ursachen können beide bei 0,8 liegen. Ein Nutzer, der das Beispiel gesehen hat und dann echte Werte bekommt, die sich auf 140 % addieren, hätte zu Recht das Gefühl, dass etwas nicht stimmt.
|
||||
|
||||
**Geändert auf 64 / 41 / 22.** Summiert sich bewusst nicht auf 100, spiegelt echtes Verhalten und behält den entscheidenden Punkt: der Spitzenwert liegt knapp unter der Sicherheitsschwelle, das Beispiel zeigt also einen ehrlich unsicheren Fall. Im Prompt steht die Nicht-Summierung jetzt ebenfalls explizit (`these are NOT probabilities and must NOT sum to 1`), damit das Modell es nicht selbst normalisiert.
|
||||
|
||||
**Warum das der stärkere Beweis ist:** Ein Beispiel mit 94 % würde aussehen wie jede Konkurrenz-App, der die Nutzer nicht mehr glauben. 64 % plus „unter 65 % sagen wir das deutlich" beweist die Behauptung im selben Atemzug, in dem sie aufgestellt wird — und macht die Prüfschritte zur logischen Konsequenz statt zum Beiwerk.
|
||||
|
||||
**Abgrenzung zum entfernten „98 %":** Der Offer Brief verbietet *Accuracy-Claims* über das Produkt insgesamt („zu 98 % genau") — eine unbelegbare Marketing-Zahl. Ein *fallbezogener Confidence-Wert* im Ergebnis ist das Gegenteil: keine Werbeaussage, sondern eine Auskunft über die Grenzen dieser einen Einschätzung. Das alte „Monstera · 98 %" war Ersteres, die 64 % im Beispiel sind Letzteres.
|
||||
|
||||
**Noch zu beobachten:** Der Prompt ist geändert, aber noch nicht gegen echte Scans validiert. Nach dem nächsten Deploy ein paar Health-Scans mit uneindeutigen Fotos laufen lassen und prüfen, ob die Werte tatsächlich in den unteren Bereich fallen — ein Modell, das trotz Anweisung durchgehend 0,9 ausgibt, wäre wieder eine Produkt-, keine Copy-Lücke.
|
||||
|
||||
### 6.3 Scan vor der Paywall
|
||||
|
||||
Du hast angemerkt, dass der Demo-Scan auf dem Welcome-Screen bereits existiert. Er ist jetzt als **„Erst ausprobieren — ohne Konto"** formuliert und wird mit `onboarding_demo_scan_started` getrackt.
|
||||
|
||||
Der Flow bleibt bewusst unverändert: `personalizing.tsx` → Paywall. Grund: Wer den Demo-Scan nimmt, umgeht das Onboarding komplett und landet nie bei der Paywall — wer das Onboarding durchläuft, sieht keinen Scan. Beide Wege haben eine Lücke, und das Zusammenführen berührt Credits- und Auth-Logik, nicht Copy.
|
||||
|
||||
**Empfehlung als eigener Schritt:** Nach `personalizing.tsx` auf den Scanner leiten („Deine erste Analyse ist inklusive"), Paywall erst nach dem ersten Ergebnis. Der Offer Brief nennt das den höchsten verfügbaren Einzelhebel. In PostHog lässt sich vorher vergleichen, wie die Demo-Scan-Nutzer gegen die Onboarding-Nutzer konvertieren — die Daten dafür laufen ab jetzt.
|
||||
|
||||
### 6.4 Cancel-Flow: Pause technisch umsetzen
|
||||
|
||||
Die Pause-Option ist in der UI umgesetzt und wird als `cancel_save_offer_accepted` mit `offer: 'pause_3_months'` getrackt. Die tatsächliche Umsetzung am Store-Abo steht noch aus — im Code als `TODO(billing)` markiert. Aktuell zeigt sie einen Mock-Alert, genau wie der Rabatt vorher auch.
|
||||
|
||||
### 6.5 Symptom-Antwort in die Personalisierung führen
|
||||
|
||||
`situation` und `symptom` aus dem Chat werden jetzt gespeichert (`PreAuthOnboardingService`) und auf dem Personalizing-Screen zurückgespiegelt. Sie fließen aber noch nicht in den tatsächlich generierten Plan ein. Das wäre der nächste Schritt, damit Belief 4 nicht nur demonstriert, sondern eingelöst wird.
|
||||
@@ -7,49 +7,52 @@ import { useColors } from '../constants/Colors';
|
||||
type ColorsType = ReturnType<typeof useColors>;
|
||||
|
||||
const getCopy = (language: Language, isPro: boolean) => {
|
||||
// Framing: der Nutzer wollte gerade etwas tun und wurde gestoppt. Der CTA
|
||||
// bringt die angefangene Aufgabe zu Ende - das Abo ist das Mittel, nicht das
|
||||
// Angebot. Kein "unbegrenzt": das Pro-Kontingent ist 100 Scans pro Monat.
|
||||
if (language === 'de') {
|
||||
return {
|
||||
title: isPro ? 'Deine Credits sind aufgebraucht' : 'Deine Gratis-Scans sind aufgebraucht',
|
||||
title: 'Diese Pflanze schauen wir uns noch an.',
|
||||
body: (date: string) => (isPro
|
||||
? `Deine Credits erneuern sich am ${date}. Kauf Credits nach, um weiterzuscannen.`
|
||||
: `Deine 3 Gratis-Scans erneuern sich am ${date}. Hol dir Pro für unbegrenztes Scannen.`),
|
||||
? `Deine Credits erneuern sich am ${date}. Mit einem Credit-Paket läuft dieser Scan sofort.`
|
||||
: `Deine 3 Gratis-Scans erneuern sich am ${date}. Mit Pro läuft dieser Scan sofort - plus Ursachen-Ranking, Prüfschritte und dein 7-Tage-Plan.`),
|
||||
bodyNoDate: isPro
|
||||
? 'Kauf Credits nach, um weiterzuscannen.'
|
||||
: 'Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.',
|
||||
cta: 'Pro-Pläne ansehen',
|
||||
? 'Mit einem Credit-Paket läuft dieser Scan sofort.'
|
||||
: 'Mit Pro läuft dieser Scan sofort - plus Ursachen-Ranking, Prüfschritte und dein 7-Tage-Plan.',
|
||||
cta: isPro ? 'Credits aufladen' : 'Diesen Scan mit Pro starten',
|
||||
topupsLabel: 'Oder einzelne Credits kaufen',
|
||||
later: 'Vielleicht später',
|
||||
later: 'Später',
|
||||
best: 'BESTE WAHL',
|
||||
credits: 'Credits',
|
||||
};
|
||||
}
|
||||
if (language === 'es') {
|
||||
return {
|
||||
title: isPro ? 'Se acabaron tus créditos' : 'Se acabaron tus escaneos gratis',
|
||||
title: 'Vamos a mirar esta planta.',
|
||||
body: (date: string) => (isPro
|
||||
? `Tus créditos se renuevan el ${date}. Compra créditos para seguir escaneando.`
|
||||
: `Tus 3 escaneos gratis se renuevan el ${date}. Pásate a Pro para escanear sin límites.`),
|
||||
? `Tus créditos se renuevan el ${date}. Con un paquete de créditos este escaneo sale ya.`
|
||||
: `Tus 3 escaneos gratis se renuevan el ${date}. Con Pro este escaneo sale ya - más ranking de causas, pasos de revisión y tu plan de 7 días.`),
|
||||
bodyNoDate: isPro
|
||||
? 'Compra créditos para seguir escaneando.'
|
||||
: 'Pásate a Pro para escanear sin límites.',
|
||||
cta: 'Ver planes Pro',
|
||||
? 'Con un paquete de créditos este escaneo sale ya.'
|
||||
: 'Con Pro este escaneo sale ya - más ranking de causas, pasos de revisión y tu plan de 7 días.',
|
||||
cta: isPro ? 'Recargar créditos' : 'Hacer este escaneo con Pro',
|
||||
topupsLabel: 'O compra créditos sueltos',
|
||||
later: 'Quizás más tarde',
|
||||
later: 'Más tarde',
|
||||
best: 'MEJOR OPCIÓN',
|
||||
credits: 'créditos',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: isPro ? "You're out of credits" : "You're out of free scans",
|
||||
title: "Let's still look at this plant.",
|
||||
body: (date: string) => (isPro
|
||||
? `Your credits renew on ${date}. Top up to keep scanning.`
|
||||
: `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`),
|
||||
? `Your credits renew on ${date}. With a credit pack this scan runs right now.`
|
||||
: `Your 3 free scans renew on ${date}. With Pro this scan runs right now - plus a cause ranking, things to check and your 7-day plan.`),
|
||||
bodyNoDate: isPro
|
||||
? 'Top up to keep scanning.'
|
||||
: 'Upgrade to Pro for unlimited scanning.',
|
||||
cta: 'See Pro Plans',
|
||||
? 'With a credit pack this scan runs right now.'
|
||||
: 'With Pro this scan runs right now - plus a cause ranking, things to check and your 7-day plan.',
|
||||
cta: isPro ? 'Top up credits' : 'Run this scan with Pro',
|
||||
topupsLabel: 'Or buy single credits',
|
||||
later: 'Maybe later',
|
||||
later: 'Later',
|
||||
best: 'BEST',
|
||||
credits: 'credits',
|
||||
};
|
||||
|
||||
187
constants/socialProof.ts
Normal file
187
constants/socialProof.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Language } from '../types';
|
||||
|
||||
/**
|
||||
* EINZIGE QUELLE FUER SOCIAL PROOF IN DER APP.
|
||||
*
|
||||
* Regel: Hier steht ausschliesslich, was im App Store / Play Store nachweisbar ist.
|
||||
* Keine erfundenen Zitate, keine geschaetzten Sterne, keine Rundungen nach oben.
|
||||
* Wenn eine Angabe nicht belegbar ist, wird sie hier entfernt - nicht im UI kaschiert.
|
||||
*
|
||||
* Stand: 27.07.2026 - 6 Bewertungen, Durchschnitt 5,0.
|
||||
* Bei jeder Aktualisierung des Store-Listings hier nachziehen.
|
||||
*/
|
||||
export const APP_STORE_RATING = {
|
||||
value: 5.0,
|
||||
count: 6,
|
||||
/** Datum der letzten Verifikation gegen App Store Connect. */
|
||||
verifiedOn: '2026-07-27',
|
||||
};
|
||||
|
||||
export type Testimonial = {
|
||||
/** Titel der Bewertung im Store. */
|
||||
title: string;
|
||||
/** Wortlaut exakt wie im Store veroeffentlicht (gekuerzt nur mit […]). */
|
||||
quote: string;
|
||||
/** Store-Anzeigename - kein erfundener Klarname. */
|
||||
author: string;
|
||||
stars: 1 | 2 | 3 | 4 | 5;
|
||||
/** Sprache, in der die Bewertung tatsaechlich verfasst wurde. */
|
||||
sourceLanguage: Language;
|
||||
/** Veroeffentlichungsdatum im Store (ISO), fuer die Nachvollziehbarkeit. */
|
||||
publishedOn: string;
|
||||
/**
|
||||
* Wie stark die Bewertung die Positionierung stuetzt (1 = staerkste).
|
||||
* Kriterium: benennt sie Triage ("sagt mir, was los ist und was ich tun soll")
|
||||
* oder nur allgemeine Zufriedenheit? Erstere ueberzeugt, letztere ist Rauschen.
|
||||
*/
|
||||
priority: number;
|
||||
/** Freigegebene Uebersetzungen. Wird im UI als Uebersetzung gekennzeichnet. */
|
||||
translations?: Partial<Record<Language, string>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* ECHTE Store-Bewertungen, Stand 27.07.2026 (6 von 6, alle 5 Sterne).
|
||||
* Wortlaut, Anzeigename und Sternezahl unveraendert uebernommen.
|
||||
*/
|
||||
export const TESTIMONIALS: Testimonial[] = [
|
||||
{
|
||||
// Staerkste Bewertung im gesamten Bestand: beschreibt exakt die Positionierung
|
||||
// (Triage statt Identifikation) in den Worten eines echten Nutzers.
|
||||
title: 'Exactly what I needed',
|
||||
quote:
|
||||
"Finally an app that actually helps me keep my plants alive. Snap a photo and it tells you what's wrong and how to fix it - no more guessing why the leaves are turning yellow. Super easy to use and the care reminders are a nice touch. Highly recommend if you're bad at keeping plants alive like me!",
|
||||
author: 'Chrispetersssss',
|
||||
stars: 5,
|
||||
sourceLanguage: 'en',
|
||||
publishedOn: '2026-07-22',
|
||||
priority: 1,
|
||||
translations: {
|
||||
de: 'Endlich eine App, die mir wirklich hilft, meine Pflanzen am Leben zu halten. Foto machen und sie sagt dir, was nicht stimmt und wie du es behebst - kein Rätselraten mehr, warum die Blätter gelb werden. Sehr einfach zu bedienen, und die Pflege-Erinnerungen sind ein netter Zusatz. Klare Empfehlung, wenn du wie ich schlecht darin bist, Pflanzen am Leben zu halten!',
|
||||
es: 'Por fin una app que de verdad me ayuda a mantener vivas mis plantas. Haces una foto y te dice qué le pasa y cómo arreglarlo: se acabó adivinar por qué se ponen amarillas las hojas. Muy fácil de usar, y los recordatorios de cuidado son un buen extra. Muy recomendable si se te dan mal las plantas, como a mí.',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Direkter Wettbewerbsvergleich - stuetzt Belief 5 (Abo statt Alternativen).
|
||||
title: 'Wow',
|
||||
quote:
|
||||
'Die Pflanzenanalyse toppt echt jede andere App die ich bisher ausprobiert habe! Der Hammer!',
|
||||
author: 'warumistjedernamevergeben',
|
||||
stars: 5,
|
||||
sourceLanguage: 'de',
|
||||
publishedOn: '2026-04-12',
|
||||
priority: 2,
|
||||
translations: {
|
||||
en: 'The plant analysis genuinely beats every other app I have tried so far! Amazing!',
|
||||
es: '¡El análisis de plantas supera de verdad a cualquier otra app que haya probado! ¡Increíble!',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Trifft den Avatar woertlich ("keinen gruenen Daumen").
|
||||
title: 'Mega',
|
||||
quote:
|
||||
'Ich habe schon viele Apps ausprobiert, weil ich keinen grünen Daumen habe. Keine hat mich bisher so überzeugt wie diese ! Kann sie jedem ans Herz legen',
|
||||
author: 'Annastasia2104',
|
||||
stars: 5,
|
||||
sourceLanguage: 'de',
|
||||
publishedOn: '2026-07-02',
|
||||
priority: 3,
|
||||
translations: {
|
||||
en: 'I have tried a lot of apps because I do not have a green thumb. None has convinced me as much as this one! I can recommend it to anyone.',
|
||||
es: 'He probado muchas apps porque no tengo mano para las plantas. ¡Ninguna me había convencido tanto como esta! Se la recomiendo a cualquiera.',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Beste App',
|
||||
quote: 'Bisher mit Abstand die beste App für meine Pflanzen!',
|
||||
author: 'lina_einhorn',
|
||||
stars: 5,
|
||||
sourceLanguage: 'de',
|
||||
publishedOn: '2026-07-02',
|
||||
priority: 4,
|
||||
translations: {
|
||||
en: 'By far the best app for my plants so far!',
|
||||
es: '¡Con diferencia, la mejor app para mis plantas hasta ahora!',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Endlich ....',
|
||||
quote:
|
||||
'… vergesse ich nicht, meine Pflanzen zu gießen, um zu pflegen!!! Einfache Bedienung - top App',
|
||||
author: 'ClaKnu',
|
||||
stars: 5,
|
||||
sourceLanguage: 'de',
|
||||
publishedOn: '2026-07-02',
|
||||
priority: 5,
|
||||
translations: {
|
||||
en: '… I no longer forget to water and care for my plants!!! Easy to use - top app',
|
||||
es: '… ¡ya no me olvido de regar y cuidar mis plantas!!! Fácil de usar, app estupenda',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tolle App',
|
||||
quote: 'Ich bin richtig zufrieden ! Tolle App',
|
||||
author: 'annabelle_gouz',
|
||||
stars: 5,
|
||||
sourceLanguage: 'de',
|
||||
publishedOn: '2026-07-04',
|
||||
priority: 6,
|
||||
translations: {
|
||||
en: 'I am really happy with it! Great app',
|
||||
es: '¡Estoy muy contenta! Gran app',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export type DisplayTestimonial = {
|
||||
title: string;
|
||||
quote: string;
|
||||
author: string;
|
||||
stars: 1 | 2 | 3 | 4 | 5;
|
||||
/** true, wenn der angezeigte Text eine Uebersetzung des Originals ist. */
|
||||
isTranslated: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Waehlt die staerkste Bewertung fuer die UI-Sprache.
|
||||
*
|
||||
* Vorrang hat eine im Original passende Sprache; gibt es keine, wird die
|
||||
* hoechstpriorisierte Bewertung mit freigegebener Uebersetzung genommen und
|
||||
* im UI als Uebersetzung gekennzeichnet. Nie wird ein fremdsprachiges
|
||||
* Original unkommentiert als muttersprachlich ausgegeben.
|
||||
*/
|
||||
export const getTestimonialForLanguage = (language: Language): DisplayTestimonial | null => {
|
||||
if (TESTIMONIALS.length === 0) return null;
|
||||
|
||||
const byPriority = [...TESTIMONIALS].sort((a, b) => a.priority - b.priority);
|
||||
|
||||
const native = byPriority.find((t) => t.sourceLanguage === language);
|
||||
if (native) {
|
||||
return {
|
||||
title: native.title,
|
||||
quote: native.quote,
|
||||
author: native.author,
|
||||
stars: native.stars,
|
||||
isTranslated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const translated = byPriority.find((t) => t.translations?.[language]);
|
||||
if (translated) {
|
||||
return {
|
||||
title: translated.title,
|
||||
quote: translated.translations![language]!,
|
||||
author: translated.author,
|
||||
stars: translated.stars,
|
||||
isTranslated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Hinweis, der erscheint, wenn eine Bewertung nicht in der UI-Sprache verfasst wurde. */
|
||||
export const translatedNote = (language: Language): string => {
|
||||
if (language === 'de') return 'Übersetzt';
|
||||
if (language === 'es') return 'Traducido';
|
||||
return 'Translated';
|
||||
};
|
||||
@@ -194,8 +194,13 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setBillingSummary(null);
|
||||
setPlants([]);
|
||||
setLanguage(getDeviceLanguage());
|
||||
setAppearanceModeState('system');
|
||||
setColorPaletteState('forest');
|
||||
// Theme ist eine GERAETE-Einstellung, keine Kontodaten. Beim Ausloggen oder
|
||||
// beim Start ohne Session darf sie deshalb nicht auf die Defaults fallen -
|
||||
// sonst landet ein Gast nach jedem Neustart wieder auf Dark/Forest.
|
||||
const deviceAppearance = AppMetaDb.get('appearance_mode');
|
||||
setAppearanceModeState(deviceAppearance && isAppearanceMode(deviceAppearance) ? deviceAppearance : 'system');
|
||||
const devicePalette = AppMetaDb.get('color_palette');
|
||||
setColorPaletteState(devicePalette && isColorPalette(devicePalette) ? devicePalette : 'forest');
|
||||
setProfileNameState('');
|
||||
setProfileImageUri(null);
|
||||
setIsLoadingPlants(false);
|
||||
@@ -385,6 +390,18 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setHasCompletedOnboarding(true);
|
||||
}
|
||||
|
||||
// Theme sofort aus dem Geraetespeicher setzen - vor Session-Restore und
|
||||
// vor dem Server-Roundtrip. Danach ueberschreibt hydrateSession ggf. mit
|
||||
// den nutzerspezifischen Werten, die aber identisch sein sollten.
|
||||
const storedAppearance = AppMetaDb.get('appearance_mode');
|
||||
if (storedAppearance && isAppearanceMode(storedAppearance)) {
|
||||
setAppearanceModeState(storedAppearance);
|
||||
}
|
||||
const storedPalette = AppMetaDb.get('color_palette');
|
||||
if (storedPalette && isColorPalette(storedPalette)) {
|
||||
setColorPaletteState(storedPalette);
|
||||
}
|
||||
|
||||
const s = await AuthService.getSession();
|
||||
if (!s) {
|
||||
resetStateForSignedOutUser();
|
||||
@@ -516,11 +533,17 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
const setAppearanceMode = useCallback((mode: AppearanceMode) => {
|
||||
setAppearanceModeState(mode);
|
||||
// Geraeteweit speichern, unabhaengig von einer Session: sonst geht die
|
||||
// Auswahl fuer Gaeste beim Neustart verloren, und eingeloggte Nutzer sehen
|
||||
// beim Start kurz das Default-Theme, weil die Nutzer-Settings erst nach
|
||||
// hydrateSession (inkl. Server-Roundtrip) geladen werden.
|
||||
AppMetaDb.set('appearance_mode', mode);
|
||||
if (session) SettingsDb.setAppearanceMode(session.userId, mode);
|
||||
}, [session]);
|
||||
|
||||
const setColorPalette = useCallback((palette: ColorPalette) => {
|
||||
setColorPaletteState(palette);
|
||||
AppMetaDb.set('color_palette', palette);
|
||||
if (session) SettingsDb.setColorPalette(session.userId, palette);
|
||||
}, [session]);
|
||||
|
||||
|
||||
134
design/stitch-paywall-prompt.md
Normal file
134
design/stitch-paywall-prompt.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Stitch-Prompt: GreenLens Pro Paywall
|
||||
|
||||
Der Prompt unten ist zum direkten Kopieren in Stitch. Englisch, weil Stitch damit
|
||||
zuverlässiger arbeitet. Darunter stehen Varianten und der Kontext, falls du
|
||||
nachsteuern willst.
|
||||
|
||||
---
|
||||
|
||||
## Prompt (kopieren)
|
||||
|
||||
```
|
||||
Design a mobile subscription paywall for GreenLens, an iOS plant-care app.
|
||||
|
||||
CONTEXT
|
||||
The user just finished onboarding. They own 3-20 houseplants, one of them has a
|
||||
problem right now (yellow leaves, brown tips), and they are anxious they caused
|
||||
it. They are price-sensitive because free alternatives exist (Google Lens).
|
||||
Their biggest fear about subscriptions is hidden charges and hard cancellation.
|
||||
|
||||
THE ONE JOB OF THIS SCREEN
|
||||
Make starting the 7-day free trial feel like the obvious, low-risk next step.
|
||||
Everything else is secondary.
|
||||
|
||||
HARD CONSTRAINTS
|
||||
- Single screen, no scrolling required to reach the primary button on a 6.1"
|
||||
iPhone (390x844pt). Content that does not fit must be collapsed or cut.
|
||||
- Dark theme. Background near-black with a green tint (#0F1A12). Primary accent
|
||||
is a muted sage green (#8FBF7F). Text white and warm grey.
|
||||
- No hero photo. No stock imagery of plants.
|
||||
- Price and billing terms must be visible without interaction.
|
||||
- Rounded corners, generous spacing, iOS-native feel. SF Pro or similar.
|
||||
|
||||
CONTENT TO PLACE (exact strings, do not rewrite)
|
||||
- Small eyebrow label: "GREENLENS PRO"
|
||||
- Headline: "Stop guessing what your plant needs."
|
||||
- Subline: "Cause, what to check, what to do now - and a follow-up after 7 days."
|
||||
- Two plan options the user picks between:
|
||||
Option A - "Monthly", "EUR 4.99 / month", no trial
|
||||
Option B - "Yearly", "EUR 39.99 / year", "that is EUR 3.33 / month",
|
||||
badge "7 DAYS FREE", badge "SAVE 33%"
|
||||
- Included, shown compactly (icons or a tight list, not full sentences):
|
||||
"Ranked causes, including what is uncertain"
|
||||
"Concrete things to check"
|
||||
"7-day plan plus follow-up"
|
||||
"100 scans per month"
|
||||
- Primary button: "Try 7 days free" (when the yearly option is selected) or
|
||||
"Start now" (when monthly is selected)
|
||||
- Under the button, small: "Due today EUR 0.00 - then EUR 39.99 on 3 August"
|
||||
- Small print, one line: "Cancel anytime in 2 taps. We remind you 2 days before
|
||||
the trial ends."
|
||||
- Top bar: close X on the left, "Restore Purchases" on the right
|
||||
- Bottom: "Privacy | Terms"
|
||||
|
||||
CRITICAL DESIGN PROBLEM TO SOLVE
|
||||
The current version uses a separate on/off switch labelled "Try 7 days free"
|
||||
sitting above a single price card. Users do not connect the switch to the price,
|
||||
and the switch is easy to miss. Replace this with a plan-selection pattern where
|
||||
both options are visible at once and the trial is part of the option itself, not
|
||||
a separate control. The recommended option should be visually pre-selected.
|
||||
|
||||
DELIVER
|
||||
Three distinct layout directions, not colour variations:
|
||||
1. Two plan cards side by side, recommended one highlighted with a border and badge
|
||||
2. Two plan rows stacked full width with radio-style selection
|
||||
3. One recommended plan shown large, the alternative as a small text link
|
||||
underneath ("or EUR 4.99 monthly without trial")
|
||||
|
||||
For each direction show the state with the yearly plan selected.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Warum dieser Prompt so gebaut ist
|
||||
|
||||
**Die eine Aufgabe ist explizit benannt.** Ohne diesen Satz optimiert Stitch auf
|
||||
"schön" statt auf "konvertiert" und produziert wieder ein Hero-Bild.
|
||||
|
||||
**Die Strings sind vorgegeben und als unveränderlich markiert.** Sonst erfindet
|
||||
das Tool Marketing-Sprache, die gegen die Positionierung arbeitet - "unlimited
|
||||
scans", "AI-powered", "99% accurate". Genau diese Formulierungen haben wir aus
|
||||
gutem Grund entfernt.
|
||||
|
||||
**Das eigentliche Problem steht als eigener Abschnitt drin.** Der Toggle ist die
|
||||
Ursache der verbliebenen Friction: Ein Schalter ist ein Ja/Nein-Control, aber
|
||||
hier steckt eine Entweder-oder-Entscheidung dahinter (Monat ohne Trial vs. Jahr
|
||||
mit Trial). Deshalb versteht niemand, dass das Umlegen den Preis ändert - und
|
||||
deshalb war der Schalter mal über, mal unter der Preiskarte, ohne dass es
|
||||
je richtig wurde. Zwei sichtbare Optionen lösen das strukturell.
|
||||
|
||||
**Drei Richtungen statt drei Farbvarianten.** Sonst bekommst du dreimal dasselbe
|
||||
Layout in Grün, Dunkelgrün und Sehr-Dunkelgrün.
|
||||
|
||||
---
|
||||
|
||||
## Wenn du nachsteuern willst
|
||||
|
||||
**Noch weniger Friction:** Ergänze im Prompt
|
||||
> "Remove the feature list entirely. Test whether headline, plan choice, price
|
||||
> and button alone are enough."
|
||||
|
||||
Das wäre die radikalste Variante - und ehrlicherweise die, die ich am ehesten
|
||||
gewinnen sehe. Der Nutzer hat die Argumente im Onboarding schon dreimal gelesen.
|
||||
|
||||
**Mit Social Proof:** Ergänze
|
||||
> "Add one line of social proof directly under the headline: 5.0 stars from 6
|
||||
> App Store ratings. Keep it to a single line with stars."
|
||||
|
||||
Sechs Bewertungen sind wenig, aber 5,0 ist stark und belegbar. Direkt unter der
|
||||
Headline getestet lohnt sich.
|
||||
|
||||
**Für den Onboarding-Kontext:** Falls du eine eigene Variante für die Paywall
|
||||
direkt nach dem Onboarding willst, ergänze
|
||||
> "This version appears immediately after onboarding, before the user has seen
|
||||
> any result from the app. Add a single line reminding them what they just told
|
||||
> us, e.g. 'Your plan for: yellow leaves, low light'."
|
||||
|
||||
---
|
||||
|
||||
## Was du danach an mich zurückgeben solltest
|
||||
|
||||
Screenshot der gewählten Richtung. Ich baue sie dann in `app/profile/billing.tsx`
|
||||
nach - der Umbau von Toggle auf Plan-Auswahl betrifft `selectedPaywallPlan` und
|
||||
ist überschaubar, weil beide Produkte (`monthly_pro`, `yearly_pro`) schon
|
||||
angebunden sind.
|
||||
|
||||
---
|
||||
|
||||
## Ein Hinweis, den der Prompt nicht lösen kann
|
||||
|
||||
Kein Design behebt, dass die Paywall im Onboarding-Flow **vor** dem ersten
|
||||
Ergebnis erscheint. Der Nutzer soll zahlen für etwas, das ihm bisher nur
|
||||
beschrieben wurde. Das ist strukturelle Friction, keine visuelle - siehe
|
||||
Abschnitt 6.3 im Copy-Audit. Ein besseres Layout holt vielleicht ein paar
|
||||
Prozent, ein erlebtes Scan-Ergebnis vor der Paywall holt deutlich mehr.
|
||||
2
eas.json
2
eas.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 3.0.0",
|
||||
"appVersionSource": "local"
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { headers } from 'next/headers'
|
||||
import Script from 'next/script'
|
||||
import './globals.css'
|
||||
import { LangProvider } from '@/context/LangContext'
|
||||
import { siteConfig, hasIosStoreUrl, hasAndroidStoreUrl } from '@/lib/site'
|
||||
@@ -119,6 +120,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
<Script
|
||||
src="https://cloud.umami.is/script.js"
|
||||
data-website-id="816dc2f4-0400-4f70-9506-2d86ee5d43ff"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<LangProvider initialLang={htmlLang}>{children}</LangProvider>
|
||||
|
||||
@@ -280,7 +280,18 @@ const buildHealthPrompt = (language, plantContext) => {
|
||||
`- "analysisSummary": 6 to 9 precise sentences in ${getLanguageLabel(language)} describing visible condition, symptom pattern, likely root cause, urgency, confidence limits, and what the owner should monitor next.`,
|
||||
'- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:',
|
||||
' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")',
|
||||
' - "confidence": float 0.05–0.99 reflecting visual certainty',
|
||||
// Kalibrierung analog zur Arterkennung (buildScanPrompt). Ohne diese Skala
|
||||
// war "confidence" eine freie Zahl ohne definierte Bedeutung — die App zeigt
|
||||
// sie aber als Prozentwert an und die Onboarding-Copy verspricht, dass wir
|
||||
// Unsicherheit ehrlich ausweisen. Die Skala macht dieses Versprechen einloesbar.
|
||||
' - "confidence": float 0.05–0.99 reflecting visual certainty for THIS issue,',
|
||||
' independent of the other issues (these are NOT probabilities and must NOT sum to 1). Calibrate it:',
|
||||
' - 0.85-0.99: the visual signs are unambiguous and typical for this issue.',
|
||||
' - 0.65-0.84: very likely, but one or two confirming signs are not visible in the photo.',
|
||||
' - 0.40-0.64: plausible, yet other causes fit the same visible signs equally well.',
|
||||
' - Below 0.40: weak signal; list it only if ruling it out matters for the owner.',
|
||||
' - Do not inflate confidence to appear decisive. If the photo cannot settle it,',
|
||||
' a value below 0.65 is the correct and expected answer.',
|
||||
' - "details": 2–4 sentence detailed explanation of what you observe visually, what causes it, and what happens if untreated. Be specific — mention leaf color, location, pattern.',
|
||||
`- "actionsNow": 5 to 8 specific, actionable steps for the next 24–48 hours. Each step must be a complete sentence with concrete instructions (e.g. amounts, durations, techniques). Written in ${getLanguageLabel(language)}.`,
|
||||
`- "plan7Days": 7 to 10 day-by-day or milestone care steps for the coming week. Each step should specify timing and expected outcome. Written in ${getLanguageLabel(language)}.`,
|
||||
|
||||
@@ -5,6 +5,8 @@ type AnalyticsProperties = Record<string, unknown>;
|
||||
type SafeAnalytics = {
|
||||
capture: (event: string, properties?: AnalyticsProperties) => void;
|
||||
identify: (userId: string, properties?: AnalyticsProperties) => void;
|
||||
/** Setzt Person-Properties ohne die User-ID zu aendern (z. B. Experiment-Varianten). */
|
||||
identifyProperties: (properties: AnalyticsProperties) => void;
|
||||
screen: (name: string, properties?: AnalyticsProperties) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
@@ -46,6 +48,15 @@ const safeAnalytics: SafeAnalytics = {
|
||||
console.warn('[Analytics] identify failed', error);
|
||||
}
|
||||
},
|
||||
identifyProperties: (properties) => {
|
||||
try {
|
||||
// PostHog RN: register() haengt die Properties an alle folgenden Events an,
|
||||
// damit laesst sich der komplette Funnel nach Variante segmentieren.
|
||||
client?.register(properties as Record<string, string>);
|
||||
} catch (error) {
|
||||
console.warn('[Analytics] identifyProperties failed', error);
|
||||
}
|
||||
},
|
||||
screen: (name, properties) => {
|
||||
try {
|
||||
client?.screen(name, properties as Record<string, string>);
|
||||
|
||||
41
services/devReset.ts
Normal file
41
services/devReset.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { AuthService } from './authService';
|
||||
import { AppMetaDb } from './database';
|
||||
|
||||
/**
|
||||
* Setzt die App auf den Zustand "frisch installiert" zurueck - fuer Tests.
|
||||
*
|
||||
* Warum das noetig ist: Ein Expo-Reload oder ein neuer QR-Scan loescht keinen
|
||||
* persistenten State. Die Session liegt in SecureStore (auf iOS = Keychain,
|
||||
* ueberlebt sogar das Loeschen der App), der Onboarding-Flag und der
|
||||
* Install-Marker liegen in SQLite. Man landet deshalb immer wieder direkt im
|
||||
* Dashboard, egal wie oft man neu laedt.
|
||||
*
|
||||
* Diese Funktion raeumt genau die vier Stellen ab, die das Onboarding-Gate in
|
||||
* app/_layout.tsx auswerten:
|
||||
* 1. Session -> SecureStore
|
||||
* 2. onboarding_completed -> SQLite (AppMetaDb)
|
||||
* 3. install_marker_v2 -> SQLite + SecureStore
|
||||
* 4. Experiment-Zuweisung -> AsyncStorage
|
||||
*/
|
||||
export const resetToFreshInstall = async (): Promise<void> => {
|
||||
// 1. Session (Access-/Refresh-Token)
|
||||
await AuthService.logout().catch(() => undefined);
|
||||
|
||||
// 2. Onboarding-Flag: ohne den bleibt hasCompletedOnboarding true und das
|
||||
// Gate laesst den Nutzer am Welcome-Screen vorbei.
|
||||
AppMetaDb.set('onboarding_completed', '0');
|
||||
|
||||
// 3. Install-Marker in BEIDEN Speichern. Der Keychain-Eintrag ist der Grund,
|
||||
// warum eine echte Neuinstallation frueher nicht als solche erkannt wurde.
|
||||
// Key muss mit SECURE_INSTALL_MARKER in app/_layout.tsx uebereinstimmen.
|
||||
AppMetaDb.set('install_marker_v2', '0');
|
||||
await SecureStore.deleteItemAsync('greenlens_install_v1').catch(() => undefined);
|
||||
await SecureStore.deleteItemAsync('greenlens_first_run_complete').catch(() => undefined);
|
||||
|
||||
// 4. A/B-Zuweisung, damit beim naechsten Start neu gewuerfelt wird.
|
||||
await AsyncStorage.removeItem('greenlens_experiment_welcome_headline_v1').catch(() => undefined);
|
||||
await AsyncStorage.removeItem('greenlens_show_tour').catch(() => undefined);
|
||||
await AsyncStorage.removeItem('greenlens_preauth_onboarding_v1').catch(() => undefined);
|
||||
};
|
||||
86
services/experiments.ts
Normal file
86
services/experiments.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Analytics } from './analytics';
|
||||
|
||||
/**
|
||||
* Leichtgewichtiges A/B-Testing ohne PostHog-Provider.
|
||||
*
|
||||
* Warum lokal und nicht PostHog Feature Flags: der PostHog-Client laeuft hier
|
||||
* bewusst ohne Provider (siehe services/analytics.ts). Flags waeren damit erst
|
||||
* nach einem Netzwerk-Roundtrip verfuegbar — der Welcome-Screen rendert aber
|
||||
* sofort. Eine lokal gewuerfelte, persistierte Zuweisung ist deterministisch,
|
||||
* funktioniert offline und wird als Event + Person-Property an PostHog gemeldet,
|
||||
* sodass sich jeder Funnel-Schritt bis zum Kauf nach Variante segmentieren laesst.
|
||||
*/
|
||||
|
||||
const STORAGE_PREFIX = 'greenlens_experiment_';
|
||||
|
||||
export const EXPERIMENTS = {
|
||||
/** Welcome-Headline: Symptom-Einstieg vs. Abgrenzung zu Gratis-Identifikations-Apps. */
|
||||
welcomeHeadline: {
|
||||
key: 'welcome_headline_v1',
|
||||
variants: ['symptom', 'contrast'] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type WelcomeHeadlineVariant = (typeof EXPERIMENTS.welcomeHeadline.variants)[number];
|
||||
|
||||
const memoryCache = new Map<string, string>();
|
||||
|
||||
const pick = <T extends readonly string[]>(variants: T): T[number] =>
|
||||
variants[Math.floor(Math.random() * variants.length)];
|
||||
|
||||
/**
|
||||
* Liefert die Variante fuer einen Nutzer. Beim ersten Aufruf wird gewuerfelt,
|
||||
* persistiert und `experiment_assigned` an PostHog geschickt. Alle weiteren
|
||||
* Aufrufe geben dieselbe Variante zurueck.
|
||||
*/
|
||||
export const getVariant = async <T extends readonly string[]>(
|
||||
experimentKey: string,
|
||||
variants: T,
|
||||
): Promise<T[number]> => {
|
||||
const cached = memoryCache.get(experimentKey);
|
||||
if (cached && (variants as readonly string[]).includes(cached)) {
|
||||
return cached as T[number];
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_PREFIX + experimentKey);
|
||||
if (stored && (variants as readonly string[]).includes(stored)) {
|
||||
memoryCache.set(experimentKey, stored);
|
||||
return stored as T[number];
|
||||
}
|
||||
} catch {
|
||||
// Storage nicht verfuegbar — wir wuerfeln neu, statt den Screen zu blockieren.
|
||||
}
|
||||
|
||||
const variant = pick(variants);
|
||||
memoryCache.set(experimentKey, variant);
|
||||
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_PREFIX + experimentKey, variant);
|
||||
} catch {
|
||||
// Nicht persistierbar: die Variante gilt dann nur fuer diese Session.
|
||||
}
|
||||
|
||||
// Event fuer die Zuweisung selbst ...
|
||||
Analytics.capture('experiment_assigned', { experiment: experimentKey, variant });
|
||||
// ... plus Person-Property, damit sich JEDER spaetere Funnel-Schritt
|
||||
// (onboarding_started, paywall_opened, purchase_completed) in PostHog nach
|
||||
// Variante aufteilen laesst, ohne die Property ueberall mitschicken zu muessen.
|
||||
Analytics.identifyProperties({ [`experiment_${experimentKey}`]: variant });
|
||||
|
||||
return variant;
|
||||
};
|
||||
|
||||
/** Nur fuer QA/Debug: erzwingt eine Variante. */
|
||||
export const forceVariant = async (experimentKey: string, variant: string): Promise<void> => {
|
||||
memoryCache.set(experimentKey, variant);
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_PREFIX + experimentKey, variant);
|
||||
} catch {
|
||||
// ignorieren
|
||||
}
|
||||
};
|
||||
|
||||
export const getWelcomeHeadlineVariant = (): Promise<WelcomeHeadlineVariant> =>
|
||||
getVariant(EXPERIMENTS.welcomeHeadline.key, EXPERIMENTS.welcomeHeadline.variants);
|
||||
@@ -8,6 +8,10 @@ export type PreAuthAnswers = {
|
||||
primaryGoal?: string;
|
||||
experienceLevel?: string;
|
||||
lightLevel?: string;
|
||||
/** 'acute' | 'slow' | 'prevention' — steuert Dringlichkeit und Tonfall des Plans. */
|
||||
situation?: string;
|
||||
/** 'yellow' | 'brown_tips' | 'drooping' | 'spots' | 'pests' | 'other' */
|
||||
symptom?: string;
|
||||
};
|
||||
|
||||
export const PreAuthOnboardingService = {
|
||||
|
||||
@@ -224,7 +224,7 @@ export const translations = {
|
||||
|
||||
// Tour
|
||||
tourFabTitle: "📷 Pflanze scannen",
|
||||
tourFabDesc: "Tippe hier um eine Pflanze zu fotografieren — die KI erkennt sie sofort.",
|
||||
tourFabDesc: "Tippe hier um eine Pflanze zu fotografieren - die KI erkennt sie sofort.",
|
||||
tourSearchTitle: "🔍 Pflanzenlexikon",
|
||||
tourSearchDesc: "Durchsuche tausende Pflanzen oder lass die KI nach der perfekten suchen.",
|
||||
tourProfileTitle: "👤 Dein Profil",
|
||||
@@ -251,7 +251,7 @@ export const translations = {
|
||||
onboardingScanBtn: "Pflanze scannen",
|
||||
onboardingRegister: "Registrieren",
|
||||
onboardingLogin: "Anmelden",
|
||||
onboardingDisclaimer: "Deine Daten bleiben privat und lokal auf deinem Gerät.",
|
||||
onboardingDisclaimer: "Deine Fotos werden nur zur Analyse verarbeitet und nicht weiterverkauft.",
|
||||
welcomeHeadline: "Pflanzenpflege\nbeginnt hier",
|
||||
welcomeSubheadline: "Scanne ein Blatt, erkenne die Pflanze und halte sie gesund.",
|
||||
welcomeFeatureIdentifyTitle: "KI-Pflanzenerkennung",
|
||||
@@ -497,7 +497,7 @@ registerToSave: "Sign up to save",
|
||||
|
||||
// Tour
|
||||
tourFabTitle: "📷 Scan Plant",
|
||||
tourFabDesc: "Tap here to photograph a plant — the AI recognizes it instantly.",
|
||||
tourFabDesc: "Tap here to photograph a plant - the AI recognizes it instantly.",
|
||||
tourSearchTitle: "🔍 Plant Encyclopedia",
|
||||
tourSearchDesc: "Search thousands of plants or let the AI find the perfect one.",
|
||||
tourProfileTitle: "👤 Your Profile",
|
||||
@@ -524,7 +524,7 @@ registerToSave: "Sign up to save",
|
||||
onboardingScanBtn: "Scan Plant",
|
||||
onboardingRegister: "Sign Up",
|
||||
onboardingLogin: "Log In",
|
||||
onboardingDisclaimer: "Your data stays private and local on your device.",
|
||||
onboardingDisclaimer: "Your photos are processed only to analyse them, and never sold on.",
|
||||
welcomeHeadline: "Plant care\nstarts here",
|
||||
welcomeSubheadline: "Scan a leaf, learn the plant, keep it healthy.",
|
||||
welcomeFeatureIdentifyTitle: "AI plant identification",
|
||||
@@ -770,7 +770,7 @@ registerToSave: "Regístrate para guardar",
|
||||
|
||||
// Tour
|
||||
tourFabTitle: "📷 Escanear Planta",
|
||||
tourFabDesc: "Toca aquí para fotografiar una planta — la IA la reconoce al instante.",
|
||||
tourFabDesc: "Toca aquí para fotografiar una planta - la IA la reconoce al instante.",
|
||||
tourSearchTitle: "🔍 Enciclopedia",
|
||||
tourSearchDesc: "Busca en miles de plantas o deja que la IA encuentre la perfecta.",
|
||||
tourProfileTitle: "👤 Tu Perfil",
|
||||
@@ -797,7 +797,7 @@ registerToSave: "Regístrate para guardar",
|
||||
onboardingScanBtn: "Escanear Planta",
|
||||
onboardingRegister: "Registrarse",
|
||||
onboardingLogin: "Iniciar sesión",
|
||||
onboardingDisclaimer: "Tus datos permanecen privados y locales en tu dispositivo.",
|
||||
onboardingDisclaimer: "Tus fotos se procesan solo para analizarlas y nunca se venden.",
|
||||
welcomeHeadline: "El cuidado\nempieza aquí",
|
||||
welcomeSubheadline: "Escanea una hoja, conoce la planta y mantenla sana.",
|
||||
welcomeFeatureIdentifyTitle: "Identificación con IA",
|
||||
|
||||
Reference in New Issue
Block a user