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

11
.gitignore vendored
View File

@@ -32,6 +32,17 @@ server/data/*.sqlite-*
# Generated social media assets
social_out/
# Local marketing exports and working files
marketing/app-store-banner-5screens-2026-07-6.5in/
marketing/app-store-banner-5screens-2026-07/
marketing/app-store-banner-5screens-continuous/
marketing/app-store-banner-5screens-triage/
marketing/popup-hhu-flyer-prompts.md
output/
tmp/
ads/
.codex-temp/
# Claude / Agents (symlinks incompatible with EAS Build on Windows)
.agents/
.claude/

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "GreenLens",
"slug": "greenlens",
"version": "2.4.4",
"version": "2.4.7",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",

View File

@@ -366,7 +366,7 @@ const styles = StyleSheet.create({
fieldGroup: { gap: 6 },
label: { fontSize: 13, fontWeight: '800', marginLeft: 2 },
inputRow: {
height: 54,
minHeight: 54,
borderWidth: 1,
borderRadius: 14,
flexDirection: 'row',
@@ -374,7 +374,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 14,
},
inputIcon: { marginRight: 10 },
input: { flex: 1, height: 54, fontSize: 15 },
input: { flex: 1, minHeight: 54, fontSize: 15 },
eyeBtn: { padding: 5, marginLeft: 6 },
forgotBtn: { alignItems: 'flex-end', marginTop: -4 },
forgotText: { fontSize: 14, fontWeight: '800' },
@@ -388,7 +388,8 @@ const styles = StyleSheet.create({
},
errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
primaryBtn: {
height: 56,
minHeight: 56,
paddingVertical: 14,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',

View File

@@ -467,7 +467,8 @@ const styles = StyleSheet.create({
dividerLine: { flex: 1, height: 1 },
dividerText: { fontSize: 12, fontWeight: '800' },
emailChoiceBtn: {
height: 56,
minHeight: 56,
paddingVertical: 14,
borderRadius: 14,
borderWidth: 1.5,
flexDirection: 'row',
@@ -480,7 +481,7 @@ const styles = StyleSheet.create({
fieldGroup: { gap: 6 },
label: { fontSize: 13, fontWeight: '800', marginLeft: 2 },
inputRow: {
height: 52,
minHeight: 52,
borderWidth: 1,
borderRadius: 14,
flexDirection: 'row',
@@ -488,7 +489,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 14,
},
inputIcon: { marginRight: 10 },
input: { flex: 1, height: 52, fontSize: 15 },
input: { flex: 1, minHeight: 52, fontSize: 15 },
eyeBtn: { padding: 5, marginLeft: 6 },
errorBox: {
flexDirection: 'row',
@@ -500,7 +501,8 @@ const styles = StyleSheet.create({
},
errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
primaryBtn: {
height: 56,
minHeight: 56,
paddingVertical: 14,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',

View File

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import {
Image,
ImageBackground,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
@@ -103,10 +104,14 @@ const getProofPoints = (language: Language): string[] => {
export default function OnboardingScreen() {
const { language } = useApp();
const { height } = useWindowDimensions();
const compact = height < 700;
const { height, width } = useWindowDimensions();
const compact = height < 700 || width < 380;
const posthog = useSafeAnalytics();
const insets = useSafeAreaInsets();
// Der Hero bekommt den Rest der Hoehe, faellt aber nie unter diese Grenze.
// Vorher war er fest auf 52% - dadurch hat die laengere Headline-Variante
// den dritten Button aus dem Bildschirm geschoben.
const heroMinHeight = Math.round(height * (compact ? 0.34 : 0.40));
const [variant, setVariant] = React.useState<WelcomeHeadlineVariant | null>(null);
const copy = getWelcomeCopy(language, variant ?? 'symptom');
@@ -132,104 +137,138 @@ export default function OnboardingScreen() {
style={[{ position: 'absolute', top: 0, left: 0, right: 0, width: '100%' }, { height: compact ? '65%' : '70%' }]}
resizeMode="cover"
/>
<View style={[{ width: '100%' }, { height: compact ? '48%' : '52%' }]}>
<View style={[styles.heroSafe, { paddingTop: Math.max(insets.top, 16) }]}>
<View style={styles.heroTopRow}>
<View style={styles.brandRow}>
<Image
source={require('../assets/icon.png')}
style={styles.logo}
resizeMode="cover"
/>
<Text style={styles.brandName}>
Green<Text style={styles.brandAccent}>Lens</Text>
</Text>
</View>
{/* Rating und Anzahl immer gemeinsam: 5,0 aus 6 Bewertungen ist
ehrlich und ueberpruefbar - eine nackte Sternezahl waere es nicht. */}
<View style={styles.ratingPill}>
<Ionicons name="star" size={13} color="#f5c04e" />
<Text style={styles.ratingText}>{copy.ratingValue}</Text>
<Text style={styles.ratingCount}>· {copy.ratingCount}</Text>
</View>
</View>
{testimonial ? (
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
<Text style={styles.testimonialText} numberOfLines={compact ? 3 : 4}>
{testimonial.quote}"
</Text>
<View style={styles.testimonialMeta}>
<Text style={styles.testimonialAuthor}>
{testimonial.author}
{testimonial.isTranslated ? ` · ${translatedNote(language)}` : ''}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
bounces={false}
alwaysBounceVertical={false}
>
<View style={[styles.heroBlock, { minHeight: heroMinHeight }]}>
<View style={[styles.heroSafe, { paddingTop: Math.max(insets.top, 16) }]}>
<View style={styles.heroTopRow}>
<View style={styles.brandRow}>
<Image
source={require('../assets/icon.png')}
style={styles.logo}
resizeMode="cover"
/>
<Text
style={styles.brandName}
maxFontSizeMultiplier={1.2}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.82}
>
Green<Text style={styles.brandAccent}>Lens</Text>
</Text>
</View>
{/* Rating und Anzahl immer gemeinsam: 5,0 aus 6 Bewertungen ist
ehrlich und ueberpruefbar - eine nackte Sternezahl waere es nicht. */}
<View style={styles.ratingPill}>
<Ionicons name="star" size={13} color="#f5c04e" />
<Text style={styles.ratingText} maxFontSizeMultiplier={1.2} numberOfLines={1}>
{copy.ratingValue}
</Text>
<Text
style={styles.ratingCount}
maxFontSizeMultiplier={1.2}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.72}
>
· {copy.ratingCount}
</Text>
<View style={styles.starsRow}>
{Array.from({ length: testimonial.stars }).map((_, i) => (
<Ionicons key={i} name="star" size={14} color="#f5c04e" />
))}
</View>
</View>
</View>
) : (
/* Kein belegtes Zitat vorhanden → Argument statt erfundenem Social Proof. */
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
{proofPoints.map((point) => (
<View key={point} style={styles.proofRow}>
<Ionicons name="checkmark-circle" size={15} color="#a6d66f" />
<Text style={styles.proofText}>{point}</Text>
{testimonial ? (
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
<Text
style={styles.testimonialText}
numberOfLines={compact ? 3 : 4}
maxFontSizeMultiplier={1.3}
>
{testimonial.quote}"
</Text>
<View style={styles.testimonialMeta}>
<Text style={styles.testimonialAuthor} maxFontSizeMultiplier={1.2}>
{testimonial.author}
{testimonial.isTranslated ? ` · ${translatedNote(language)}` : ''}
</Text>
<View style={styles.starsRow}>
{Array.from({ length: testimonial.stars }).map((_, i) => (
<Ionicons key={i} name="star" size={14} color="#f5c04e" />
))}
</View>
</View>
))}
</View>
)}
</View>
</View>
<View style={styles.sheet}>
<View style={styles.sheetHandle} />
<View style={styles.sheetContent}>
<View style={styles.topSpacer} />
{/* Erst rendern, wenn die Variante feststeht. Sonst blitzt fuer die
Haelfte der Nutzer kurz die falsche Headline auf und wechselt dann -
das sieht kaputt aus und verfaelscht den Test. */}
<View style={{ opacity: variant ? 1 : 0 }}>
<Text style={[styles.headline, compact && styles.headlineCompact]}>{copy.headline}</Text>
<Text style={styles.subline}>{copy.subline}</Text>
</View>
) : (
/* Kein belegtes Zitat vorhanden → Argument statt erfundenem Social Proof. */
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
{proofPoints.map((point) => (
<View key={point} style={styles.proofRow}>
<Ionicons name="checkmark-circle" size={15} color="#a6d66f" />
<Text style={styles.proofText} maxFontSizeMultiplier={1.3}>{point}</Text>
</View>
))}
</View>
)}
</View>
<View style={styles.spacer} />
<TouchableOpacity
style={styles.cta}
onPress={() => {
posthog.capture('onboarding_started', { welcome_variant: variant ?? 'unassigned' });
router.push('/onboarding/slides');
}}
activeOpacity={0.86}
>
<Text style={styles.ctaText}>{copy.cta}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/auth/login')} style={styles.loginLink}>
<Text style={styles.loginText}>{copy.login}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
posthog.capture('onboarding_demo_scan_started', {
welcome_variant: variant ?? 'unassigned',
});
router.push('/scanner');
}}
style={styles.demoLink}
>
<Ionicons name="scan-outline" size={18} color="#a6d66f" />
<Text style={styles.demoText}>{copy.demoScan}</Text>
</TouchableOpacity>
<Text style={styles.legal}>{copy.legal}</Text>
</View>
</View>
<View style={[styles.sheet, { paddingBottom: Math.max(insets.bottom, 12) }]}>
<View style={styles.sheetHandle} />
<View style={styles.sheetContent}>
{/* Erst rendern, wenn die Variante feststeht. Sonst blitzt fuer die
Haelfte der Nutzer kurz die falsche Headline auf und wechselt dann -
das sieht kaputt aus und verfaelscht den Test. */}
<View style={{ opacity: variant ? 1 : 0 }}>
{/* maxFontSizeMultiplier: groessere Systemschrift darf die Headline
wachsen lassen, aber nicht die CTAs aus dem Screen schieben. */}
<Text
style={[styles.headline, compact && styles.headlineCompact]}
maxFontSizeMultiplier={1.25}
>
{copy.headline}
</Text>
<Text style={styles.subline} maxFontSizeMultiplier={1.3}>{copy.subline}</Text>
</View>
<View style={styles.spacer} />
<TouchableOpacity
style={styles.cta}
onPress={() => {
posthog.capture('onboarding_started', { welcome_variant: variant ?? 'unassigned' });
router.push('/onboarding/slides');
}}
activeOpacity={0.86}
>
<Text style={styles.ctaText} maxFontSizeMultiplier={1.2}>{copy.cta}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/auth/login')} style={styles.loginLink}>
<Text style={styles.loginText} maxFontSizeMultiplier={1.2}>{copy.login}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
posthog.capture('onboarding_demo_scan_started', {
welcome_variant: variant ?? 'unassigned',
});
router.push('/scanner');
}}
style={styles.demoLink}
>
<Ionicons name="scan-outline" size={18} color="#a6d66f" />
<Text style={styles.demoText} maxFontSizeMultiplier={1.2}>{copy.demoScan}</Text>
</TouchableOpacity>
<Text style={styles.legal} maxFontSizeMultiplier={1.3}>{copy.legal}</Text>
</View>
</View>
</ScrollView>
</View>
);
}
@@ -239,6 +278,22 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#0a110b',
},
scroll: {
flex: 1,
},
scrollContent: {
// Fuellt mindestens den Bildschirm. Wird der Inhalt hoeher (lange
// Headline-Variante, grosse Systemschrift, kleines Geraet), scrollt der
// Screen - statt die unteren Buttons abzuschneiden.
flexGrow: 1,
},
heroBlock: {
width: '100%',
// Nimmt den Platz, den das Sheet uebrig laesst, faellt aber nie unter
// heroMinHeight. Vorher fest 52% - das hat nur bei kurzer Copy gepasst.
flexGrow: 1,
flexShrink: 0,
},
hero: {
width: '100%',
},
@@ -269,6 +324,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 10,
flexShrink: 1,
},
logo: {
width: 36,
@@ -277,6 +333,7 @@ const styles = StyleSheet.create({
backgroundColor: '#fff',
},
brandName: {
flexShrink: 1,
color: '#ffffff',
fontSize: 24,
fontWeight: '900',
@@ -285,9 +342,11 @@ const styles = StyleSheet.create({
color: '#8ba885',
},
ratingPill: {
flexShrink: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginLeft: 10,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.25)',
@@ -301,6 +360,7 @@ const styles = StyleSheet.create({
fontWeight: '700',
},
ratingCount: {
flexShrink: 1,
color: '#c9d3c2',
fontSize: 11,
fontWeight: '600',
@@ -347,6 +407,9 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
},
testimonialAuthor: {
flex: 1,
flexShrink: 1,
marginRight: 8,
color: '#a6d66f',
fontSize: 13,
fontWeight: '700',
@@ -356,7 +419,7 @@ const styles = StyleSheet.create({
gap: 1,
},
sheet: {
flex: 1,
flexShrink: 0,
backgroundColor: '#0c160d',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
@@ -374,14 +437,10 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
sheetContent: {
flex: 1,
paddingHorizontal: 24,
paddingTop: 8,
paddingTop: 12,
paddingBottom: 12,
},
topSpacer: {
height: 12,
},
headline: {
color: '#ffffff',
fontSize: 28,
@@ -402,15 +461,16 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
spacer: {
height: 18,
height: 14,
},
cta: {
height: 60,
minHeight: 58,
paddingVertical: 16,
borderRadius: 16,
backgroundColor: '#437824',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 6,
marginBottom: 4,
},
ctaText: {
color: '#f8f7ef',
@@ -419,7 +479,7 @@ const styles = StyleSheet.create({
},
loginLink: {
alignItems: 'center',
paddingVertical: 10,
paddingVertical: 9,
},
loginText: {
color: '#a6d66f',
@@ -431,13 +491,14 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
gap: 8,
height: 52,
minHeight: 50,
paddingVertical: 13,
borderRadius: 14,
borderWidth: 1.5,
borderColor: '#a6d66f',
backgroundColor: 'rgba(166, 214, 111, 0.05)',
marginVertical: 8,
marginBottom: 12,
marginTop: 6,
marginBottom: 10,
},
demoText: {
color: '#a6d66f',

View File

@@ -55,21 +55,31 @@ export default function CustomizeOnboardingScreen() {
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<TouchableOpacity onPress={skipCustomization} style={[styles.iconBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="close" size={20} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerCopy}>
<Text style={[styles.eyebrow, { color: colors.primary }]}>{t.onboardingChecklistIntro}</Text>
<Text style={[styles.title, { color: colors.text }]}>{t.customizeOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.customizeOnboardingSubtitle}</Text>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<TouchableOpacity onPress={skipCustomization} style={[styles.iconBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="close" size={20} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerCopy}>
<Text style={[styles.eyebrow, { color: colors.primary }]} maxFontSizeMultiplier={1.3}>
{t.onboardingChecklistIntro}
</Text>
<Text style={[styles.title, { color: colors.text }]} maxFontSizeMultiplier={1.25}>
{t.customizeOnboardingTitle}
</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]} maxFontSizeMultiplier={1.3}>
{t.customizeOnboardingSubtitle}
</Text>
</View>
</View>
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={[styles.previewCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.previewLabel, { color: colors.textMuted }]}>{t.customizeOnboardingPreview}</Text>
<Text style={[styles.previewTitle, { color: colors.text }]}>{t.onboardingTagline}</Text>
<Text style={[styles.previewLabel, { color: colors.textMuted }]} maxFontSizeMultiplier={1.3}>{t.customizeOnboardingPreview}</Text>
<Text style={[styles.previewTitle, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{t.onboardingTagline}</Text>
<View style={styles.previewMeta}>
<View style={[styles.previewChip, { backgroundColor: colors.primarySoft }]}>
<Text style={[styles.previewChipText, { color: colors.primaryDark }]}>{appearanceMode}</Text>
@@ -84,7 +94,7 @@ export default function CustomizeOnboardingScreen() {
</View>
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t.appearanceMode}</Text>
<Text style={[styles.sectionTitle, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{t.appearanceMode}</Text>
<View style={styles.segmentedControl}>
{(['system', 'light', 'dark'] as AppearanceMode[]).map((mode) => {
const isActive = appearanceMode === mode;
@@ -96,7 +106,7 @@ export default function CustomizeOnboardingScreen() {
style={[styles.segmentBtn, isActive && { backgroundColor: colors.primary }]}
onPress={() => setAppearanceMode(mode)}
>
<Text style={[styles.segmentText, { color: isActive ? colors.onPrimary : colors.text }]}>
<Text style={[styles.segmentText, { color: isActive ? colors.onPrimary : colors.text }]} maxFontSizeMultiplier={1.2} numberOfLines={2}>
{label}
</Text>
</TouchableOpacity>
@@ -106,7 +116,7 @@ export default function CustomizeOnboardingScreen() {
</View>
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t.colorPalette}</Text>
<Text style={[styles.sectionTitle, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{t.colorPalette}</Text>
<View style={styles.swatchContainer}>
{(['forest', 'ocean', 'sunset', 'mono'] as ColorPalette[]).map((palette) => {
const isActive = colorPalette === palette;
@@ -127,7 +137,7 @@ export default function CustomizeOnboardingScreen() {
onPress={() => setColorPalette(palette)}
>
<View style={[styles.swatch, { backgroundColor: swatch[0] }]} />
<Text style={[styles.swatchLabel, { color: colors.text }]}>{label}</Text>
<Text style={[styles.swatchLabel, { color: colors.text }]} maxFontSizeMultiplier={1.2} numberOfLines={2}>{label}</Text>
</TouchableOpacity>
);
})}
@@ -135,7 +145,7 @@ export default function CustomizeOnboardingScreen() {
</View>
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t.language}</Text>
<Text style={[styles.sectionTitle, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{t.language}</Text>
<View style={styles.languageRow}>
{(['en', 'de', 'es'] as Language[]).map((lang) => {
const isActive = language === lang;
@@ -147,7 +157,10 @@ export default function CustomizeOnboardingScreen() {
style={[styles.languageBtn, isActive && { backgroundColor: colors.primary }]}
onPress={() => changeLanguage(lang)}
>
<Text style={{ color: isActive ? colors.onPrimary : colors.text, fontWeight: '600' }}>
<Text
style={{ color: isActive ? colors.onPrimary : colors.text, fontWeight: '600' }}
maxFontSizeMultiplier={1.2}
>
{label}
</Text>
</TouchableOpacity>
@@ -162,10 +175,10 @@ export default function CustomizeOnboardingScreen() {
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={skipCustomization}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{t.customizeOnboardingSkip}</Text>
<Text style={[styles.secondaryBtnText, { color: colors.text }]} maxFontSizeMultiplier={1.2} numberOfLines={2}>{t.customizeOnboardingSkip}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={finishCustomization}>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.customizeOnboardingContinue}</Text>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]} maxFontSizeMultiplier={1.2} numberOfLines={2}>{t.customizeOnboardingContinue}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
@@ -180,12 +193,13 @@ const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
scroll: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
paddingHorizontal: 20,
paddingTop: 12,
},
iconBtn: {
width: 40,
@@ -218,6 +232,7 @@ const styles = StyleSheet.create({
content: {
padding: 20,
gap: 16,
paddingBottom: 12,
},
previewCard: {
borderWidth: 1,
@@ -317,7 +332,9 @@ const styles = StyleSheet.create({
},
secondaryBtn: {
flex: 1,
height: 52,
minHeight: 52,
paddingHorizontal: 8,
paddingVertical: 14,
borderRadius: 16,
borderWidth: 1.5,
alignItems: 'center',
@@ -326,10 +343,13 @@ const styles = StyleSheet.create({
secondaryBtnText: {
fontSize: 15,
fontWeight: '600',
textAlign: 'center',
},
primaryBtn: {
flex: 1.3,
height: 52,
minHeight: 52,
paddingHorizontal: 8,
paddingVertical: 14,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
@@ -337,5 +357,6 @@ const styles = StyleSheet.create({
primaryBtnText: {
fontSize: 15,
fontWeight: '700',
textAlign: 'center',
},
});

View File

@@ -126,12 +126,18 @@ export default function HealthCheckOnboardingScreen() {
</View>
<View style={styles.backBtn} />
</View>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{copy.subtitle}</Text>
</View>
{/* Header liegt im Scrollbereich: bei grosser Systemschrift wuchs er
sonst nach unten und hat den Scrollbereich auf Null gequetscht. */}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]} maxFontSizeMultiplier={1.25}>{copy.title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]} maxFontSizeMultiplier={1.3}>{copy.subtitle}</Text>
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
{/* 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 }]}>
@@ -212,10 +218,10 @@ export default function HealthCheckOnboardingScreen() {
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => finish(true)}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{copy.skip}</Text>
<Text style={[styles.secondaryBtnText, { color: colors.text }]} maxFontSizeMultiplier={1.2} numberOfLines={2}>{copy.skip}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={() => finish(false)}>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.cta}</Text>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]} maxFontSizeMultiplier={1.2} numberOfLines={2}>{copy.cta}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
@@ -226,11 +232,12 @@ export default function HealthCheckOnboardingScreen() {
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
scroll: { flex: 1 },
topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 },
backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' },
progressFill: { height: 6, borderRadius: 3 },
header: { gap: 9, marginTop: 8, marginBottom: 18 },
header: { gap: 9, marginTop: 8, marginBottom: 4 },
title: { fontSize: 30, lineHeight: 34, fontWeight: '900' },
subtitle: { fontSize: 14, lineHeight: 20 },
content: { gap: 14, paddingBottom: 12 },
@@ -268,8 +275,10 @@ const styles = StyleSheet.create({
guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' },
footer: { flexDirection: 'row', gap: 12, marginTop: 12 },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' },
// minHeight statt height: sonst wird der Label-Text bei groesserer
// Systemschrift oben und unten abgeschnitten.
secondaryBtn: { flex: 1, minHeight: 52, paddingVertical: 14, paddingHorizontal: 8, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600', textAlign: 'center' },
primaryBtn: { flex: 1.3, minHeight: 52, paddingVertical: 14, paddingHorizontal: 8, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700', textAlign: 'center' },
});

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native';
import { Animated, Easing, Image, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
@@ -178,7 +178,14 @@ export default function OnboardingPersonalizingScreen() {
return (
<SafeAreaView style={[styles.safe, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}>
<Text style={[styles.percent, { color: colors.primary }]}>{percent}%</Text>
{/* Scrollbar, damit die Zusammenfassungs-Karte auf kleinen Geraeten und
bei grosser Systemschrift nicht unten wegfaellt. */}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Text style={[styles.percent, { color: colors.primary }]} maxFontSizeMultiplier={1.2}>{percent}%</Text>
<View style={styles.ringWrap}>
<Svg width={RING_SIZE} height={RING_SIZE} style={StyleSheet.absoluteFill}>
<Circle
@@ -208,7 +215,7 @@ export default function OnboardingPersonalizingScreen() {
</View>
<View style={[styles.statusPill, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="sync-outline" size={15} color={colors.textSecondary} />
<Text style={[styles.statusText, { color: colors.textSecondary }]}>{copy.status}</Text>
<Text style={[styles.statusText, { color: colors.textSecondary }]} maxFontSizeMultiplier={1.3}>{copy.status}</Text>
</View>
<View style={styles.checklist}>
{copy.steps.map((label, index) => {
@@ -220,7 +227,7 @@ export default function OnboardingPersonalizingScreen() {
size={24}
color={done ? colors.primary : colors.border}
/>
<Text style={[styles.checkLabel, { color: done ? colors.text : colors.textMuted }]}>{label}</Text>
<Text style={[styles.checkLabel, { color: done ? colors.text : colors.textMuted }]} maxFontSizeMultiplier={1.3}>{label}</Text>
</View>
);
})}
@@ -239,12 +246,15 @@ export default function OnboardingPersonalizingScreen() {
)}
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, alignItems: 'center', paddingHorizontal: 24, paddingTop: 30 },
safe: { flex: 1 },
scroll: { flex: 1 },
scrollContent: { flexGrow: 1, alignItems: 'center', paddingHorizontal: 24, paddingTop: 30, paddingBottom: 16 },
percent: { fontSize: 56, fontWeight: '900', marginBottom: 16 },
ringWrap: { width: 150, height: 150, alignItems: 'center', justifyContent: 'center', marginBottom: 22 },
ringImage: { width: 112, height: 112, borderRadius: 56 },

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Image, StyleSheet, Text, TouchableOpacity, View, useWindowDimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import React, { useEffect, useRef, useState } from 'react';
import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View, useWindowDimensions } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAnalytics } from '../../services/analytics';
@@ -112,6 +112,7 @@ const getSlidesCopy = (language: Language) => {
};
type SlidesCopy = ReturnType<typeof getSlidesCopy>;
const OVERLAY_TEXT_MAX_MULTIPLIER = 1.2;
/** Slide 1: Ursachen-Ranking statt Prozent-Behauptung. */
function CauseRankingOverlay({ copy, colors }: { copy: SlidesCopy; colors: ColorsType }) {
@@ -124,16 +125,18 @@ function CauseRankingOverlay({ copy, colors }: { copy: SlidesCopy; colors: Color
<View style={[styles.cornerBR, { borderColor: colors.primary }]} />
</View>
<View style={styles.healthCard}>
<Text style={styles.overlayEyebrow}>{copy.causeTitle}</Text>
<Text style={styles.overlayEyebrow} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{copy.causeTitle}
</Text>
<View style={[styles.healthRow, styles.healthRowWarning]}>
<Ionicons name="alert-circle" size={15} color="#C62828" />
<Text style={styles.healthRowWarningText}>
<Text style={styles.healthRowWarningText} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{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}>
<Text style={styles.healthRowNeutralText} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{copy.causeSecondary} · {copy.causeSecondaryLevel}
</Text>
</View>
@@ -141,8 +144,12 @@ function CauseRankingOverlay({ copy, colors }: { copy: SlidesCopy; colors: Color
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>
<Text style={styles.checkChipLabel} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{copy.checkLabel}
</Text>
<Text style={styles.checkChipText} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{copy.checkItems}
</Text>
</View>
</View>
</>
@@ -157,15 +164,21 @@ function PlanOverlay({ copy }: { copy: SlidesCopy }) {
<View style={styles.healthCardIcon}>
<Ionicons name="calendar" size={16} color="#2e7d32" />
</View>
<Text style={styles.healthCardTitle}>{copy.planLabel}</Text>
<Text style={styles.healthCardTitle} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{copy.planLabel}
</Text>
</View>
<View style={[styles.healthRow, styles.healthRowSuccess]}>
<Ionicons name="water-outline" size={15} color="#2e7d32" />
<Text style={styles.healthRowSuccessText}>{copy.planNow}</Text>
<Text style={styles.healthRowSuccessText} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{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>
<Text style={styles.healthRowNeutralText} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{copy.planFollowUp}
</Text>
</View>
</View>
);
@@ -179,23 +192,35 @@ function UncertaintyOverlay({ copy }: { copy: SlidesCopy }) {
<View style={styles.healthCardIcon}>
<Ionicons name="eye-off-outline" size={16} color="#6b7280" />
</View>
<Text style={styles.healthCardTitle}>{copy.uncertainTitle}</Text>
<Text style={styles.healthCardTitle} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{copy.uncertainTitle}
</Text>
</View>
{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>
<Text style={styles.healthRowNeutralText} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{item}
</Text>
</View>
))}
<Text style={styles.overlayNote}>{copy.uncertainNote}</Text>
<Text style={styles.overlayNote} maxFontSizeMultiplier={OVERLAY_TEXT_MAX_MULTIPLIER}>
{copy.uncertainNote}
</Text>
</View>
);
}
export default function OnboardingSlidesScreen() {
const router = useRouter();
const scrollRef = useRef<ScrollView>(null);
const { height } = useWindowDimensions();
const insets = useSafeAreaInsets();
const compact = height < 700;
// Das Bild bekommt den Platz, den das Sheet uebrig laesst - aber nie weniger
// als diese Untergrenze. Feste Prozenthoehen haben bei groesserer
// System-Schriftgroesse den CTA aus dem Bildschirm geschoben.
const imageMinHeight = Math.round(height * (compact ? 0.46 : 0.42));
const { language, isDarkMode, colorPalette } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics();
@@ -204,6 +229,7 @@ export default function OnboardingSlidesScreen() {
const slide = copy.slides[page];
useEffect(() => {
scrollRef.current?.scrollTo({ y: 0, animated: false });
posthog.capture('onboarding_slide_viewed', { index: page, slide: ['triage', 'plan', 'uncertainty'][page] });
}, [page, posthog]);
@@ -224,8 +250,15 @@ export default function OnboardingSlidesScreen() {
};
return (
<View style={[styles.container, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}>
<View style={[styles.imageArea, { height: compact ? '58%' : '65%' }]}>
<ScrollView
ref={scrollRef}
style={[styles.container, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
bounces={false}
alwaysBounceVertical={false}
>
<View style={[styles.imageArea, { minHeight: imageMinHeight }]}>
<Image
source={
page === 0
@@ -246,9 +279,20 @@ export default function OnboardingSlidesScreen() {
{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>
<Text style={[styles.body, { color: colors.textSecondary }]}>{slide.body}</Text>
<View
style={[
styles.sheet,
{ backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, 16) + 8 },
]}
>
{/* maxFontSizeMultiplier: die Systemschriftgroesse darf die Headline
vergroessern, aber nicht so weit, dass sie das halbe Sheet frisst. */}
<Text style={[styles.title, { color: colors.text }]} maxFontSizeMultiplier={1.25}>
{slide.title}
</Text>
<Text style={[styles.body, { color: colors.textSecondary }]} maxFontSizeMultiplier={1.35}>
{slide.body}
</Text>
<View style={styles.dots}>
{copy.slides.map((_, index) => (
<View
@@ -267,10 +311,12 @@ export default function OnboardingSlidesScreen() {
onPress={next}
activeOpacity={0.86}
>
<Text style={[styles.ctaText, { color: colors.onPrimary }]}>{copy.continueLabel}</Text>
<Text style={[styles.ctaText, { color: colors.onPrimary }]} maxFontSizeMultiplier={1.2}>
{copy.continueLabel}
</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
);
}
@@ -278,21 +324,26 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
// Fuellt mindestens den Bildschirm; wird der Inhalt bei sehr grosser
// Schrift hoeher, scrollt der Screen statt den CTA abzuschneiden.
flexGrow: 1,
},
imageArea: {
height: '58%',
flexGrow: 1,
flexShrink: 0,
position: 'relative',
overflow: 'hidden',
},
image: {
width: '100%',
height: '100%',
position: 'absolute',
...StyleSheet.absoluteFillObject,
},
imageSafeArea: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 2,
},
backBtn: {
marginLeft: 16,
@@ -358,6 +409,7 @@ const styles = StyleSheet.create({
bottom: 34,
left: 16,
right: 16,
zIndex: 1,
backgroundColor: 'rgba(255,255,255,0.96)',
borderRadius: 18,
padding: 14,
@@ -456,7 +508,9 @@ const styles = StyleSheet.create({
// Reminder chips overlay (slide 3)
// Bottom sheet
sheet: {
flex: 1,
// Hoehe folgt dem Inhalt statt einem festen Prozentwert - sonst haengt es
// von Textlaenge und Systemschriftgroesse ab, ob der CTA noch passt.
flexShrink: 0,
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
marginTop: -24,
@@ -481,7 +535,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 'auto',
marginBottom: 22,
},
dot: {
width: 8,
@@ -495,11 +549,11 @@ const styles = StyleSheet.create({
},
cta: {
alignSelf: 'stretch',
height: 58,
minHeight: 58,
paddingVertical: 16,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
ctaText: {
fontSize: 17,

View File

@@ -7,6 +7,7 @@ import {
Image,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
@@ -312,6 +313,7 @@ export default function OnboardingChatScreen() {
});
const flatListRef = useRef<FlatList>(null);
const inputScrollRef = useRef<ScrollView>(null);
const floatAnim = useRef(new Animated.Value(0)).current;
// Float animation loop for the background monstera pot
@@ -382,6 +384,10 @@ export default function OnboardingChatScreen() {
}, 100);
}, [messages, isTyping]);
useEffect(() => {
inputScrollRef.current?.scrollTo({ y: 0, animated: false });
}, [currentStep]);
const selectOption = (id: string, label: string) => {
// Add user bubble
const userMsg: Message = {
@@ -509,7 +515,7 @@ export default function OnboardingChatScreen() {
if (currentStep === 0) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>{copy.stepSituation}</Text>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepSituation}</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'acute', label: copy.situations.acute, icon: 'alert-circle-outline' as const },
@@ -519,7 +525,7 @@ export default function OnboardingChatScreen() {
<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>
<Text style={[styles.rowText, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
@@ -533,7 +539,7 @@ export default function OnboardingChatScreen() {
if (currentStep === 1) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>{copy.stepSymptom}</Text>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepSymptom}</Text>
<View style={styles.optionsGrid}>
{([
{ id: 'yellow', label: copy.symptoms.yellow, icon: 'leaf-outline' as const },
@@ -545,7 +551,7 @@ export default function OnboardingChatScreen() {
]).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>
<Text style={[styles.pillLabel, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
@@ -557,7 +563,7 @@ export default function OnboardingChatScreen() {
if (currentStep === 2) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>{copy.stepLight}</Text>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{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' },
@@ -566,8 +572,8 @@ export default function OnboardingChatScreen() {
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.bentoOption, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.bentoCopy}>
<Text style={[styles.bentoTitle, { color: colors.text }]}>{opt.label}</Text>
<Text style={[styles.bentoSub, { color: colors.textSecondary }]}>{opt.sub}</Text>
<Text style={[styles.bentoTitle, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
<Text style={[styles.bentoSub, { color: colors.textSecondary }]} maxFontSizeMultiplier={1.3}>{opt.sub}</Text>
</View>
<Ionicons name={opt.icon === 'sunny' ? 'sunny-outline' : opt.icon === 'brightness-4' ? 'contrast' : 'partly-sunny-outline'} size={20} color={colors.primary} />
</TouchableOpacity>
@@ -582,7 +588,7 @@ export default function OnboardingChatScreen() {
if (currentStep === 3) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>{copy.stepExperience}</Text>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepExperience}</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'beginner', label: copy.experiences.beginner, icon: 'leaf-outline' as const },
@@ -592,7 +598,7 @@ export default function OnboardingChatScreen() {
<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>
<Text style={[styles.rowText, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
@@ -608,7 +614,7 @@ export default function OnboardingChatScreen() {
if (currentStep === 4) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle}>{copy.stepSource}</Text>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepSource}</Text>
<View style={styles.optionsGrid}>
{([
{ id: 'app_store', label: copy.sources.app_store, icon: 'logo-apple' as const },
@@ -623,7 +629,7 @@ export default function OnboardingChatScreen() {
]).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>
<Text style={[styles.pillLabel, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
@@ -636,7 +642,7 @@ export default function OnboardingChatScreen() {
return (
<View style={styles.footerWrap}>
<TouchableOpacity style={[styles.continueCta, { backgroundColor: colors.primary }]} onPress={onFinish} activeOpacity={0.86}>
<Text style={[styles.continueCtaText, { color: colors.onPrimary }]}>{copy.continue}</Text>
<Text style={[styles.continueCtaText, { color: colors.onPrimary }]} maxFontSizeMultiplier={1.2}>{copy.continue}</Text>
</TouchableOpacity>
</View>
);
@@ -734,9 +740,18 @@ export default function OnboardingChatScreen() {
/>
</View>
{/* Input/Selection Panel */}
{/* Input/Selection Panel. Scrollbar und in der Hoehe gedeckelt: der
Attributions-Schritt hat 9 Optionen, die bei grosser Systemschrift
sonst den Chatverlauf und den Weiter-Button verdraengen. */}
<View style={styles.inputPanel}>
{renderOptions()}
<ScrollView
ref={inputScrollRef}
contentContainerStyle={styles.inputPanelContent}
showsVerticalScrollIndicator={false}
bounces={false}
>
{renderOptions()}
</ScrollView>
</View>
</SafeAreaView>
</KeyboardAvoidingView>
@@ -902,12 +917,16 @@ const styles = StyleSheet.create({
borderRadius: 3,
},
inputPanel: {
flexShrink: 1,
maxHeight: '58%',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(255,255,255,0.08)',
backgroundColor: '#131e14',
},
inputPanelContent: {
paddingTop: 12,
paddingBottom: 24,
paddingHorizontal: 16,
backgroundColor: '#131e14',
},
optionsWrap: {
gap: 10,
@@ -989,7 +1008,8 @@ const styles = StyleSheet.create({
paddingVertical: 8,
},
continueCta: {
height: 56,
minHeight: 56,
paddingVertical: 16,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',

View File

@@ -756,7 +756,9 @@ export default function BillingScreen() {
return (
<View style={[styles.hardPaywallScreen, { backgroundColor: colors.background }]}>
<View style={styles.hardPaywallPlain}>
<SafeAreaView style={styles.hardPaywallSafe} edges={['top']}>
{/* 'bottom' mit drin: sonst liegen Kuendigungshinweis und
Privacy/Terms auf dem Home-Indicator. */}
<SafeAreaView style={styles.hardPaywallSafe} edges={['top', 'bottom']}>
<View style={styles.heroTopBar}>
<TouchableOpacity onPress={handleBack} style={[styles.heroIconButton, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="close" size={24} color={colors.text} />
@@ -773,7 +775,7 @@ export default function BillingScreen() {
showsVerticalScrollIndicator={false}
>
<Text style={[styles.paywallEyebrow, { color: colors.primary }]}>{copy.paywallEyebrow.toUpperCase()}</Text>
<Text style={[styles.paywallHeadline, { color: colors.text }]}>{copy.paywallHeadline}</Text>
<Text style={[styles.paywallHeadline, { color: colors.text }]} maxFontSizeMultiplier={1.25}>{copy.paywallHeadline}</Text>
<Text style={[styles.paywallSub, { color: colors.textSecondary }]}>{copy.paywallSub}</Text>
{/* Feature-Bullets ueber der Angebotskarte: kurz, drei
@@ -857,7 +859,10 @@ export default function BillingScreen() {
<ActivityIndicator color={colors.onPrimary} />
) : (
<>
<Text style={[styles.offerCtaText, { color: colors.onPrimary }]}>
<Text
style={[styles.offerCtaText, { color: colors.onPrimary }]}
maxFontSizeMultiplier={1.2}
>
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
<Ionicons name="arrow-forward" size={19} color={colors.onPrimary} />
@@ -1366,11 +1371,15 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
gap: 9,
height: 56,
// minHeight statt height: der Kauf-Button darf mit der Systemschrift
// wachsen, statt sein Label abzuschneiden.
minHeight: 56,
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 14,
marginTop: 16,
},
offerCtaText: { fontSize: 17.5, fontWeight: '800' },
offerCtaText: { fontSize: 17.5, fontWeight: '800', textAlign: 'center' },
offerAltLink: { alignItems: 'center', paddingVertical: 13 },
offerAltLinkText: { fontSize: 13.5, fontWeight: '600', textDecorationLine: 'underline' },
dueBanner: {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { 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 { Ionicons } from '@expo/vector-icons';
import { useColors } from '../constants/Colors';
@@ -42,9 +42,15 @@ export function OnboardingQuestion({
</View>
<View style={styles.backBtn} />
</View>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{subtitle}</Text>
<View style={styles.options}>
{/* Scrollbar statt fixem Block: bei grosser Systemschriftgroesse wuchsen
Titel und Karten frueher ueber den Footer und haben den CTA verdeckt. */}
<ScrollView
style={styles.optionsScroll}
contentContainerStyle={styles.options}
showsVerticalScrollIndicator={false}
>
<Text style={[styles.title, { color: colors.text }]} maxFontSizeMultiplier={1.25}>{title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]} maxFontSizeMultiplier={1.3}>{subtitle}</Text>
{options.map((option) => {
const active = selectedId === option.id;
return (
@@ -59,13 +65,13 @@ export function OnboardingQuestion({
>
<Text style={styles.emoji}>{option.emoji}</Text>
<View style={styles.cardCopy}>
<Text style={[styles.cardLabel, { color: active ? colors.primary : colors.text }]}>{option.label}</Text>
{option.subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textMuted }]}>{option.subtitle}</Text> : null}
<Text style={[styles.cardLabel, { color: active ? colors.primary : colors.text }]} maxFontSizeMultiplier={1.3}>{option.label}</Text>
{option.subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textMuted }]} maxFontSizeMultiplier={1.3}>{option.subtitle}</Text> : null}
</View>
</TouchableOpacity>
);
})}
</View>
</ScrollView>
<View style={styles.footer}>
{skipLabel && onSkip ? (
<TouchableOpacity onPress={onSkip} style={styles.skipBtn}>
@@ -78,7 +84,7 @@ export function OnboardingQuestion({
activeOpacity={0.86}
style={[styles.cta, { backgroundColor: selectedId ? colors.primary : colors.surfaceMuted }]}
>
<Text style={[styles.ctaText, { color: selectedId ? colors.onPrimary : colors.textMuted }]}>{continueLabel}</Text>
<Text style={[styles.ctaText, { color: selectedId ? colors.onPrimary : colors.textMuted }]} maxFontSizeMultiplier={1.2}>{continueLabel}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
@@ -93,7 +99,8 @@ const styles = StyleSheet.create({
progressFill: { height: 6, borderRadius: 3 },
title: { fontSize: 30, lineHeight: 35, fontWeight: '900', textAlign: 'center', marginTop: 18, marginBottom: 8 },
subtitle: { fontSize: 15, lineHeight: 20, textAlign: 'center', marginBottom: 22 },
options: { gap: 12, flex: 1 },
optionsScroll: { flex: 1 },
options: { gap: 12, paddingBottom: 12 },
card: { flexDirection: 'row', alignItems: 'center', gap: 14, borderRadius: 16, borderWidth: 2, paddingHorizontal: 16, paddingVertical: 18 },
emoji: { fontSize: 26 },
cardCopy: { flex: 1, gap: 2 },
@@ -102,6 +109,6 @@ const styles = StyleSheet.create({
footer: { gap: 8, paddingBottom: 6 },
skipBtn: { alignItems: 'center', paddingVertical: 6 },
skipText: { fontSize: 14, fontWeight: '700' },
cta: { height: 56, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
cta: { minHeight: 56, paddingVertical: 16, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
ctaText: { fontSize: 17, fontWeight: '800' },
});

View File

@@ -0,0 +1,150 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import styles from './press.module.css'
const releaseUrl =
'https://news.prfree.org/@greenlenspro/greenlens-publishes-a-step-by-step-guide-to-troubleshooting-common-houseplant-problems-0ochpt6gr315'
const routineReleaseUrl =
'https://news.newswirebiz.com/@greenlenspro/greenlens-shares-a-5-step-plant-care-routine-for-when-houseplants-start-showing-stress-39kipufgt3xh'
const openPrReleaseUrl =
'https://www.openpr.de/news/1319466/Zimmerpflanzen-retten-statt-raten-6-Fragen-vor-jedem-Pflege-Schritt.html'
export const metadata: Metadata = {
title: 'Press | GreenLens',
description: 'Latest GreenLens news, press releases, and company announcements.',
alternates: { canonical: '/press' },
}
export default function PressPage() {
return (
<main className={styles.page}>
<section className={styles.hero}>
<div className={styles.contour} aria-hidden="true" />
<div className={styles.heroInner}>
<header className={styles.nav}>
<Link href="/" className={styles.brand} aria-label="GreenLens home">
<span className={styles.brandMark} aria-hidden="true">
<LeafIcon />
</span>
GreenLens
</Link>
<nav aria-label="Press navigation" className={styles.navLinks}>
<Link href="/">Home</Link>
<Link href="/support">Support</Link>
<a href="https://apps.apple.com/de/app/plant-doctor-greenlens-pro/id6759843546?l=en-GB" target="_blank" rel="noreferrer">
Get the app
</a>
</nav>
</header>
<div className={styles.heroCopy}>
<p className={styles.kicker}>GreenLens press office</p>
<h1>Newsroom</h1>
<div className={styles.rule} />
<p>Latest updates, product news, and practical plant-care resources from the GreenLens team.</p>
</div>
</div>
</section>
<section className={styles.releases} aria-labelledby="latest-release">
<div className={styles.content}>
<div className={styles.sectionHeading}>
<span className={styles.sectionIcon} aria-hidden="true"><LeafIcon /></span>
<h2 id="latest-release">Latest releases</h2>
<span className={styles.headingLine} aria-hidden="true" />
</div>
<div className={styles.releaseList}>
<ReleaseCard
title="Zimmerpflanzen retten statt raten: 6 Fragen vor jedem Pflege-Schritt"
lede="Der deutschsprachige Ratgeber zeigt, wie Pflanzenfans Symptome einordnen, vor einer Veränderung die wichtigsten Hinweise prüfen und mit ruhigen, nachvollziehbaren Schritten handeln können."
points={[
'Sichtbare Veränderungen genauer beschreiben statt sofort zu reagieren.',
'Wasser, Licht und jüngste Änderungen am Standort prüfen.',
'Hinweise auf Schädlinge oder Krankheiten früh erkennen.',
'Mit kleinen Anpassungen arbeiten und die Entwicklung dokumentieren.',
]}
href={openPrReleaseUrl}
date="July 30, 2026"
/>
<ReleaseCard
title="GreenLens Shares a 5-Step Plant Care Routine for When Houseplants Start Showing Stress"
lede="A practical five-step routine helps indoor plant owners assess common warning signs before changing watering, light, fertilizer, or treatments."
points={[
'Define the symptom before treating it.',
'Review recent changes in the plants environment.',
'Check the root zone, drainage, and available light.',
'Make one low-risk adjustment and document the response.',
]}
href={routineReleaseUrl}
/>
<ReleaseCard
title="GreenLens Publishes a Step-by-Step Guide to Troubleshooting Common Houseplant Problems"
lede="The new guide gives indoor plant owners a practical way to assess yellow leaves, brown tips, wilting, pests, light, and watering before making a care change."
points={[
'Read symptom patterns before reacting to a single leaf.',
'Check watering, drainage, light, and recent changes one at a time.',
'Build a simple photo-and-notes history to spot recurring patterns.',
'Know when a plant issue needs professional or urgent attention.',
]}
href={releaseUrl}
/>
</div>
</div>
</section>
</main>
)
}
function ReleaseCard({
title,
lede,
points,
href,
date = 'July 29, 2026',
}: {
title: string
lede: string
points: string[]
href: string
date?: string
}) {
return (
<article className={styles.releaseCard}>
<div className={styles.releaseMeta}>
<time dateTime={date === 'July 30, 2026' ? '2026-07-30' : '2026-07-29'}>{date}</time>
<span>Press release</span>
</div>
<h3>{title}</h3>
<p className={styles.lede}>{lede}</p>
<div className={styles.keyPoints}>
<h4>What the guide covers</h4>
<ul>
{points.map((point) => <li key={point}><LeafBullet /> {point}</li>)}
</ul>
</div>
<div className={styles.releaseFooter}>
<p>Source: <strong>GreenLens Press Office</strong></p>
<a href={href} target="_blank" rel="noreferrer" className={styles.releaseLink}>
Read full release <span aria-hidden="true"></span>
</a>
</div>
</article>
)
}
function LeafIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M19.5 3.5C12.1 3.6 6.4 7.2 5.1 13.1c-.8 3.7 1.4 6.6 4.8 6.6 6.3 0 9.9-7.7 9.6-16.2Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
<path d="M4.4 20.5c3.8-4.6 7.7-7.8 12.4-10.1M10.2 14.5l.7 4.2M13.9 10.5l3.1 1" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
)
}
function LeafBullet() {
return <span className={styles.bullet} aria-hidden="true"><LeafIcon /></span>
}

View File

@@ -0,0 +1,288 @@
.page {
min-height: 100vh;
color: #12332e;
background: #f7f5ef;
}
.hero {
position: relative;
overflow: hidden;
min-height: 430px;
color: #f7f5ef;
background: #0e1b24;
}
.hero::after {
position: absolute;
inset: 0;
content: '';
pointer-events: none;
background: radial-gradient(circle at 70% 48%, rgba(47, 165, 114, 0.09), transparent 30%);
}
.contour {
position: absolute;
right: -5%;
bottom: -45%;
width: min(720px, 65vw);
aspect-ratio: 1;
opacity: 0.48;
border: 1px solid rgba(111, 205, 151, 0.35);
border-radius: 54% 46% 58% 42%;
box-shadow:
42px -32px 0 -1px rgba(111, 205, 151, 0.26),
88px -70px 0 -1px rgba(111, 205, 151, 0.18),
132px -114px 0 -1px rgba(111, 205, 151, 0.12);
transform: rotate(-18deg);
}
.heroInner,
.content {
position: relative;
z-index: 1;
width: min(1180px, calc(100% - 48px));
margin: 0 auto;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26px 0;
border-bottom: 1px solid rgba(247, 245, 239, 0.13);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: #fff;
font-family: var(--body);
font-size: 1.28rem;
font-weight: 800;
letter-spacing: -0.04em;
}
.brandMark,
.sectionIcon,
.bullet {
display: inline-grid;
place-items: center;
color: #61c98c;
}
.brandMark { width: 27px; height: 27px; }
.brandMark svg { width: 100%; height: 100%; }
.navLinks {
display: flex;
align-items: center;
gap: 26px;
}
.navLinks a {
color: rgba(247, 245, 239, 0.76);
font-size: 0.86rem;
font-weight: 600;
transition: color 160ms ease;
}
.navLinks a:hover { color: #fff; }
.heroCopy {
max-width: 630px;
padding: 74px 0 88px;
}
.kicker {
margin-bottom: 17px;
color: #78d8a1;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.heroCopy h1 {
color: #fff;
font-family: var(--body);
font-size: clamp(3.4rem, 7vw, 6.6rem);
font-weight: 800;
letter-spacing: -0.075em;
line-height: 0.9;
}
.rule {
width: 58px;
height: 3px;
margin: 28px 0 23px;
border-radius: 4px;
background: #61c98c;
}
.heroCopy > p:last-child {
max-width: 530px;
color: rgba(247, 245, 239, 0.77);
font-size: 1.08rem;
line-height: 1.65;
}
.releases { padding: 72px 0 110px; }
.sectionHeading {
display: flex;
align-items: center;
gap: 13px;
margin-bottom: 28px;
}
.sectionIcon {
width: 42px;
height: 42px;
border: 1px solid #8acdae;
border-radius: 50%;
}
.sectionIcon svg { width: 22px; height: 22px; }
.sectionHeading h2 {
color: #12332e;
font-family: var(--body);
font-size: clamp(1.45rem, 2vw, 1.9rem);
font-weight: 750;
letter-spacing: -0.04em;
}
.headingLine {
height: 1px;
flex: 1;
margin-left: 18px;
background: #9ad1b5;
}
.releaseCard {
padding: clamp(28px, 5vw, 58px);
border: 1px solid #d0dbd3;
border-radius: 16px;
background: #fffefa;
box-shadow: 0 18px 55px rgba(14, 51, 42, 0.06);
}
.releaseList {
display: grid;
gap: 22px;
}
.releaseMeta,
.releaseFooter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.releaseMeta time {
color: #587069;
font-size: 0.88rem;
font-weight: 600;
}
.releaseMeta span {
padding: 7px 11px;
border: 1px solid #c5d6cd;
border-radius: 7px;
color: #276e51;
font-size: 0.69rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.releaseCard h3 {
max-width: 880px;
margin: 27px 0 16px;
color: #12332e;
font-family: var(--body);
font-size: clamp(1.75rem, 4vw, 3rem);
font-weight: 760;
letter-spacing: -0.055em;
line-height: 1.08;
}
.lede {
max-width: 820px;
color: #536762;
font-size: 1.05rem;
line-height: 1.7;
}
.keyPoints {
margin-top: 38px;
padding: 25px 0 27px;
border-top: 1px solid #d8e1da;
border-bottom: 1px solid #d8e1da;
}
.keyPoints h4 {
margin-bottom: 17px;
color: #2b7a58;
font-family: var(--body);
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.keyPoints ul {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 28px;
}
.keyPoints li {
display: flex;
align-items: flex-start;
gap: 9px;
color: #4a5f59;
font-size: 0.92rem;
line-height: 1.52;
}
.bullet { width: 16px; min-width: 16px; height: 16px; margin-top: 3px; }
.bullet svg { width: 100%; height: 100%; }
.releaseFooter { padding-top: 24px; }
.releaseFooter p {
color: #697b75;
font-size: 0.84rem;
}
.releaseFooter strong { color: #347c5b; }
.releaseLink {
display: inline-flex;
align-items: center;
gap: 10px;
color: #236f50;
font-size: 0.9rem;
font-weight: 750;
}
.releaseLink span { font-size: 1.2rem; transition: transform 160ms ease; }
.releaseLink:hover span { transform: translateX(4px); }
@media (max-width: 640px) {
.hero { min-height: 390px; }
.heroInner, .content { width: min(100% - 32px, 1180px); }
.nav { padding: 19px 0; }
.navLinks { gap: 14px; }
.navLinks a { font-size: 0.75rem; }
.navLinks a:first-child { display: none; }
.heroCopy { padding: 62px 0 68px; }
.releases { padding: 48px 0 72px; }
.headingLine { display: none; }
.releaseMeta, .releaseFooter { align-items: flex-start; flex-direction: column; }
.releaseMeta { gap: 12px; }
.keyPoints ul { grid-template-columns: 1fr; }
}

View File

@@ -24,6 +24,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/press`,
lastModified: new Date('2026-07-29'),
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/plant-identifier-app`,
lastModified: new Date('2026-07-10'),

View File

@@ -0,0 +1,55 @@
{
"host": "greenlenspro.com",
"generatedAt": "2026-07-27",
"reason": "Commits 50c0f33, 45a7704, 018b751. Title/Description/H1-Rewrites plus neue Content-Sections. Die 4 Problem-Pages ohne seoPages.ts-Diff sind enthalten, weil SeoCategoryPage.tsx, SeoSections.tsx, FAQ.tsx und CTA.tsx umgebaut wurden und damit jede SEO-Seite anders rendert. /de/* fehlt bewusst: die Route ist ein 301 auf die Root-Slugs.",
"groups": {
"home": [
"https://greenlenspro.com/",
"https://greenlenspro.com/en",
"https://greenlenspro.com/es"
],
"moneyPagesEn": [
"https://greenlenspro.com/plant-identifier-app",
"https://greenlenspro.com/plant-disease-identifier",
"https://greenlenspro.com/plant-care-app",
"https://greenlenspro.com/plant-health-app",
"https://greenlenspro.com/plant-scanner",
"https://greenlenspro.com/houseplant-identifier",
"https://greenlenspro.com/succulent-identifier",
"https://greenlenspro.com/flower-scanner",
"https://greenlenspro.com/identify-plant-photo",
"https://greenlenspro.com/best-plant-identification-app"
],
"moneyPagesDe": [
"https://greenlenspro.com/pflanzen-erkennen-app",
"https://greenlenspro.com/pflanzen-erkennen-kostenlos",
"https://greenlenspro.com/pflanzen-bestimmen",
"https://greenlenspro.com/zimmerpflanzen-bestimmen",
"https://greenlenspro.com/pflanzen-krankheiten-erkennen",
"https://greenlenspro.com/pflanzen-pflege-app",
"https://greenlenspro.com/giess-erinnerung-app",
"https://greenlenspro.com/blumen-scanner"
],
"problemPagesDe": [
"https://greenlenspro.com/braune-blattspitzen",
"https://greenlenspro.com/gelbe-blaetter-zimmerpflanze",
"https://greenlenspro.com/pflanze-haengt-nach-umtopfen",
"https://greenlenspro.com/wurzelfaeule-erkennen",
"https://greenlenspro.com/ueberwaessert-oder-zu-trocken",
"https://greenlenspro.com/trauermuecken-blumenerde"
],
"comparison": [
"https://greenlenspro.com/vs/picturethis",
"https://greenlenspro.com/vs/plantum",
"https://greenlenspro.com/vs/inaturalist",
"https://greenlenspro.com/vs/google-lens"
],
"spanish": [
"https://greenlenspro.com/es/identificador-de-plantas",
"https://greenlenspro.com/es/app-para-cuidar-plantas",
"https://greenlenspro.com/es/diagnosticar-enfermedades-plantas",
"https://greenlenspro.com/es/escaner-de-plantas",
"https://greenlenspro.com/es/comparar/google-lens"
]
}
}

View File

@@ -0,0 +1,329 @@
#!/usr/bin/env node
/**
* Reicht eine kuratierte URL-Liste bei IndexNow (Bing, Yandex, Seznam, Naver)
* und optional bei der Google Indexing API ein.
*
* Keine npm-Dependencies. Braucht Node 18+ (globales fetch).
*
* node scripts/submit-changed-urls.mjs --dry-run
* node scripts/submit-changed-urls.mjs
* node scripts/submit-changed-urls.mjs --google-only
* node scripts/submit-changed-urls.mjs --urls scripts/changed-urls-2026-07-27.json
*
* Konfiguration (.env oder Umgebung):
* INDEXNOW_KEY IndexNow-Key, muss als <key>.txt live erreichbar sein
* GOOGLE_SERVICE_ACCOUNT Pfad zur Service-Account-JSON (Default: ./service_account.json)
*
* Hinweis zur Google Indexing API: offiziell unterstuetzt Google damit nur
* JobPosting und BroadcastEvent. Fuer normale Seiten funktioniert es in der
* Praxis oft, ist aber nicht zugesichert. Das Tageslimit liegt bei 200 URLs.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
// ---------------------------------------------------------------- CLI + env
const argv = process.argv.slice(2);
const hasFlag = (name) => argv.includes(`--${name}`);
const getArg = (name, fallback) => {
const i = argv.indexOf(`--${name}`);
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
};
const DRY_RUN = hasFlag('dry-run');
const SKIP_PREFLIGHT = hasFlag('skip-preflight');
const GOOGLE_ONLY = hasFlag('google-only');
const INDEXNOW_ONLY = hasFlag('indexnow-only');
const DO_INDEXNOW = !GOOGLE_ONLY;
const DO_GOOGLE = !INDEXNOW_ONLY;
loadDotEnv(path.join(repoRoot, '.env'));
loadDotEnv(path.join(repoRoot, '.env.local'));
function loadDotEnv(file) {
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
if (!m) continue;
const key = m[1];
if (process.env[key] !== undefined) continue;
process.env[key] = m[2].replace(/^["']|["']$/g, '');
}
}
// ---------------------------------------------------------------- URL-Liste
const urlsFile = path.resolve(
repoRoot,
getArg('urls', 'scripts/changed-urls-2026-07-27.json')
);
if (!fs.existsSync(urlsFile)) {
fail(`URL-Datei nicht gefunden: ${urlsFile}`);
}
const config = JSON.parse(fs.readFileSync(urlsFile, 'utf8'));
const groups = config.groups ?? {};
const urls = [...new Set(Object.values(groups).flat())];
if (urls.length === 0) fail('Die URL-Liste ist leer.');
console.log(`\n Quelle ${path.relative(repoRoot, urlsFile)}`);
console.log(` Host ${config.host}`);
console.log(` URLs ${urls.length}`);
for (const [name, list] of Object.entries(groups)) {
console.log(` ${String(list.length).padStart(3)} ${name}`);
}
console.log(` Modus ${DRY_RUN ? 'DRY RUN (es wird nichts gesendet)' : 'LIVE'}`);
console.log(
` Kanaele ${[DO_INDEXNOW && 'IndexNow', DO_GOOGLE && 'Google Indexing API']
.filter(Boolean)
.join(' + ') || 'keine'}\n`
);
// alle URLs muessen zum konfigurierten Host gehoeren
const foreign = urls.filter((u) => new URL(u).host !== config.host);
if (foreign.length) {
fail(`Diese URLs passen nicht zu host="${config.host}":\n ${foreign.join('\n ')}`);
}
// ---------------------------------------------------------------- Preflight
if (!SKIP_PREFLIGHT) {
console.log(' Preflight: pruefe, ob jede URL live 200 liefert ...');
const bad = [];
for (const url of urls) {
const status = await statusOf(url);
if (status !== 200) bad.push(`${status} ${url}`);
}
if (bad.length) {
console.error('\n Diese URLs antworten nicht mit 200:');
for (const b of bad) console.error(` ${b}`);
fail(
'Abbruch. Eine URL einzureichen, die 404 oder 500 liefert, schadet mehr als sie nutzt.\n' +
' Deploy pruefen, dann erneut ausfuehren (oder --skip-preflight setzen).'
);
}
console.log(` Preflight OK: alle ${urls.length} URLs liefern 200.\n`);
}
// ---------------------------------------------------------------- IndexNow
if (DO_INDEXNOW) {
const key = process.env.INDEXNOW_KEY || detectKeyInPublicDir();
if (!key) {
fail(
'Kein IndexNow-Key gefunden.\n' +
' Entweder INDEXNOW_KEY in .env setzen, oder eine <key>.txt in public/\n' +
' ablegen, die genau den Key als Inhalt hat (bing.com/indexnow).'
);
}
if (!process.env.INDEXNOW_KEY) {
console.log(` IndexNow-Key aus public/ erkannt: ${key}`);
}
const keyLocation = `https://${config.host}/${key}.txt`;
if (!SKIP_PREFLIGHT) {
const res = await fetch(keyLocation).catch(() => null);
const body = res && res.ok ? (await res.text()).trim().replace(/^/, '') : null;
if (!res || !res.ok) {
fail(`IndexNow-Key-Datei nicht erreichbar: ${keyLocation}`);
}
if (body !== key) {
fail(
`IndexNow-Key-Datei enthaelt nicht den erwarteten Key.\n` +
` ${keyLocation}\n erwartet: ${key}\n gefunden: ${JSON.stringify(body)}`
);
}
console.log(` IndexNow-Key verifiziert: ${keyLocation}`);
}
const payload = {
host: config.host,
key,
keyLocation,
urlList: urls,
};
if (DRY_RUN) {
console.log(` [dry-run] POST https://api.indexnow.org/indexnow (${urls.length} URLs)\n`);
} else {
const res = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload),
});
// 200 = angenommen, 202 = angenommen, Key wird noch geprueft
if (res.status === 200 || res.status === 202) {
console.log(` IndexNow OK (${res.status}): ${urls.length} URLs uebermittelt.\n`);
} else {
console.error(` IndexNow fehlgeschlagen (${res.status}): ${await res.text()}\n`);
process.exitCode = 1;
}
}
}
// ------------------------------------------------------- Google Indexing API
if (DO_GOOGLE) {
const saPath = path.resolve(
repoRoot,
process.env.GOOGLE_SERVICE_ACCOUNT || 'service_account.json'
);
if (!fs.existsSync(saPath)) {
console.warn(
` Google Indexing API uebersprungen: ${path.relative(repoRoot, saPath)} nicht gefunden.\n` +
` Pfad ueber GOOGLE_SERVICE_ACCOUNT setzen oder --indexnow-only nutzen.\n`
);
} else if (urls.length > 200) {
fail(`Die Google Indexing API erlaubt 200 URLs pro Tag, die Liste hat ${urls.length}.`);
} else {
const sa = JSON.parse(fs.readFileSync(saPath, 'utf8'));
console.log(` Google Indexing API als ${sa.client_email}`);
if (DRY_RUN) {
console.log(` [dry-run] ${urls.length}x urlNotifications:publish (URL_UPDATED)\n`);
} else {
const token = await getAccessToken(sa);
let ok = 0;
const failures = [];
for (const url of urls) {
const res = await fetch(
'https://indexing.googleapis.com/v3/urlNotifications:publish',
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, type: 'URL_UPDATED' }),
}
);
if (res.ok) {
ok++;
console.log(` ok ${url}`);
} else {
const text = await res.text();
failures.push(`${res.status} ${url}\n ${text.slice(0, 200)}`);
console.log(` FEHL ${res.status} ${url}`);
}
await sleep(120); // bleibt unter dem Minutenlimit
}
console.log(`\n Google: ${ok}/${urls.length} akzeptiert.`);
if (failures.length) {
console.error('\n Fehlgeschlagen:');
for (const f of failures) console.error(` ${f}`);
process.exitCode = 1;
}
console.log();
}
}
}
console.log(' Fertig.\n');
if (!DRY_RUN) {
console.log(' Denk dran: Google Search Console hat keine API fuer "Indexierung');
console.log(' beantragen". Die wichtigsten Seiten dort weiterhin manuell anstossen.\n');
}
// ---------------------------------------------------------------- Helpers
/**
* Sucht in public/ nach einer <key>.txt, deren Inhalt exakt dem Dateinamen
* entspricht. Genau so verlangt IndexNow die Key-Datei.
*/
function detectKeyInPublicDir() {
const publicDir = path.join(repoRoot, 'public');
if (!fs.existsSync(publicDir)) return null;
const candidates = [];
for (const file of fs.readdirSync(publicDir)) {
if (!file.endsWith('.txt')) continue;
const name = file.slice(0, -4);
if (!/^[a-f0-9]{8,128}$/i.test(name)) continue;
let content;
try {
content = fs.readFileSync(path.join(publicDir, file), 'utf8');
} catch {
continue;
}
// BOM und Null-Bytes aus Windows-Editoren wegräumen
const normalised = content.replace(//g, '').replace(/^/, '').trim();
if (normalised === name) candidates.push(name);
}
if (candidates.length > 1) {
console.warn(
` Mehrere gueltige IndexNow-Keys in public/: ${candidates.join(', ')}\n` +
` Es wird ${candidates[0]} genutzt. Fuer Eindeutigkeit INDEXNOW_KEY setzen.`
);
}
return candidates[0] ?? null;
}
async function statusOf(url) {
try {
let res = await fetch(url, { method: 'HEAD', redirect: 'follow' });
// manche Hosts mögen HEAD nicht
if (res.status === 405 || res.status === 501) {
res = await fetch(url, { method: 'GET', redirect: 'follow' });
}
return res.status;
} catch (err) {
return `ERR ${err.message}`;
}
}
/** OAuth2 Access Token per signiertem JWT, ohne googleapis-Dependency. */
async function getAccessToken(sa) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', typ: 'JWT' };
const claim = {
iss: sa.client_email,
scope: 'https://www.googleapis.com/auth/indexing',
aud: 'https://oauth2.googleapis.com/token',
exp: now + 3600,
iat: now,
};
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const unsigned = `${b64(header)}.${b64(claim)}`;
const signature = crypto
.createSign('RSA-SHA256')
.update(unsigned)
.sign(sa.private_key)
.toString('base64url');
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: `${unsigned}.${signature}`,
}),
});
const json = await res.json();
if (!res.ok) fail(`Google-Auth fehlgeschlagen: ${JSON.stringify(json)}`);
return json.access_token;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function fail(msg) {
console.error(`\n ${msg}\n`);
process.exit(1);
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "greenlens",
"version": "2.4.0",
"version": "2.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "greenlens",
"version": "2.4.0",
"version": "2.4.7",
"hasInstallScript": true,
"dependencies": {
"@expo/vector-icons": "^15.0.3",

View File

@@ -1,6 +1,6 @@
{
"name": "greenlens",
"version": "2.4.2",
"version": "2.4.7",
"main": "expo-router/entry",
"private": true,
"scripts": {