App chrash + seo

This commit is contained in:
2026-05-10 22:37:01 +02:00
parent 9386ae1be7
commit 2658c37453
28 changed files with 13232 additions and 519 deletions

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "GreenLens", "name": "GreenLens",
"slug": "greenlens", "slug": "greenlens",
"version": "2.2.4", "version": "2.2.6",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@@ -19,7 +19,7 @@
"supportsTablet": true, "supportsTablet": true,
"usesAppleSignIn": true, "usesAppleSignIn": true,
"bundleIdentifier": "com.greenlens.app", "bundleIdentifier": "com.greenlens.app",
"buildNumber": "38", "buildNumber": "42",
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "GreenLens needs camera access to identify plants.", "NSCameraUsageDescription": "GreenLens needs camera access to identify plants.",
"NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.", "NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.",

View File

@@ -15,7 +15,7 @@ import { useRouter } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
@@ -233,7 +233,7 @@ export default function HomeScreen() {
const { layouts, registerLayout, startTour } = useCoachMarks(); const { layouts, registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(null); const fabRef = useRef<View>(null);
const tourStartRequestedRef = useRef(false); const tourStartRequestedRef = useRef(false);
const posthog = usePostHog(); const posthog = useSafeAnalytics();
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {

View File

@@ -1,12 +1,9 @@
import { useEffect, useRef, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Animated, AppState, Easing, Image, StyleSheet, Text, View } from 'react-native'; import { Text, View } from 'react-native';
import { Redirect, Stack, usePathname, useRouter } from 'expo-router'; import { Redirect, Stack, usePathname, useRouter } from 'expo-router';
import { useShareIntent } from 'expo-share-intent'; import { useShareIntent } from 'expo-share-intent';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
import { AppProvider, useApp } from '../context/AppContext'; import { AppProvider, useApp } from '../context/AppContext';
import { CoachMarksProvider } from '../context/CoachMarksContext'; import { CoachMarksProvider } from '../context/CoachMarksContext';
import { CoachMarksOverlay } from '../components/CoachMarksOverlay'; import { CoachMarksOverlay } from '../components/CoachMarksOverlay';
@@ -14,14 +11,57 @@ import { useColors } from '../constants/Colors';
import { initDatabase, AppMetaDb } from '../services/database'; import { initDatabase, AppMetaDb } from '../services/database';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import * as ExpoLinking from 'expo-linking';
import { AuthService } from '../services/authService'; import { AuthService } from '../services/authService';
import { PostHogProvider, usePostHog } from 'posthog-react-native'; import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
// Prevent the splash screen from auto-hiding before asset loading is complete. // Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync().catch(() => { }); SplashScreen.preventAutoHideAsync().catch(() => { });
const POSTHOG_API_KEY = process.env.EXPO_PUBLIC_POSTHOG_API_KEY || 'phc_FX6HRgx9NSpS5moxjMF6xyc37yMwjoeu6TbWUqNNKlk';
const SECURE_INSTALL_MARKER = 'greenlens_install_v1'; const SECURE_INSTALL_MARKER = 'greenlens_install_v1';
const isShareIntentUrl = (url: string | null | undefined) => Boolean(url?.includes('://dataUrl='));
const toStartupErrorMessage = (error: unknown): string => {
if (!error) return 'Unknown startup error';
if (error instanceof Error) return error.message;
return String(error);
};
const StartupFallback = ({ details }: { details?: string | null }) => (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, backgroundColor: '#111813' }}>
<Text style={{ color: '#fff', fontSize: 18, fontWeight: '700', marginBottom: 8, textAlign: 'center' }}>
GreenLens could not start.
</Text>
<Text style={{ color: '#D6DED7', fontSize: 14, lineHeight: 20, textAlign: 'center' }}>
Please send this startup error to support.
</Text>
{details ? (
<Text style={{ color: '#FFB4A8', fontSize: 12, lineHeight: 17, marginTop: 16, textAlign: 'center' }}>
{details}
</Text>
) : null}
</View>
);
class RootErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean; errorMessage: string | null }> {
state = { hasError: false, errorMessage: null };
static getDerivedStateFromError(error: unknown) {
return { hasError: true, errorMessage: toStartupErrorMessage(error) };
}
componentDidCatch(error: unknown) {
console.error('[RootErrorBoundary]', error);
}
render() {
if (this.state.hasError) {
return <StartupFallback details={this.state.errorMessage} />;
}
return this.props.children;
}
}
const ensureInstallConsistency = async (): Promise<boolean> => { const ensureInstallConsistency = async (): Promise<boolean> => {
try { try {
@@ -51,9 +91,6 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
} }
}; };
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() { function RootLayoutInner() {
const { const {
isDarkMode, isDarkMode,
@@ -65,88 +102,37 @@ function RootLayoutInner() {
isInitializing, isInitializing,
isLoadingPlants, isLoadingPlants,
isLoadingBilling, isLoadingBilling,
syncRevenueCatState,
} = useApp(); } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent(); const [shareIntentEnabled, setShareIntentEnabled] = useState(false);
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent({ disabled: !shareIntentEnabled });
const [installCheckDone, setInstallCheckDone] = useState(false); const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false); const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
const posthog = usePostHog();
useEffect(() => { useEffect(() => {
// RevenueCat requires native store access — not available in Expo Go let mounted = true;
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo) {
console.log('[RevenueCat] Skipping configure: running in Expo Go');
return;
}
Purchases.setLogLevel(LOG_LEVEL.VERBOSE); ExpoLinking.getInitialURL()
const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR'; .then((url) => {
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder'; if (mounted && isShareIntentUrl(url)) {
if (Platform.OS === 'ios') { setShareIntentEnabled(true);
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
} }
setRevenueCatReady(true); })
}, []); .catch(() => {});
useEffect(() => { const subscription = ExpoLinking.addEventListener('url', ({ url }) => {
const isExpoGo = Constants.appOwnership === 'expo'; if (isShareIntentUrl(url)) {
if (isExpoGo || !revenueCatReady) { setShareIntentEnabled(true);
return;
} }
});
let cancelled = false;
(async () => {
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
return () => { return () => {
cancelled = true; mounted = false;
subscription.remove();
}; };
}, [revenueCatReady, session?.serverUserId, syncRevenueCatState]); }, []);
useEffect(() => {
if (session?.serverUserId) {
posthog.identify(session.serverUserId, {
email: session.email,
name: session.name,
});
} else if (session === null) {
posthog.reset();
}
}, [session, posthog]);
useEffect(() => {
posthog.capture('screen_viewed', { screen: pathname });
}, [pathname, posthog]);
useEffect(() => {
posthog.capture('app_opened');
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
posthog.capture('app_opened');
}
});
return () => subscription.remove();
}, [posthog]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -288,18 +274,24 @@ function RootLayoutInner() {
} }
export default function RootLayout() { export default function RootLayout() {
let dbInitError: string | null = null;
try {
initDatabase(); initDatabase();
} catch (e) {
dbInitError = String(e);
}
if (dbInitError) {
return <StartupFallback details={`Database init failed: ${dbInitError}`} />;
}
return ( return (
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{ <RootErrorBoundary>
host: 'https://us.i.posthog.com',
enableSessionReplay: false,
}}>
<AppProvider> <AppProvider>
<CoachMarksProvider> <CoachMarksProvider>
<RootLayoutInner /> <RootLayoutInner />
</CoachMarksProvider> </CoachMarksProvider>
</AppProvider> </AppProvider>
</PostHogProvider> </RootErrorBoundary>
); );
} }

View File

