From c9e776ae985eaeb69e0599c9f7209ebf8af53aa0 Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Thu, 30 Jul 2026 10:03:37 +0200 Subject: [PATCH] Press --- .gitignore | 19 +- app.json | 2 +- app/auth/login.tsx | 7 +- app/auth/signup.tsx | 10 +- app/onboarding.tsx | 275 +++++++++------ app/onboarding/customize.tsx | 69 ++-- app/onboarding/health-check.tsx | 33 +- app/onboarding/personalizing.tsx | 20 +- app/onboarding/slides.tsx | 112 ++++-- app/onboarding/source.tsx | 52 ++- app/profile/billing.tsx | 19 +- components/OnboardingQuestion.tsx | 27 +- greenlns-landing/app/press/page.tsx | 150 ++++++++ greenlns-landing/app/press/press.module.css | 288 +++++++++++++++ greenlns-landing/app/sitemap.ts | 6 + .../scripts/changed-urls-2026-07-27.json | 55 +++ .../scripts/submit-changed-urls.mjs | 329 ++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 19 files changed, 1256 insertions(+), 223 deletions(-) create mode 100644 greenlns-landing/app/press/page.tsx create mode 100644 greenlns-landing/app/press/press.module.css create mode 100644 greenlns-landing/scripts/changed-urls-2026-07-27.json create mode 100644 greenlns-landing/scripts/submit-changed-urls.mjs diff --git a/.gitignore b/.gitignore index 3ca1270..5c042ee 100644 --- a/.gitignore +++ b/.gitignore @@ -29,10 +29,21 @@ server/data/*.sqlite-* # Expo .expo/ -# Generated social media assets -social_out/ - -# Claude / Agents (symlinks incompatible with EAS Build on Windows) +# 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/ .env diff --git a/app.json b/app.json index aa66e15..9f8acea 100644 --- a/app.json +++ b/app.json @@ -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", diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 611f231..23cb7f1 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -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', diff --git a/app/auth/signup.tsx b/app/auth/signup.tsx index a38ab68..ea746c9 100644 --- a/app/auth/signup.tsx +++ b/app/auth/signup.tsx @@ -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', diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 3424df6..acb7837 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -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(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" /> - - - - - - - GreenLens - - - {/* Rating und Anzahl immer gemeinsam: 5,0 aus 6 Bewertungen ist - ehrlich und ueberpruefbar - eine nackte Sternezahl waere es nicht. */} - - - {copy.ratingValue} - · {copy.ratingCount} - - - - {testimonial ? ( - - - „{testimonial.quote}" - - - - {testimonial.author} - {testimonial.isTranslated ? ` · ${translatedNote(language)}` : ''} + + + + + + + + GreenLens + + + {/* Rating und Anzahl immer gemeinsam: 5,0 aus 6 Bewertungen ist + ehrlich und ueberpruefbar - eine nackte Sternezahl waere es nicht. */} + + + + {copy.ratingValue} + + + · {copy.ratingCount} - - {Array.from({ length: testimonial.stars }).map((_, i) => ( - - ))} - - ) : ( - /* Kein belegtes Zitat vorhanden → Argument statt erfundenem Social Proof. */ - - {proofPoints.map((point) => ( - - - {point} + + {testimonial ? ( + + + „{testimonial.quote}" + + + + {testimonial.author} + {testimonial.isTranslated ? ` · ${translatedNote(language)}` : ''} + + + {Array.from({ length: testimonial.stars }).map((_, i) => ( + + ))} + - ))} - - )} - - - - - - - - {/* 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. */} - - {copy.headline} - {copy.subline} + + ) : ( + /* Kein belegtes Zitat vorhanden → Argument statt erfundenem Social Proof. */ + + {proofPoints.map((point) => ( + + + {point} + + ))} + + )} - - - - { - posthog.capture('onboarding_started', { welcome_variant: variant ?? 'unassigned' }); - router.push('/onboarding/slides'); - }} - activeOpacity={0.86} - > - {copy.cta} - - - router.push('/auth/login')} style={styles.loginLink}> - {copy.login} - - - { - posthog.capture('onboarding_demo_scan_started', { - welcome_variant: variant ?? 'unassigned', - }); - router.push('/scanner'); - }} - style={styles.demoLink} - > - - {copy.demoScan} - - - {copy.legal} - + + + + + {/* 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. */} + + {/* maxFontSizeMultiplier: groessere Systemschrift darf die Headline + wachsen lassen, aber nicht die CTAs aus dem Screen schieben. */} + + {copy.headline} + + {copy.subline} + + + + + { + posthog.capture('onboarding_started', { welcome_variant: variant ?? 'unassigned' }); + router.push('/onboarding/slides'); + }} + activeOpacity={0.86} + > + {copy.cta} + + + router.push('/auth/login')} style={styles.loginLink}> + {copy.login} + + + { + posthog.capture('onboarding_demo_scan_started', { + welcome_variant: variant ?? 'unassigned', + }); + router.push('/scanner'); + }} + style={styles.demoLink} + > + + {copy.demoScan} + + + {copy.legal} + + + ); } @@ -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', diff --git a/app/onboarding/customize.tsx b/app/onboarding/customize.tsx index 22dd693..ea8a37f 100644 --- a/app/onboarding/customize.tsx +++ b/app/onboarding/customize.tsx @@ -55,21 +55,31 @@ export default function CustomizeOnboardingScreen() { - - - - - - {t.onboardingChecklistIntro} - {t.customizeOnboardingTitle} - {t.customizeOnboardingSubtitle} + + + + + + + + {t.onboardingChecklistIntro} + + + {t.customizeOnboardingTitle} + + + {t.customizeOnboardingSubtitle} + + - - - {t.customizeOnboardingPreview} - {t.onboardingTagline} + {t.customizeOnboardingPreview} + {t.onboardingTagline} {appearanceMode} @@ -84,7 +94,7 @@ export default function CustomizeOnboardingScreen() { - {t.appearanceMode} + {t.appearanceMode} {(['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)} > - + {label} @@ -106,7 +116,7 @@ export default function CustomizeOnboardingScreen() { - {t.colorPalette} + {t.colorPalette} {(['forest', 'ocean', 'sunset', 'mono'] as ColorPalette[]).map((palette) => { const isActive = colorPalette === palette; @@ -127,7 +137,7 @@ export default function CustomizeOnboardingScreen() { onPress={() => setColorPalette(palette)} > - {label} + {label} ); })} @@ -135,7 +145,7 @@ export default function CustomizeOnboardingScreen() { - {t.language} + {t.language} {(['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)} > - + {label} @@ -162,10 +175,10 @@ export default function CustomizeOnboardingScreen() { style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]} onPress={skipCustomization} > - {t.customizeOnboardingSkip} + {t.customizeOnboardingSkip} - {t.customizeOnboardingContinue} + {t.customizeOnboardingContinue} @@ -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', }, }); diff --git a/app/onboarding/health-check.tsx b/app/onboarding/health-check.tsx index 0bccd68..da737d4 100644 --- a/app/onboarding/health-check.tsx +++ b/app/onboarding/health-check.tsx @@ -126,12 +126,18 @@ export default function HealthCheckOnboardingScreen() { - - {copy.title} - {copy.subtitle} - + {/* Header liegt im Scrollbereich: bei grosser Systemschrift wuchs er + sonst nach unten und hat den Scrollbereich auf Null gequetscht. */} + + + {copy.title} + {copy.subtitle} + - {/* Worked Example statt Wegbeschreibung: ein echtes Ergebnis schlaegt jede Behauptung ueber die Qualitaet der Ergebnisse. */} @@ -212,10 +218,10 @@ export default function HealthCheckOnboardingScreen() { style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]} onPress={() => finish(true)} > - {copy.skip} + {copy.skip} finish(false)}> - {copy.cta} + {copy.cta} @@ -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' }, }); diff --git a/app/onboarding/personalizing.tsx b/app/onboarding/personalizing.tsx index 0652118..acb9ecb 100644 --- a/app/onboarding/personalizing.tsx +++ b/app/onboarding/personalizing.tsx @@ -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 ( - {percent}% + {/* Scrollbar, damit die Zusammenfassungs-Karte auf kleinen Geraeten und + bei grosser Systemschrift nicht unten wegfaellt. */} + + {percent}% - {copy.status} + {copy.status} {copy.steps.map((label, index) => { @@ -220,7 +227,7 @@ export default function OnboardingPersonalizingScreen() { size={24} color={done ? colors.primary : colors.border} /> - {label} + {label} ); })} @@ -239,12 +246,15 @@ export default function OnboardingPersonalizingScreen() { )} + ); } 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 }, diff --git a/app/onboarding/slides.tsx b/app/onboarding/slides.tsx index cbbf883..dcf4770 100644 --- a/app/onboarding/slides.tsx +++ b/app/onboarding/slides.tsx @@ -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; +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 - {copy.causeTitle} + + {copy.causeTitle} + - + {copy.causePrimary} · {copy.causePrimaryLevel} - + {copy.causeSecondary} · {copy.causeSecondaryLevel} @@ -141,8 +144,12 @@ function CauseRankingOverlay({ copy, colors }: { copy: SlidesCopy; colors: Color positionierte Overlays ueberlappen sich, sobald Uebersetzungen die Karte wachsen lassen. */} - {copy.checkLabel} - {copy.checkItems} + + {copy.checkLabel} + + + {copy.checkItems} + @@ -157,15 +164,21 @@ function PlanOverlay({ copy }: { copy: SlidesCopy }) { - {copy.planLabel} + + {copy.planLabel} + - {copy.planNow} + + {copy.planNow} + - {copy.planFollowUp} + + {copy.planFollowUp} + ); @@ -179,23 +192,35 @@ function UncertaintyOverlay({ copy }: { copy: SlidesCopy }) { - {copy.uncertainTitle} + + {copy.uncertainTitle} + {copy.uncertainItems.map((item) => ( - {item} + + {item} + ))} - {copy.uncertainNote} + + {copy.uncertainNote} + ); } export default function OnboardingSlidesScreen() { const router = useRouter(); + const scrollRef = useRef(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 ( - - + + } {page === 2 && } - - {slide.title} - {slide.body} + + {/* maxFontSizeMultiplier: die Systemschriftgroesse darf die Headline + vergroessern, aber nicht so weit, dass sie das halbe Sheet frisst. */} + + {slide.title} + + + {slide.body} + {copy.slides.map((_, index) => ( - {copy.continueLabel} + + {copy.continueLabel} + - + ); } @@ -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, diff --git a/app/onboarding/source.tsx b/app/onboarding/source.tsx index b7ace94..55bac79 100644 --- a/app/onboarding/source.tsx +++ b/app/onboarding/source.tsx @@ -7,6 +7,7 @@ import { Image, KeyboardAvoidingView, Platform, + ScrollView, StyleSheet, Text, TouchableOpacity, @@ -312,6 +313,7 @@ export default function OnboardingChatScreen() { }); const flatListRef = useRef(null); + const inputScrollRef = useRef(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 ( - {copy.stepSituation} + {copy.stepSituation} {([ { id: 'acute', label: copy.situations.acute, icon: 'alert-circle-outline' as const }, @@ -519,7 +525,7 @@ export default function OnboardingChatScreen() { selectOption(opt.id, opt.label)}> - {opt.label} + {opt.label} @@ -533,7 +539,7 @@ export default function OnboardingChatScreen() { if (currentStep === 1) { return ( - {copy.stepSymptom} + {copy.stepSymptom} {([ { id: 'yellow', label: copy.symptoms.yellow, icon: 'leaf-outline' as const }, @@ -545,7 +551,7 @@ export default function OnboardingChatScreen() { ]).map((opt) => ( selectOption(opt.id, opt.label)}> - {opt.label} + {opt.label} ))} @@ -557,7 +563,7 @@ export default function OnboardingChatScreen() { if (currentStep === 2) { return ( - {copy.stepLight} + {copy.stepLight} {([ { 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) => ( selectOption(opt.id, opt.label)}> - {opt.label} - {opt.sub} + {opt.label} + {opt.sub} @@ -582,7 +588,7 @@ export default function OnboardingChatScreen() { if (currentStep === 3) { return ( - {copy.stepExperience} + {copy.stepExperience} {([ { id: 'beginner', label: copy.experiences.beginner, icon: 'leaf-outline' as const }, @@ -592,7 +598,7 @@ export default function OnboardingChatScreen() { selectOption(opt.id, opt.label)}> - {opt.label} + {opt.label} @@ -608,7 +614,7 @@ export default function OnboardingChatScreen() { if (currentStep === 4) { return ( - {copy.stepSource} + {copy.stepSource} {([ { id: 'app_store', label: copy.sources.app_store, icon: 'logo-apple' as const }, @@ -623,7 +629,7 @@ export default function OnboardingChatScreen() { ]).map((opt) => ( selectOption(opt.id, opt.label)}> - {opt.label} + {opt.label} ))} @@ -636,7 +642,7 @@ export default function OnboardingChatScreen() { return ( - {copy.continue} + {copy.continue} ); @@ -734,9 +740,18 @@ export default function OnboardingChatScreen() { /> - {/* 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. */} - {renderOptions()} + + {renderOptions()} + @@ -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', diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 2347750..3860e46 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -756,7 +756,9 @@ export default function BillingScreen() { return ( - + {/* 'bottom' mit drin: sonst liegen Kuendigungshinweis und + Privacy/Terms auf dem Home-Indicator. */} + @@ -773,7 +775,7 @@ export default function BillingScreen() { showsVerticalScrollIndicator={false} > {copy.paywallEyebrow.toUpperCase()} - {copy.paywallHeadline} + {copy.paywallHeadline} {copy.paywallSub} {/* Feature-Bullets ueber der Angebotskarte: kurz, drei @@ -857,7 +859,10 @@ export default function BillingScreen() { ) : ( <> - + {trialEnabled ? copy.ctaTrial : copy.ctaMonthly} @@ -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: { diff --git a/components/OnboardingQuestion.tsx b/components/OnboardingQuestion.tsx index 40a27c1..181d4d6 100644 --- a/components/OnboardingQuestion.tsx +++ b/components/OnboardingQuestion.tsx @@ -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({ - {title} - {subtitle} - + {/* Scrollbar statt fixem Block: bei grosser Systemschriftgroesse wuchsen + Titel und Karten frueher ueber den Footer und haben den CTA verdeckt. */} + + {title} + {subtitle} {options.map((option) => { const active = selectedId === option.id; return ( @@ -59,13 +65,13 @@ export function OnboardingQuestion({ > {option.emoji} - {option.label} - {option.subtitle ? {option.subtitle} : null} + {option.label} + {option.subtitle ? {option.subtitle} : null} ); })} - + {skipLabel && onSkip ? ( @@ -78,7 +84,7 @@ export function OnboardingQuestion({ activeOpacity={0.86} style={[styles.cta, { backgroundColor: selectedId ? colors.primary : colors.surfaceMuted }]} > - {continueLabel} + {continueLabel} @@ -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' }, }); diff --git a/greenlns-landing/app/press/page.tsx b/greenlns-landing/app/press/page.tsx new file mode 100644 index 0000000..6734859 --- /dev/null +++ b/greenlns-landing/app/press/page.tsx @@ -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 ( +
+
+
+ +
+
+
+ +

Latest releases

+
+ +
+ + + +
+
+
+
+ ) +} + +function ReleaseCard({ + title, + lede, + points, + href, + date = 'July 29, 2026', +}: { + title: string + lede: string + points: string[] + href: string + date?: string +}) { + return ( +
+
+ + Press release +
+ +

{title}

+

{lede}

+ +
+

What the guide covers

+
    + {points.map((point) =>
  • {point}
  • )} +
+
+ +
+

Source: GreenLens Press Office

+ + Read full release + +
+
+ ) +} + +function LeafIcon() { + return ( + + ) +} + +function LeafBullet() { + return +} diff --git a/greenlns-landing/app/press/press.module.css b/greenlns-landing/app/press/press.module.css new file mode 100644 index 0000000..d8f1e93 --- /dev/null +++ b/greenlns-landing/app/press/press.module.css @@ -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; } +} diff --git a/greenlns-landing/app/sitemap.ts b/greenlns-landing/app/sitemap.ts index 981eb85..fab56f5 100644 --- a/greenlns-landing/app/sitemap.ts +++ b/greenlns-landing/app/sitemap.ts @@ -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'), diff --git a/greenlns-landing/scripts/changed-urls-2026-07-27.json b/greenlns-landing/scripts/changed-urls-2026-07-27.json new file mode 100644 index 0000000..17771d8 --- /dev/null +++ b/greenlns-landing/scripts/changed-urls-2026-07-27.json @@ -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" + ] + } +} diff --git a/greenlns-landing/scripts/submit-changed-urls.mjs b/greenlns-landing/scripts/submit-changed-urls.mjs new file mode 100644 index 0000000..c793f83 --- /dev/null +++ b/greenlns-landing/scripts/submit-changed-urls.mjs @@ -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 .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 .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 .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); +} diff --git a/package-lock.json b/package-lock.json index 66acacf..d8603a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c45670e..5ea14a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "greenlens", - "version": "2.4.2", + "version": "2.4.7", "main": "expo-router/entry", "private": true, "scripts": {