@@ -19,7 +19,7 @@ import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
const ONBOARDING_AUTH_BACKGROUND = { const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3', light: '#fbfaf3',
@@ -29,7 +29,7 @@ const ONBOARDING_AUTH_BACKGROUND = {
export default function LoginScreen() { export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const screenBackground = isDarkMode const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark ? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light; : ONBOARDING_AUTH_BACKGROUND.light;

View File

@@ -19,7 +19,7 @@ import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
const ONBOARDING_AUTH_BACKGROUND = { const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3', light: '#fbfaf3',
@@ -29,7 +29,7 @@ const ONBOARDING_AUTH_BACKGROUND = {
export default function SignupScreen() { export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const pendingPlant = getPendingPlant(); const pendingPlant = getPendingPlant();
const screenBackground = isDarkMode const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark ? ONBOARDING_AUTH_BACKGROUND.dark

View File

@@ -3,7 +3,7 @@ import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-nati
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
@@ -19,7 +19,7 @@ const PALETTE_SWATCHES: Record<ColorPalette, string[]> = {
export default function CustomizeOnboardingScreen() { export default function CustomizeOnboardingScreen() {
const router = useRouter(); const router = useRouter();
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const { const {
session, session,
isDarkMode, isDarkMode,

View File

@@ -3,7 +3,7 @@ import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
@@ -58,7 +58,7 @@ const getExperienceScreenCopy = (language: 'de' | 'en' | 'es') => {
export default function OnboardingExperienceScreen() { export default function OnboardingExperienceScreen() {
const router = useRouter(); const router = useRouter();
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, language, t } = useApp(); const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;

View File

@@ -3,7 +3,7 @@ import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
@@ -62,7 +62,7 @@ const getGoalScreenCopy = (language: 'de' | 'en' | 'es') => {
export default function OnboardingGoalScreen() { export default function OnboardingGoalScreen() {
const router = useRouter(); const router = useRouter();
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, language, t } = useApp(); const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;

View File

@@ -3,7 +3,7 @@ import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View }
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
@@ -75,7 +75,7 @@ const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => {
export default function HealthCheckOnboardingScreen() { export default function HealthCheckOnboardingScreen() {
const router = useRouter(); const router = useRouter();
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const { isDarkMode, colorPalette, language, billingSummary } = useApp(); const { isDarkMode, colorPalette, language, billingSummary } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;

View File

@@ -3,7 +3,7 @@ import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
@@ -79,7 +79,7 @@ const getSourceOnboardingCopy = (language: 'de' | 'en' | 'es') => {
export default function OnboardingSourceScreen() { export default function OnboardingSourceScreen() {
const router = useRouter(); const router = useRouter();
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, language, t } = useApp(); const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;

View File

@@ -1,11 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, useWindowDimensions } from 'react-native'; import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, useWindowDimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import Purchases, { import Purchases, {
LOG_LEVEL,
PACKAGE_TYPE, PACKAGE_TYPE,
PRODUCT_CATEGORY, PRODUCT_CATEGORY,
PurchasesOffering, PurchasesOffering,
@@ -13,7 +14,7 @@ import Purchases, {
PurchasesStoreProduct, PurchasesStoreProduct,
} from 'react-native-purchases'; } from 'react-native-purchases';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../../services/analytics';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types'; import { Language } from '../../types';
@@ -82,6 +83,24 @@ const summarizeOfferingPackages = (offering: PurchasesOffering | null) => {
}; };
}; };
let revenueCatConfigured = false;
const ensureRevenueCatConfigured = () => {
if (revenueCatConfigured || Constants.appOwnership === 'expo') {
return;
}
Purchases.setLogLevel(LOG_LEVEL.WARN);
const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR';
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder';
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
}
revenueCatConfigured = true;
};
const getBillingCopy = (language: Language) => { const getBillingCopy = (language: Language) => {
if (language === 'de') { if (language === 'de') {
return { return {
@@ -319,7 +338,7 @@ export default function BillingScreen() {
const router = useRouter(); const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const copy = getBillingCopy(language); const copy = getBillingCopy(language);
const isExpoGo = Constants.appOwnership === 'expo'; const isExpoGo = Constants.appOwnership === 'expo';
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions();
@@ -328,6 +347,7 @@ export default function BillingScreen() {
const [subModalVisible, setSubModalVisible] = useState(false); const [subModalVisible, setSubModalVisible] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [storeReady, setStoreReady] = useState(isExpoGo); const [storeReady, setStoreReady] = useState(isExpoGo);
const [storeError, setStoreError] = useState<string | null>(null);
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({}); const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({}); const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('yearly'); const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('yearly');
@@ -349,6 +369,7 @@ export default function BillingScreen() {
} }
try { try {
ensureRevenueCatConfigured();
const [offerings, topups] = await Promise.all([ const [offerings, topups] = await Promise.all([
Purchases.getOfferings(), Purchases.getOfferings(),
Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION),
@@ -369,8 +390,12 @@ export default function BillingScreen() {
topup_medium: topups.find((product) => product.identifier === 'topup_medium'), topup_medium: topups.find((product) => product.identifier === 'topup_medium'),
topup_large: topups.find((product) => product.identifier === 'topup_large'), topup_large: topups.find((product) => product.identifier === 'topup_large'),
}); });
setStoreError(null);
} catch (error) { } catch (error) {
console.warn('Failed to load RevenueCat products', error); console.warn('Failed to load RevenueCat products', error);
if (!cancelled) {
setStoreError('Purchases are temporarily unavailable. Please try again later.');
}
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setStoreReady(true); setStoreReady(true);
@@ -386,12 +411,14 @@ export default function BillingScreen() {
}, [isExpoGo]); }, [isExpoGo]);
useEffect(() => { useEffect(() => {
posthog.capture('paywall_viewed', { plan_id: planId }); try { posthog.capture('paywall_viewed', { plan_id: planId }); } catch {}
if (showPaywallPlans) { if (showPaywallPlans) {
try {
posthog.capture('hard_paywall_viewed', { posthog.capture('hard_paywall_viewed', {
plan_id: planId, plan_id: planId,
authenticated: Boolean(session), authenticated: Boolean(session),
}); });
} catch {}
} }
}, [posthog, planId, session?.serverUserId, showPaywallPlans]); }, [posthog, planId, session?.serverUserId, showPaywallPlans]);
@@ -467,6 +494,11 @@ export default function BillingScreen() {
return; return;
} }
if (!isExpoGo && storeError) {
Alert.alert('Purchases unavailable', storeError);
return;
}
setIsUpdating(true); setIsUpdating(true);
posthog.capture('purchase_initiated', { product_id: productId }); posthog.capture('purchase_initiated', { product_id: productId });
try { try {
@@ -483,6 +515,7 @@ export default function BillingScreen() {
await completeExpoGoSimulation(productId); await completeExpoGoSimulation(productId);
return; return;
} else { } else {
ensureRevenueCatConfigured();
if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (planId === 'pro') { if (planId === 'pro') {
await openAppleSubscriptions(); await openAppleSubscriptions();
@@ -553,6 +586,7 @@ export default function BillingScreen() {
setIsUpdating(true); setIsUpdating(true);
try { try {
if (!isExpoGo) { if (!isExpoGo) {
ensureRevenueCatConfigured();
const customerInfo = await Purchases.restorePurchases(); const customerInfo = await Purchases.restorePurchases();
await syncRevenueCatState(customerInfo as any, 'restore'); await syncRevenueCatState(customerInfo as any, 'restore');
} }
@@ -681,10 +715,10 @@ export default function BillingScreen() {
style={[ style={[
styles.hardPaywallCta, styles.hardPaywallCta,
compactPaywall && styles.hardPaywallCtaCompact, compactPaywall && styles.hardPaywallCtaCompact,
(!storeReady || isUpdating) && styles.disabledPlanCard, (!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]} ]}
onPress={() => handlePurchase(selectedProductId)} onPress={() => handlePurchase(selectedProductId)}
disabled={isUpdating || !storeReady} disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.9} activeOpacity={0.9}
> >
{isUpdating || !storeReady ? ( {isUpdating || !storeReady ? (
@@ -699,6 +733,9 @@ export default function BillingScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
{storeError ? (
<Text style={styles.hardPaywallFooter}>{storeError}</Text>
) : null}
<Text style={styles.hardPaywallFooter}>{paywallFooterLabel}</Text> <Text style={styles.hardPaywallFooter}>{paywallFooterLabel}</Text>
<View style={styles.legalLinksRow}> <View style={styles.legalLinksRow}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}> <TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
@@ -778,7 +815,7 @@ export default function BillingScreen() {
<TouchableOpacity <TouchableOpacity
style={[styles.paywallPlanCardPrimary, { borderColor: colors.primary, backgroundColor: colors.primary + '12' }]} style={[styles.paywallPlanCardPrimary, { borderColor: colors.primary, backgroundColor: colors.primary + '12' }]}
onPress={() => handlePurchase('yearly_pro')} onPress={() => handlePurchase('yearly_pro')}
disabled={isUpdating || !storeReady} disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.9} activeOpacity={0.9}
> >
<View style={styles.planTopRow}> <View style={styles.planTopRow}>
@@ -805,10 +842,10 @@ export default function BillingScreen() {
style={[ style={[
styles.paywallPlanCardSecondary, styles.paywallPlanCardSecondary,
{ borderColor: colors.border }, { borderColor: colors.border },
(!storeReady || isUpdating) && styles.disabledPlanCard, (!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]} ]}
onPress={() => handlePurchase('monthly_pro')} onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady} disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.9} activeOpacity={0.9}
> >
<View style={styles.planTopRow}> <View style={styles.planTopRow}>
@@ -876,7 +913,7 @@ export default function BillingScreen() {
} }
]} ]}
onPress={() => handlePurchase(pack.id)} onPress={() => handlePurchase(pack.id)}
disabled={isUpdating || !storeReady} disabled={isUpdating || !storeReady || Boolean(storeError)}
> >
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="flash" size={18} color={colors.primary} /> <Ionicons name="flash" size={18} color={colors.primary} />
@@ -953,7 +990,7 @@ export default function BillingScreen() {
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' } planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]} ]}
onPress={() => handlePurchase('monthly_pro')} onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady} disabled={isUpdating || !storeReady || Boolean(storeError)}
> >
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}> <View style={styles.planHeaderRow}>
@@ -984,7 +1021,7 @@ export default function BillingScreen() {
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' } planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]} ]}
onPress={() => handlePurchase('yearly_pro')} onPress={() => handlePurchase('yearly_pro')}
disabled={isUpdating || !storeReady} disabled={isUpdating || !storeReady || Boolean(storeError)}
> >
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}> <View style={styles.planHeaderRow}>
@@ -1070,7 +1107,7 @@ export default function BillingScreen() {
<TouchableOpacity <TouchableOpacity
style={styles.offerDeclineBtn} style={styles.offerDeclineBtn}
onPress={finalizeCancel} onPress={finalizeCancel}
disabled={isUpdating || !storeReady} disabled={isUpdating}
> >
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>{copy.offerDecline}</Text> <Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>{copy.offerDecline}</Text>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -11,7 +11,7 @@ import * as ImageManipulator from 'expo-image-manipulator';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native'; import { useSafeAnalytics } from '../services/analytics';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors'; import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService'; import { PlantRecognitionService } from '../services/plantRecognitionService';
@@ -130,7 +130,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
export default function ScannerScreen() { export default function ScannerScreen() {
const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageUri?: string }>(); const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageUri?: string }>();
const posthog = usePostHog(); const posthog = useSafeAnalytics();
const { const {
isDarkMode, isDarkMode,
colorPalette, colorPalette,

View File

@@ -8,7 +8,7 @@ export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain), metadataBase: new URL(siteConfig.domain),
title: { title: {
default: 'GreenLens Pflanzen erkennen & Pflege-App', default: 'GreenLens Pflanzen erkennen & Pflege-App',
template: '%s | GreenLens', template: '%s',
}, },
description: description:
'GreenLens erkennt Pflanzen per Foto in Sekunden und liefert sofort Pflegeplan, Gießerinnerungen und Gesundheitscheck — alles in einer App.', 'GreenLens erkennt Pflanzen per Foto in Sekunden und liefert sofort Pflegeplan, Gießerinnerungen und Gesundheitscheck — alles in einer App.',

View File

@@ -36,19 +36,19 @@ export default function sitemap(): MetadataRoute.Sitemap {
}, },
{ {
url: `${baseUrl}/pflanzen-erkennen-app`, url: `${baseUrl}/pflanzen-erkennen-app`,
lastModified: new Date('2026-04-27'), lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.85, priority: 0.85,
}, },
{ {
url: `${baseUrl}/blumen-scanner`, url: `${baseUrl}/blumen-scanner`,
lastModified: new Date('2026-04-27'), lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.8, priority: 0.8,
}, },
{ {
url: `${baseUrl}/pflanzen-bestimmen`, url: `${baseUrl}/pflanzen-bestimmen`,
lastModified: new Date('2026-04-27'), lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.8, priority: 0.8,
}, },
@@ -72,7 +72,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
}, },
{ {
url: `${baseUrl}/vs/google-lens`, url: `${baseUrl}/vs/google-lens`,
lastModified: new Date('2026-04-27'), lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.75, priority: 0.75,
}, },
@@ -108,19 +108,19 @@ export default function sitemap(): MetadataRoute.Sitemap {
}, },
{ {
url: `${baseUrl}/pflanzen-krankheiten-erkennen`, url: `${baseUrl}/pflanzen-krankheiten-erkennen`,
lastModified: new Date('2026-04-27'), lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.8, priority: 0.8,
}, },
{ {
url: `${baseUrl}/pflanzen-pflege-app`, url: `${baseUrl}/pflanzen-pflege-app`,
lastModified: new Date('2026-04-27'), lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.75, priority: 0.75,
}, },
{ {
url: `${baseUrl}/zimmerpflanzen-bestimmen`, url: `${baseUrl}/zimmerpflanzen-bestimmen`,
lastModified: new Date('2026-04-27'), lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.8, priority: 0.8,
}, },

View File

@@ -529,9 +529,9 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
'google-lens': { 'google-lens': {
slug: 'google-lens', slug: 'google-lens',
name: 'Google Lens', name: 'Google Lens',
metaTitle: 'GreenLens vs Google Lens Pflanzen erkennen App im Vergleich', metaTitle: 'GreenLens vs Google Lens: Pflanzen bestimmen | GreenLens',
metaDescription: metaDescription:
'Google Lens nennt den Pflanzennamen. GreenLens liefert danach Pflegeplan, Gießerinnerungen und Gesundheitscheck. Vergleich: Was Google Lens kann — und was fehlt.', 'Pflanzen mit Google Lens bestimmen? Vergleich: Google nennt den Namen, GreenLens liefert Pflegeplan, Gießerinnerung und Gesundheitscheck.',
heroSummary: heroSummary:
'Google Lens kann Pflanzen erkennen — aber es hört genau dort auf. GreenLens ist die spezialisierte Alternative: Pflanze fotografieren, sofort Name erhalten, und dann direkt Pflegeplan, Diagnose und Erinnerungen — alles ohne Umweg über Google-Suchergebnisse.', 'Google Lens kann Pflanzen erkennen — aber es hört genau dort auf. GreenLens ist die spezialisierte Alternative: Pflanze fotografieren, sofort Name erhalten, und dann direkt Pflegeplan, Diagnose und Erinnerungen — alles ohne Umweg über Google-Suchergebnisse.',
heroVerdict: [ heroVerdict: [
@@ -548,6 +548,7 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
'GreenLens ist kein allgemeines Suchwerkzeug. Die App ist ausschließlich für Pflanzen entwickelt: Erkennung, Pflegeplanung, Gesundheitscheck und Sammelverwaltung in einer Oberfläche — ohne Weiterleitung auf externe Webseiten.', 'GreenLens ist kein allgemeines Suchwerkzeug. Die App ist ausschließlich für Pflanzen entwickelt: Erkennung, Pflegeplanung, Gesundheitscheck und Sammelverwaltung in einer Oberfläche — ohne Weiterleitung auf externe Webseiten.',
whyPeopleCompare: [ whyPeopleCompare: [
'Sie haben Google Lens für schnelle Erkennung genutzt, aber keine Pflegeinformationen erhalten.', 'Sie haben Google Lens für schnelle Erkennung genutzt, aber keine Pflegeinformationen erhalten.',
'Sie suchen nach „Pflanzen bestimmen Google" und wollen wissen, ob eine spezialisierte App danach mehr hilft.',
'Sie suchen eine kostenlose Alternative zu Google, die nach der Erkennung weitergeht.', 'Sie suchen eine kostenlose Alternative zu Google, die nach der Erkennung weitergeht.',
'Sie wollen nach dem Scan direkt wissen, was sie tun sollen — nicht auf eine Suchergebnisseite weitergeleitet werden.', 'Sie wollen nach dem Scan direkt wissen, was sie tun sollen — nicht auf eine Suchergebnisseite weitergeleitet werden.',
], ],
@@ -679,6 +680,11 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
answer: answer:
'Nein. Google Lens ist ein allgemeines Bildsuchwerkzeug ohne Pflegeplan, Gießerinnerungen, Gesundheitscheck oder Sammlungsverwaltung.', 'Nein. Google Lens ist ein allgemeines Bildsuchwerkzeug ohne Pflegeplan, Gießerinnerungen, Gesundheitscheck oder Sammlungsverwaltung.',
}, },
{
question: 'Wie kann ich Pflanzen mit Google bestimmen?',
answer:
'Mit Google Lens kannst du ein Pflanzenfoto analysieren und Suchergebnisse zum wahrscheinlichen Namen erhalten. GreenLens nutzt denselben Foto-Intent, geht aber weiter: Nach dem Scan erhältst du Artname, Pflegeplan, Gießerinnerung und bei Symptomen eine Diagnose.',
},
{ {
question: 'Ist GreenLens besser als Google Lens für Pflanzenerkennung?', question: 'Ist GreenLens besser als Google Lens für Pflanzenerkennung?',
answer: answer:

View File

@@ -335,16 +335,16 @@ const seoPageProfiles: Record<string, SeoPageProfile> = {
'pflanzen-erkennen-app': { 'pflanzen-erkennen-app': {
slug: 'pflanzen-erkennen-app', slug: 'pflanzen-erkennen-app',
metaTitle: 'Pflanzen erkennen App GreenLens', metaTitle: 'Pflanzen erkennen App per Foto | GreenLens',
metaDescription: metaDescription:
'GreenLens erkennt Pflanzen per Foto in Sekunden und liefert sofort einen Pflegeplan, Gießerinnerungen und Gesundheitsdiagnosen — alles in einer App.', 'Pflanzen erkennen per Foto: GreenLens liefert Artname, Pflegeplan, Gießerinnerungen und Diagnose direkt in einer App.',
canonical: '/pflanzen-erkennen-app', canonical: '/pflanzen-erkennen-app',
h1: 'Pflanzen erkennen App', h1: 'Pflanzen erkennen App',
tagline: 'Pflanze fotografieren — Name, Pflegeanleitung und Diagnose in einer Sekunde.', tagline: 'Pflanze fotografieren — Name, Pflegeanleitung und Diagnose in einer Sekunde.',
directAnswer: directAnswer:
'GreenLens ist eine Pflanzenerkennungs-App für iOS und Android. Einfach die Kamera auf eine Pflanze richten, scannen — und sofort erscheinen Artname, Pflegebedarf und nächste Handlungsempfehlung.', 'GreenLens ist eine Pflanzenerkennungs-App für iOS und Android. Einfach die Kamera auf eine Pflanze richten, scannen — und sofort erscheinen Artname, Pflegebedarf, Gießerinnerungen und nächste Handlungsempfehlung.',
definitionBlock: definitionBlock:
'Eine Pflanzen-App erkennt Pflanzen anhand von Fotos und liefert den Artnamen sowie Pflegeinformationen. GreenLens geht weiter: Jeder Scan erzeugt automatisch einen Pflegeplan, und ein separater Gesundheitscheck analysiert Symptome wie gelbe Blätter oder weiche Stiele.', 'Eine Pflanzen-App erkennt Pflanzen anhand von Fotos und liefert den Artnamen sowie Pflegeinformationen. GreenLens geht weiter: Jeder Scan erzeugt automatisch einen Pflegeplan, Gießerinnerungen und eine Sammlung; ein separater Gesundheitscheck analysiert Symptome wie gelbe Blätter oder weiche Stiele.',
lastUpdated: 'April 2026', lastUpdated: 'April 2026',
includeAppSchema: true, includeAppSchema: true,
featureTable: { featureTable: {
@@ -408,6 +408,11 @@ const seoPageProfiles: Record<string, SeoPageProfile> = {
answer: answer:
'Ja. Der Gesundheitscheck analysiert sichtbare Symptome wie gelbe Blätter, braune Spitzen, weiche Stiele oder Flecken und liefert die wahrscheinlichste Ursache sowie einen konkreten nächsten Schritt — zum Beispiel Gießen einstellen, Standort prüfen oder isolieren.', 'Ja. Der Gesundheitscheck analysiert sichtbare Symptome wie gelbe Blätter, braune Spitzen, weiche Stiele oder Flecken und liefert die wahrscheinlichste Ursache sowie einen konkreten nächsten Schritt — zum Beispiel Gießen einstellen, Standort prüfen oder isolieren.',
}, },
{
question: 'Kann ich Pflanzen mit GreenLens per Foto und nicht mit Google bestimmen?',
answer:
'Ja. GreenLens ist für die Pflanzenbestimmung per Foto gebaut. Im Unterschied zu Google Lens bleibt der Ablauf in einer App: Foto scannen, Artname sehen, Pflegeplan erhalten, Gießerinnerungen setzen und bei Symptomen direkt einen Gesundheitscheck starten.',
},
{ {
question: 'Welche Sprachen unterstützt GreenLens?', question: 'Welche Sprachen unterstützt GreenLens?',
answer: answer:
@@ -415,21 +420,31 @@ const seoPageProfiles: Record<string, SeoPageProfile> = {
}, },
], ],
relatedLinks: [ relatedLinks: [
{
href: '/pflanzen-bestimmen',
label: 'Pflanzen bestimmen per Foto',
description: 'Der Hauptvergleich für Pflanzenbestimmung per Foto und Google Lens.',
},
{
href: '/blumen-scanner',
label: 'Blumen Scanner',
description: 'Blumen und Pflanzen per Foto scannen, bestimmen und speichern.',
},
{
href: '/pflanzen-pflege-app',
label: 'Pflanzen Pflege App',
description: 'Gießerinnerungen, Pflegeplan und Sammlung für jede Pflanze.',
},
{
href: '/pflanzen-krankheiten-erkennen',
label: 'Pflanzenkrankheiten erkennen',
description: 'Symptome wie gelbe Blätter, Flecken oder Schädlinge diagnostizieren.',
},
{ {
href: '/plant-identifier-app', href: '/plant-identifier-app',
label: 'Plant Identifier App (English)', label: 'Plant Identifier App (English)',
description: 'The English version of this page for plant identification and care.', description: 'The English version of this page for plant identification and care.',
}, },
{
href: '/plant-disease-identifier',
label: 'Plant Disease Identifier',
description: 'Symptom-based diagnosis when your plant starts showing problems.',
},
{
href: '/plant-care-app',
label: 'Plant Care App',
description: 'Reminders and care tracking for every plant in your collection.',
},
], ],
}, },
} }
@@ -437,16 +452,16 @@ const seoPageProfiles: Record<string, SeoPageProfile> = {
const additionalSeoPages: Record<string, SeoPageProfile> = { const additionalSeoPages: Record<string, SeoPageProfile> = {
'blumen-scanner': { 'blumen-scanner': {
slug: 'blumen-scanner', slug: 'blumen-scanner',
metaTitle: 'Blumen Scanner App — Pflanzen per Foto erkennen | GreenLens', metaTitle: 'Blumen Scanner App: Blumen per Foto erkennen | GreenLens',
metaDescription: metaDescription:
'GreenLens ist der Blumen Scanner, der weitergeht: Blume oder Pflanze fotografieren, sofort den Namen erhalten — und direkt Pflegeplan, Gießerinnerungen und Gesundheitscheck.', 'Blumen Scanner App: Blume fotografieren, Namen erkennen, Pflegeplan erhalten und bei welken Blüten direkt den Gesundheitscheck nutzen.',
canonical: '/blumen-scanner', canonical: '/blumen-scanner',
h1: 'Blumen Scanner', h1: 'Blumen Scanner',
tagline: 'Blume fotografieren — sofort Name, Herkunft und Pflegeplan.', tagline: 'Blume fotografieren — sofort Name, Herkunft, Pflegeplan und Erinnerung.',
directAnswer: directAnswer:
'GreenLens ist ein Blumen Scanner für iOS und Android. Einfach die Kamera auf eine Blume oder Pflanze richten, scannen — und sofort erscheinen Artname, botanische Klassifizierung und ein vollständiger Pflegeplan.', 'GreenLens ist ein Blumen Scanner für iOS und Android. Einfach die Kamera auf eine Blume oder Pflanze richten, scannen — und sofort erscheinen Artname, botanische Klassifizierung, Pflegeplan und Gießerinnerung.',
definitionBlock: definitionBlock:
'Ein Blumen Scanner erkennt Blumen und Pflanzen anhand von Fotos und liefert den Artnamen sowie Pflegeinformationen. GreenLens geht über einfache Namensuche hinaus: Jeder Scan erzeugt automatisch einen Pflegeplan, und ein separater Gesundheitscheck analysiert Symptome wie gelbe Blätter oder welkende Blüten.', 'Ein Blumen Scanner erkennt Blumen und Pflanzen anhand von Fotos und liefert den Artnamen sowie Pflegeinformationen. GreenLens geht über einfache Namensuche hinaus: Jeder Scan erzeugt automatisch einen Pflegeplan; ein separater Gesundheitscheck analysiert Symptome wie gelbe Blätter, welkende Blüten oder Schädlingsbefall.',
lastUpdated: 'April 2026', lastUpdated: 'April 2026',
includeAppSchema: true, includeAppSchema: true,
featureTable: { featureTable: {
@@ -517,6 +532,16 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
answer: answer:
'Ja. GreenLens kann Fotos aus der Galerie analysieren — ein neues Kamerafoto ist nicht notwendig. Das Ergebnis ist genauso präzise wie bei einem Echtzeitscan, solange das Foto die Pflanze scharf und bei guter Beleuchtung zeigt.', 'Ja. GreenLens kann Fotos aus der Galerie analysieren — ein neues Kamerafoto ist nicht notwendig. Das Ergebnis ist genauso präzise wie bei einem Echtzeitscan, solange das Foto die Pflanze scharf und bei guter Beleuchtung zeigt.',
}, },
{
question: 'Kann ich mit GreenLens Blumen per Foto kostenlos erkennen?',
answer:
'Ja. GreenLens zeigt den Namen der Blume oder Pflanze nach dem Scan kostenlos an. Erweiterte Funktionen wie unbegrenzte Scans oder zusätzliche KI-Gesundheitschecks sind optional kostenpflichtig.',
},
{
question: 'Ist der Blumen Scanner auch für Zimmerpflanzen geeignet?',
answer:
'Ja. GreenLens erkennt nicht nur Blumen, sondern auch häufige Zimmerpflanzen, Sukkulenten und Gartenpflanzen. Für reine Zimmerpflanzen-Suchen ist die Seite Zimmerpflanzen bestimmen besonders passend.',
},
{ {
question: 'Was tun, wenn eine Blume welkt oder krank aussieht?', question: 'Was tun, wenn eine Blume welkt oder krank aussieht?',
answer: answer:
@@ -524,6 +549,11 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
}, },
], ],
relatedLinks: [ relatedLinks: [
{
href: '/zimmerpflanzen-bestimmen',
label: 'Zimmerpflanzen bestimmen',
description: 'Zimmerpflanzen per Foto erkennen und passenden Pflegeplan erhalten.',
},
{ {
href: '/pflanzen-erkennen-app', href: '/pflanzen-erkennen-app',
label: 'Pflanzen erkennen App', label: 'Pflanzen erkennen App',
@@ -535,8 +565,8 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
description: 'Pflanze per Foto bestimmen — schneller und genauer als mit Google.', description: 'Pflanze per Foto bestimmen — schneller und genauer als mit Google.',
}, },
{ {
href: '/plant-disease-identifier', href: '/pflanzen-krankheiten-erkennen',
label: 'Plant Disease Identifier', label: 'Pflanzenkrankheiten erkennen',
description: 'Diagnose für Symptome: wenn eine Blume oder Pflanze krank aussieht.', description: 'Diagnose für Symptome: wenn eine Blume oder Pflanze krank aussieht.',
}, },
], ],
@@ -544,16 +574,16 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
'pflanzen-bestimmen': { 'pflanzen-bestimmen': {
slug: 'pflanzen-bestimmen', slug: 'pflanzen-bestimmen',
metaTitle: 'Pflanzen bestimmen per Foto — besser als Google | GreenLens', metaTitle: 'Pflanzen bestimmen per Foto: App statt Google | GreenLens',
metaDescription: metaDescription:
'Pflanzen bestimmen per Foto: GreenLens liefert Artname, Pflegeplan und Gesundheitscheck in einer App — ohne Umweg über Google-Suchergebnisse.', 'Pflanzen per Foto bestimmen: GreenLens erkennt den Namen, erstellt Pflegeplan und Gießerinnerungen und erklärt den Unterschied zu Google Lens.',
canonical: '/pflanzen-bestimmen', canonical: '/pflanzen-bestimmen',
h1: 'Pflanzen bestimmen per Foto', h1: 'Pflanzen bestimmen per Foto',
tagline: 'Pflanze fotografieren — sofort bestimmt, mit Pflegeplan und ohne Google-Umweg.', tagline: 'Pflanze fotografieren — sofort bestimmt, mit Pflegeplan und ohne Google-Umweg.',
directAnswer: directAnswer:
'GreenLens bestimmt Pflanzen per Foto in unter einer Sekunde. Statt auf Google-Suchergebnisse weiterzuleiten, liefert die App direkt Artname, Pflegebedarf, Gießerinnerungen und bei Bedarf eine Gesundheitsdiagnose.', 'GreenLens bestimmt Pflanzen per Foto in unter einer Sekunde. Statt auf Google-Suchergebnisse weiterzuleiten, liefert die App direkt Artname, Pflegebedarf, Gießerinnerungen und bei Bedarf eine Gesundheitsdiagnose.',
definitionBlock: definitionBlock:
'Pflanzen bestimmen bedeutet, anhand eines Fotos die Pflanzenart, den Trivialnamen und die zugehörigen Pflegeanforderungen herauszufinden. Während Google Lens und allgemeine Suchmaschinen einen Namen und Links liefern, kombiniert GreenLens die Bestimmung mit einem automatischen Pflegeplan und einem Gesundheitscheck-Werkzeug.', 'Pflanzen bestimmen bedeutet, anhand eines Fotos die Pflanzenart, den Trivialnamen und die zugehörigen Pflegeanforderungen herauszufinden. Während Google Lens und allgemeine Suchmaschinen Namen und Links liefern, kombiniert GreenLens die Bestimmung mit einem automatischen Pflegeplan, Gießerinnerungen, Sammlung und Gesundheitscheck.',
lastUpdated: 'April 2026', lastUpdated: 'April 2026',
includeAppSchema: true, includeAppSchema: true,
featureTable: { featureTable: {
@@ -627,6 +657,16 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
answer: answer:
'GreenLens erkennt über 450 Pflanzenarten, darunter häufige Wildpflanzen, Gartenblumen, Zimmerpflanzen und Sukkulenten. Für seltene Wildpflanzen und wissenschaftliche Bestimmung hat iNaturalist eine größere Expertencommunity.', 'GreenLens erkennt über 450 Pflanzenarten, darunter häufige Wildpflanzen, Gartenblumen, Zimmerpflanzen und Sukkulenten. Für seltene Wildpflanzen und wissenschaftliche Bestimmung hat iNaturalist eine größere Expertencommunity.',
}, },
{
question: 'Was ist besser zum Pflanzen bestimmen: Google Lens oder GreenLens?',
answer:
'Google Lens ist gut, wenn du nur schnell einen Namen oder Suchergebnisse brauchst. GreenLens ist besser, wenn nach der Bestimmung Pflege folgen soll: Die App liefert Artname, Pflegeplan, Gießerinnerungen, Sammlung und Gesundheitscheck in einem Ablauf.',
},
{
question: 'Kann ich Pflanzen mit Google bestimmen und danach GreenLens nutzen?',
answer:
'Ja. Wenn du eine Pflanze bereits über Google Lens gefunden hast, kannst du sie in GreenLens erneut scannen oder speichern, um Pflegeplan, Erinnerungen und Gesundheitsdiagnose zu nutzen. Für einen direkten Ablauf ist der GreenLens-Scan meist schneller.',
},
{ {
question: 'Wie genau ist GreenLens beim Bestimmen von Pflanzen?', question: 'Wie genau ist GreenLens beim Bestimmen von Pflanzen?',
answer: answer:
@@ -634,6 +674,11 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
}, },
], ],
relatedLinks: [ relatedLinks: [
{
href: '/vs/google-lens',
label: 'GreenLens vs Google Lens',
description: 'Vollständiger Vergleich: Was Google Lens kann — und was GreenLens darüber hinaus liefert.',
},
{ {
href: '/pflanzen-erkennen-app', href: '/pflanzen-erkennen-app',
label: 'Pflanzen erkennen App', label: 'Pflanzen erkennen App',
@@ -644,11 +689,6 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
label: 'Blumen Scanner', label: 'Blumen Scanner',
description: 'Blumen per Foto scannen — sofort Name, Herkunft und Pflegeplan.', description: 'Blumen per Foto scannen — sofort Name, Herkunft und Pflegeplan.',
}, },
{
href: '/vs/google-lens',
label: 'GreenLens vs Google Lens',
description: 'Vollständiger Vergleich: Was Google Lens kann — und was GreenLens darüber hinaus liefert.',
},
], ],
}, },
} }
@@ -875,16 +915,16 @@ const englishSeoPages: Record<string, SeoPageProfile> = {
const germanSeoPages2: Record<string, SeoPageProfile> = { const germanSeoPages2: Record<string, SeoPageProfile> = {
'pflanzen-krankheiten-erkennen': { 'pflanzen-krankheiten-erkennen': {
slug: 'pflanzen-krankheiten-erkennen', slug: 'pflanzen-krankheiten-erkennen',
metaTitle: 'Pflanzenkrankheiten erkennen — App für Diagnose & Hilfe | GreenLens', metaTitle: 'Pflanzenkrankheiten erkennen per Foto | GreenLens',
metaDescription: metaDescription:
'GreenLens erkennt Pflanzenkrankheiten per Foto: gelbe Blätter, weiche Stiele, Flecken — und liefert sofort die wahrscheinlichste Ursache und einen konkreten nächsten Schritt.', 'Pflanzenkrankheiten erkennen: gelbe Blätter, braune Flecken, Schädlinge oder Wurzelfäule per Foto prüfen und nächsten Schritt erhalten.',
canonical: '/pflanzen-krankheiten-erkennen', canonical: '/pflanzen-krankheiten-erkennen',
h1: 'Pflanzenkrankheiten erkennen', h1: 'Pflanzenkrankheiten erkennen',
tagline: 'Symptom fotografieren — Ursache erfahren, nächsten Schritt erhalten.', tagline: 'Symptom fotografieren — Ursache erfahren, nächsten Schritt erhalten.',
directAnswer: directAnswer:
'GreenLens erkennt Pflanzenkrankheiten und Stresssymptome anhand von Fotos. Die App analysiert gelbe Blätter, braune Spitzen, weiche Stiele, Flecken oder plötzlichen Rückgang — und liefert die wahrscheinlichste Ursache sowie eine konkrete Handlungsempfehlung.', 'GreenLens erkennt Pflanzenkrankheiten und Stresssymptome anhand von Fotos. Die App analysiert gelbe Blätter, braune Spitzen, weiche Stiele, Flecken, Schädlinge oder plötzlichen Rückgang — und liefert die wahrscheinlichste Ursache sowie eine konkrete Handlungsempfehlung.',
definitionBlock: definitionBlock:
'Pflanzenkrankheiten erkennen bedeutet, sichtbare Symptome einer Pflanze — Verfärbungen, Texturveränderungen, Blattfall, weiche Stiele — einer bekannten Ursache zuzuordnen: Überwässerung, Wurzelfäule, Pilzbefall oder Nährstoffmangel. GreenLens fokussiert auf den nächsten sicheren Handlungsschritt statt auf eine umfangreiche Diagnose-Liste.', 'Pflanzenkrankheiten erkennen bedeutet, sichtbare Symptome einer Pflanze — Verfärbungen, Texturveränderungen, Blattfall, weiche Stiele, Fraßspuren oder klebrige Beläge — einer bekannten Ursache zuzuordnen: Überwässerung, Wurzelfäule, Pilzbefall, Schädlinge oder Nährstoffmangel. GreenLens fokussiert auf den nächsten sicheren Handlungsschritt statt auf eine lange Diagnose-Liste.',
lastUpdated: 'April 2026', lastUpdated: 'April 2026',
includeAppSchema: true, includeAppSchema: true,
featureTable: { featureTable: {
@@ -958,6 +998,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
answer: answer:
'Welken nach dem Umtopfen ist häufig Transplantationsschock oder Wurzelschaden durch Überwässerung direkt nach dem Einpflanzen. GreenLens verbindet das Symptom mit dem jüngsten Pflegeschritt und empfiehlt die nächste risikoärmste Maßnahme — meist: warten, keine weiteren Eingriffe.', 'Welken nach dem Umtopfen ist häufig Transplantationsschock oder Wurzelschaden durch Überwässerung direkt nach dem Einpflanzen. GreenLens verbindet das Symptom mit dem jüngsten Pflegeschritt und empfiehlt die nächste risikoärmste Maßnahme — meist: warten, keine weiteren Eingriffe.',
}, },
{
question: 'Kann GreenLens eine Pflanzen-Diagnose per Foto erstellen?',
answer:
'Ja. Der Gesundheitscheck analysiert ein Foto der betroffenen Stelle und kombiniert sichtbare Symptome mit Pflegekontext. Das Ergebnis ist keine Laboranalyse, aber eine priorisierte Einschätzung mit einem konkreten nächsten Schritt.',
},
{
question: 'Was sind häufige Anzeichen für Pflanzenschädlinge?',
answer:
'Typische Hinweise sind feine Gespinste, klebrige Blätter, kleine Punkte auf Blattunterseiten, Fraßspuren, silbrige Flecken oder kleine Fliegen in der Erde. GreenLens ordnet solche Symptome ein und empfiehlt, ob isolieren, abduschen oder Erde prüfen sinnvoll ist.',
},
{ {
question: 'Wie lange dauert es, bis eine kranke Pflanze sich erholt?', question: 'Wie lange dauert es, bis eine kranke Pflanze sich erholt?',
answer: answer:
@@ -985,14 +1035,14 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
'pflanzen-pflege-app': { 'pflanzen-pflege-app': {
slug: 'pflanzen-pflege-app', slug: 'pflanzen-pflege-app',
metaTitle: 'Pflanzen Pflege App Gießerinnerungen & Gesundheitscheck | GreenLens', metaTitle: 'Pflanzen Pflege App mit Gießerinnerung | GreenLens',
metaDescription: metaDescription:
'GreenLens ist die Pflanzen Pflege App mit Gießerinnerungen, Pflegeplan und Gesundheitscheck — individuell pro Pflanze, nicht nach Kalender.', 'Pflanzen Pflege App mit Gießerinnerung: GreenLens erstellt Pflegeplan, erinnert pro Pflanze und erkennt Stress wie Überwässerung.',
canonical: '/pflanzen-pflege-app', canonical: '/pflanzen-pflege-app',
h1: 'Pflanzen Pflege App', h1: 'Pflanzen Pflege App',
tagline: 'Pflegeerinnerungen, die deine Pflanze kennen — nicht nur deinen Kalender.', tagline: 'Gießerinnerungen, die deine Pflanze kennen — nicht nur deinen Kalender.',
directAnswer: directAnswer:
'GreenLens ist eine Pflanzen Pflege App, die Pflanzenerkennung, Pflegeplanung und Gesundheitsdiagnose verbindet. Statt generischer Gießeintimer verknüpft sie Pflegeempfehlungen mit der konkreten Pflanze, ihrem Standort und jüngsten Veränderungen.', 'GreenLens ist eine Pflanzen Pflege App, die Pflanzenerkennung, Pflegeplanung, Gießerinnerungen und Gesundheitsdiagnose verbindet. Statt generischer Gießeintimer verknüpft sie Pflegeempfehlungen mit der konkreten Pflanze, ihrem Standort und jüngsten Veränderungen.',
definitionBlock: definitionBlock:
'Eine Pflanzen Pflege App hilft dabei, Gieß-, Dünge- und Pflegepläne für jede einzelne Pflanze zu verwalten. GreenLens erweitert das mit KI-basierten Pflegeplänen aus dem Scan-Ergebnis, kontextbewussten Erinnerungen und einem Gesundheitscheck, damit Pflegeentscheidungen auf dem basieren, was die Pflanze tatsächlich zeigt.', 'Eine Pflanzen Pflege App hilft dabei, Gieß-, Dünge- und Pflegepläne für jede einzelne Pflanze zu verwalten. GreenLens erweitert das mit KI-basierten Pflegeplänen aus dem Scan-Ergebnis, kontextbewussten Erinnerungen und einem Gesundheitscheck, damit Pflegeentscheidungen auf dem basieren, was die Pflanze tatsächlich zeigt.',
lastUpdated: 'April 2026', lastUpdated: 'April 2026',
@@ -1009,9 +1059,9 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
'Manuelle Timer ohne pflanzenspezifischen Kontext.', 'Manuelle Timer ohne pflanzenspezifischen Kontext.',
}, },
{ {
feature: 'Gießhinweise', feature: 'Gießerinnerung',
greenlens: greenlens:
'Berücksichtigt Erdfeuchtigkeit, Jahreszeit und Umgebung — nicht nur vergangene Tage.', 'Erinnert pro Pflanze und berücksichtigt Art, Erdfeuchtigkeit, Jahreszeit und Umgebung — nicht nur vergangene Tage.',
alternative: alternative:
'Festes Intervall (z.B. alle 7 Tage) unabhängig vom Pflanzenzustand.', 'Festes Intervall (z.B. alle 7 Tage) unabhängig vom Pflanzenzustand.',
}, },
@@ -1068,6 +1118,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
answer: answer:
'Ja. Der Gesundheitscheck ist darauf ausgelegt, Überwässerung zu erkennen, bevor sie zu Wurzelfäule wird. Wenn eine Pflanze weiche Stiele oder Vergilben zeigt und die Pflegehistorie eine kürzliche Bewässerung aufzeichnet, weist GreenLens auf den wahrscheinlichen Zusammenhang hin.', 'Ja. Der Gesundheitscheck ist darauf ausgelegt, Überwässerung zu erkennen, bevor sie zu Wurzelfäule wird. Wenn eine Pflanze weiche Stiele oder Vergilben zeigt und die Pflegehistorie eine kürzliche Bewässerung aufzeichnet, weist GreenLens auf den wahrscheinlichen Zusammenhang hin.',
}, },
{
question: 'Welche App erinnert mich ans Pflanzen gießen?',
answer:
'GreenLens erinnert dich pro Pflanze ans Gießen. Nach dem Scan erhält jede Pflanze einen eigenen Pflegeplan, den du an Standort und Zustand anpassen kannst. Dadurch ist die Erinnerung präziser als ein allgemeiner Kalendertermin.',
},
{
question: 'Kann ich für jede Pflanze eine eigene Gießerinnerung setzen?',
answer:
'Ja. Jede gespeicherte Pflanze kann eigene Erinnerungen, Notizen und Pflegeintervalle haben. Das ist besonders hilfreich, wenn Sukkulenten, tropische Zimmerpflanzen und Balkonpflanzen unterschiedliche Wasserbedürfnisse haben.',
},
{ {
question: 'Funktioniert GreenLens auch für Garten- und Balkonpflanzen?', question: 'Funktioniert GreenLens auch für Garten- und Balkonpflanzen?',
answer: answer:
@@ -1075,6 +1135,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
}, },
], ],
relatedLinks: [ relatedLinks: [
{
href: '/pflanzen-bestimmen',
label: 'Pflanzen bestimmen',
description: 'Pflanze per Foto bestimmen und direkt den passenden Pflegeplan erhalten.',
},
{
href: '/zimmerpflanzen-bestimmen',
label: 'Zimmerpflanzen bestimmen',
description: 'Zimmerpflanzen erkennen und passende Gießerinnerungen einrichten.',
},
{ {
href: '/pflanzen-erkennen-app', href: '/pflanzen-erkennen-app',
label: 'Pflanzen erkennen App', label: 'Pflanzen erkennen App',
@@ -1085,26 +1155,21 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
label: 'Pflanzenkrankheiten erkennen', label: 'Pflanzenkrankheiten erkennen',
description: 'Wenn die Pflegeroutine nicht ausreicht und Symptome auftreten.', description: 'Wenn die Pflegeroutine nicht ausreicht und Symptome auftreten.',
}, },
{
href: '/plant-care-app',
label: 'Plant Care App (English)',
description: 'The English version of context-aware plant care and reminders.',
},
], ],
}, },
'zimmerpflanzen-bestimmen': { 'zimmerpflanzen-bestimmen': {
slug: 'zimmerpflanzen-bestimmen', slug: 'zimmerpflanzen-bestimmen',
metaTitle: 'Zimmerpflanzen bestimmen per Foto — sofort mit Pflegeplan | GreenLens', metaTitle: 'Zimmerpflanzen bestimmen per Foto | GreenLens',
metaDescription: metaDescription:
'Zimmerpflanze bestimmen per Foto: GreenLens erkennt über 450 Zimmerpflanzenarten und liefert sofort Artname, Pflegeplan und Gießerinnerungen.', 'Zimmerpflanzen bestimmen per Foto: GreenLens erkennt Monstera, Efeutute, Ficus, Orchideen und Sukkulenten mit Pflegeplan.',
canonical: '/zimmerpflanzen-bestimmen', canonical: '/zimmerpflanzen-bestimmen',
h1: 'Zimmerpflanzen bestimmen', h1: 'Zimmerpflanzen bestimmen',
tagline: 'Zimmerpflanze fotografieren — Artname, Pflegebedarf und Gießplan in einer Sekunde.', tagline: 'Zimmerpflanze fotografieren — Artname, Pflegebedarf und Gießplan in einer Sekunde.',
directAnswer: directAnswer:
'GreenLens bestimmt Zimmerpflanzen per Foto in unter einer Sekunde. Die App liefert Artname, botanische Einordnung und einen vollständigen Pflegeplan — direkt nach dem Scan, ohne Umweg über externe Seiten.', 'GreenLens bestimmt Zimmerpflanzen per Foto in unter einer Sekunde. Die App liefert Artname, botanische Einordnung und einen vollständigen Pflegeplan — direkt nach dem Scan, ohne Umweg über externe Seiten.',
definitionBlock: definitionBlock:
'Zimmerpflanzen bestimmen bedeutet, anhand eines Fotos die Pflanzenart, den Trivialnamen und die Pflegeanforderungen für Innenraumbedingungen herauszufinden. GreenLens kombiniert die Bestimmung mit einem automatischen Pflegeplan und einem Gesundheitscheck, damit die Erkennung direkt zur Pflege führt.', 'Zimmerpflanzen bestimmen bedeutet, anhand eines Fotos die Pflanzenart, den Trivialnamen und die Pflegeanforderungen für Innenraumbedingungen herauszufinden. GreenLens kombiniert die Bestimmung mit einem automatischen Pflegeplan, Gießerinnerungen und einem Gesundheitscheck, damit die Erkennung direkt zur Pflege führt.',
lastUpdated: 'April 2026', lastUpdated: 'April 2026',
includeAppSchema: true, includeAppSchema: true,
featureTable: { featureTable: {
@@ -1178,6 +1243,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
answer: answer:
'GreenLens bietet einen Gesundheitscheck für sichtbare Symptome. Einfach die betroffene Stelle fotografieren — die App analysiert wahrscheinliche Ursachen wie Überwässerung, Lichtmangel oder Nährstoffmangel und empfiehlt den nächsten konkreten Schritt.', 'GreenLens bietet einen Gesundheitscheck für sichtbare Symptome. Einfach die betroffene Stelle fotografieren — die App analysiert wahrscheinliche Ursachen wie Überwässerung, Lichtmangel oder Nährstoffmangel und empfiehlt den nächsten konkreten Schritt.',
}, },
{
question: 'Welche häufigen Zimmerpflanzen erkennt GreenLens?',
answer:
'GreenLens erkennt unter anderem Monstera, Efeutute, Ficus, Bogenhanf, Glücksfeder, Orchideen, Calathea, Drachenbaum, Yucca, Pilea, Kakteen und viele Sukkulenten. Nach dem Scan erhältst du nicht nur den Namen, sondern auch Pflegehinweise für Licht, Wasser und Standort.',
},
{
question: 'Kann GreenLens Zimmerpflanzen-Arten unterscheiden, die ähnlich aussehen?',
answer:
'Bei häufigen Zimmerpflanzen kann GreenLens ähnliche Arten oft gut unterscheiden, wenn das Foto Blattform, Wuchs und Muster klar zeigt. Bei sehr ähnlichen Sorten hilft ein zweites Foto aus anderer Perspektive oder mit sichtbarer Blattstruktur.',
},
{ {
question: 'Ist GreenLens auch für Sukkulenten und Kakteen geeignet?', question: 'Ist GreenLens auch für Sukkulenten und Kakteen geeignet?',
answer: answer:
@@ -1185,6 +1260,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
}, },
], ],
relatedLinks: [ relatedLinks: [
{
href: '/pflanzen-pflege-app',
label: 'Pflanzen Pflege App',
description: 'Gießerinnerungen und Pflegepläne für jede Zimmerpflanze.',
},
{
href: '/pflanzen-krankheiten-erkennen',
label: 'Pflanzenkrankheiten erkennen',
description: 'Hilfe bei gelben Blättern, braunen Spitzen oder Schädlingen.',
},
{ {
href: '/pflanzen-erkennen-app', href: '/pflanzen-erkennen-app',
label: 'Pflanzen erkennen App', label: 'Pflanzen erkennen App',
@@ -1195,11 +1280,6 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
label: 'Pflanzen bestimmen', label: 'Pflanzen bestimmen',
description: 'Pflanze per Foto bestimmen — besser als mit Google.', description: 'Pflanze per Foto bestimmen — besser als mit Google.',
}, },
{
href: '/pflanzen-pflege-app',
label: 'Pflanzen Pflege App',
description: 'Gießerinnerungen und Pflegepläne für jede Zimmerpflanze.',
},
], ],
}, },
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
# Google Search Console SEO Report
Property: https://greenlenspro.com/
Current period: 2026-04-10 to 2026-05-07
Previous period: 2026-03-13 to 2026-04-09
## High-Impression Low-CTR Opportunities
_No rows matched this rule._
## Striking-Distance Queries
_No rows matched this rule._
## Declining Queries
_No rows matched this rule._

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
# Google Search Console SEO Report
Property: https://greenlenspro.com/
Current period: 2026-02-07 to 2026-05-07
Previous period: 2025-11-09 to 2026-02-06
## High-Impression Low-CTR Opportunities
_No rows matched this rule._
## Striking-Distance Queries
_No rows matched this rule._
## Declining Queries
_No rows matched this rule._

600
output/gsc-tools/package-lock.json generated Normal file
View File

@@ -0,0 +1,600 @@
{
"name": "gsc-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"googleapis": "^171.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis": {
"version": "171.4.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz",
"integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.2.0",
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz",
"integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"gaxios": "^7.0.0-rc.4",
"google-auth-library": "^10.1.0",
"qs": "^6.7.0",
"url-template": "^2.0.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/url-template": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
}
}
}

View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"googleapis": "^171.4.0"
}
}

View File

@@ -0,0 +1,8 @@
{
"access_token": "ya29.a0AQvPyINmej164coiATatx7qWBbW51SvMtLZldyig_-6jetTbC743AYJ-yACoU57-NMusVd8B2zs60sy7_idw0YpoS2fph5ApYd9c8epd5ilY6ouG7GyowZmYaw48TNnigOdSBV3Y4iw7HvDN4612zukwvMaitw7BGFZ_-N8Jw7b2sgNQ8dXFzu0JvbTAQMSz4BWKSA4aCgYKAcsSARYSFQHGX2Mi_MimrSXm8CNiUT96FF3jwA0206",
"refresh_token": "1//03yyl4WqvoJJoCgYIARAAGAMSNwF-L9Irp5UITAEjpKQUTloqoKrQtIjyIWFbKY_JCBBQtwJAfDYx0VR-eUkH6edR94thgicPwTY",
"scope": "https://www.googleapis.com/auth/webmasters.readonly",
"token_type": "Bearer",
"refresh_token_expires_in": 604799,
"expiry_date": 1778447285480
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "greenlens", "name": "greenlens",
"version": "2.2.3", "version": "2.2.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "greenlens", "name": "greenlens",
"version": "2.2.3", "version": "2.2.6",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@google/genai": "^1.38.0", "@google/genai": "^1.38.0",

View File

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

17
services/analytics.ts Normal file
View File

@@ -0,0 +1,17 @@
type AnalyticsProperties = Record<string, unknown>;
type SafeAnalytics = {
capture: (event: string, properties?: AnalyticsProperties) => void;
identify: (userId: string, properties?: AnalyticsProperties) => void;
reset: () => void;
};
const noop = () => {};
const safeAnalytics: SafeAnalytics = {
capture: noop,
identify: noop,
reset: noop,
};
export const useSafeAnalytics = (): SafeAnalytics => safeAnalytics;

View File

@@ -100,6 +100,10 @@ export const AuthService = {
await clearStoredSession(); await clearStoredSession();
return null; return null;
} }
if (!AuthDb.getUserById(parsed.userId)) {
await clearStoredSession();
return null;
}
return parsed as AuthSession; return parsed as AuthSession;
} catch { } catch {
await clearStoredSession(); await clearStoredSession();

View File

@@ -13,14 +13,35 @@ export const getDb = (): SQLite.SQLiteDatabase => {
// ─── Schema ──────────────────────────────────────────────────────────────────── // ─── Schema ────────────────────────────────────────────────────────────────────
const repairForeignKeyState = (db: SQLite.SQLiteDatabase): void => {
const deleteOrphans = [
`DELETE FROM user_settings
WHERE NOT EXISTS (SELECT 1 FROM users WHERE users.id = user_settings.user_id)`,
`DELETE FROM user_onboarding_profile
WHERE NOT EXISTS (SELECT 1 FROM users WHERE users.id = user_onboarding_profile.user_id)`,
`DELETE FROM plants
WHERE NOT EXISTS (SELECT 1 FROM users WHERE users.id = plants.user_id)`,
`DELETE FROM lexicon_search_history
WHERE NOT EXISTS (SELECT 1 FROM users WHERE users.id = lexicon_search_history.user_id)`,
];
for (const statement of deleteOrphans) {
try {
db.runSync(statement);
} catch (error) {
console.warn('Failed to repair SQLite orphaned rows.', error);
}
}
};
export const initDatabase = (): void => { export const initDatabase = (): void => {
if (_isDatabaseInitialized) return; if (_isDatabaseInitialized) return;
const db = getDb(); const db = getDb();
try { try {
db.execSync('PRAGMA foreign_keys = OFF;');
db.execSync(` db.execSync(`
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -80,7 +101,6 @@ export const initDatabase = (): void => {
value TEXT NOT NULL value TEXT NOT NULL
); );
`); `);
_isDatabaseInitialized = true;
} catch (error) { } catch (error) {
console.error('Failed to initialize SQLite schema.', error); console.error('Failed to initialize SQLite schema.', error);
throw error; throw error;
@@ -90,6 +110,15 @@ export const initDatabase = (): void => {
try { try {
db.runSync('ALTER TABLE user_settings ADD COLUMN language_set INTEGER NOT NULL DEFAULT 0'); db.runSync('ALTER TABLE user_settings ADD COLUMN language_set INTEGER NOT NULL DEFAULT 0');
} catch (_) { /* column already exists */ } } catch (_) { /* column already exists */ }
// Migration: add health_checks column to existing databases
try {
db.runSync("ALTER TABLE plants ADD COLUMN health_checks TEXT NOT NULL DEFAULT '[]'");
} catch (_) { /* column already exists */ }
repairForeignKeyState(db);
_isDatabaseInitialized = true;
}; };
// ─── App Meta ───────────────────────────────────────────────────────────────── // ─── App Meta ─────────────────────────────────────────────────────────────────
@@ -158,7 +187,12 @@ export const AuthDb = {
}, },
deleteLocalUser(id: number): void { deleteLocalUser(id: number): void {
getDb().runSync('DELETE FROM users WHERE id = ?', [id]); const db = getDb();
db.runSync('DELETE FROM user_settings WHERE user_id = ?', [id]);
db.runSync('DELETE FROM user_onboarding_profile WHERE user_id = ?', [id]);
db.runSync('DELETE FROM plants WHERE user_id = ?', [id]);
db.runSync('DELETE FROM lexicon_search_history WHERE user_id = ?', [id]);
db.runSync('DELETE FROM users WHERE id = ?', [id]);
}, },
}; };