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": {
"name": "GreenLens",
"slug": "greenlens",
"version": "2.2.4",
"version": "2.2.6",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -19,7 +19,7 @@
"supportsTablet": true,
"usesAppleSignIn": true,
"bundleIdentifier": "com.greenlens.app",
"buildNumber": "38",
"buildNumber": "42",
"infoPlist": {
"NSCameraUsageDescription": "GreenLens needs camera access to identify plants.",
"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 { Ionicons } from '@expo/vector-icons';
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 { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
@@ -233,7 +233,7 @@ export default function HomeScreen() {
const { layouts, registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(null);
const tourStartRequestedRef = useRef(false);
const posthog = usePostHog();
const posthog = useSafeAnalytics();
useFocusEffect(
React.useCallback(() => {

View File

@@ -1,12 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { Animated, AppState, Easing, Image, StyleSheet, Text, View } from 'react-native';
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { Redirect, Stack, usePathname, useRouter } from 'expo-router';
import { useShareIntent } from 'expo-share-intent';
import { StatusBar } from 'expo-status-bar';
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 { CoachMarksProvider } from '../context/CoachMarksContext';
import { CoachMarksOverlay } from '../components/CoachMarksOverlay';
@@ -14,14 +11,57 @@ import { useColors } from '../constants/Colors';
import { initDatabase, AppMetaDb } from '../services/database';
import * as SecureStore from 'expo-secure-store';
import * as SplashScreen from 'expo-splash-screen';
import * as ExpoLinking from 'expo-linking';
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.
SplashScreen.preventAutoHideAsync().catch(() => { });
const POSTHOG_API_KEY = process.env.EXPO_PUBLIC_POSTHOG_API_KEY || 'phc_FX6HRgx9NSpS5moxjMF6xyc37yMwjoeu6TbWUqNNKlk';
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> => {
try {
@@ -51,9 +91,6 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
}
};
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() {
const {
isDarkMode,
@@ -65,88 +102,37 @@ function RootLayoutInner() {
isInitializing,
isLoadingPlants,
isLoadingBilling,
syncRevenueCatState,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
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 [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
const posthog = usePostHog();
useEffect(() => {
// RevenueCat requires native store access — not available in Expo Go
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo) {
console.log('[RevenueCat] Skipping configure: running in Expo Go');
return;
}
let mounted = true;
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
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 });
}
setRevenueCatReady(true);
}, []);
useEffect(() => {
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
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();
ExpoLinking.getInitialURL()
.then((url) => {
if (mounted && isShareIntentUrl(url)) {
setShareIntentEnabled(true);
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [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');
const subscription = ExpoLinking.addEventListener('url', ({ url }) => {
if (isShareIntentUrl(url)) {
setShareIntentEnabled(true);
}
});
return () => subscription.remove();
}, [posthog]);
return () => {
mounted = false;
subscription.remove();
};
}, []);
useEffect(() => {
(async () => {
@@ -288,18 +274,24 @@ function RootLayoutInner() {
}
export default function RootLayout() {
initDatabase();
let dbInitError: string | null = null;
try {
initDatabase();
} catch (e) {
dbInitError = String(e);
}
if (dbInitError) {
return <StartupFallback details={`Database init failed: ${dbInitError}`} />;
}
return (
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
host: 'https://us.i.posthog.com',
enableSessionReplay: false,
}}>
<RootErrorBoundary>
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</PostHogProvider>
</RootErrorBoundary>
);
}

View File

@@ -19,7 +19,7 @@ import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native';
import { useSafeAnalytics } from '../../services/analytics';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
@@ -29,7 +29,7 @@ const ONBOARDING_AUTH_BACKGROUND = {
export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog();
const posthog = useSafeAnalytics();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: 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 * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native';
import { useSafeAnalytics } from '../../services/analytics';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
@@ -29,7 +29,7 @@ const ONBOARDING_AUTH_BACKGROUND = {
export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog();
const posthog = useSafeAnalytics();
const pendingPlant = getPendingPlant();
const screenBackground = isDarkMode
? 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 { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { usePostHog } from 'posthog-react-native';
import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
@@ -19,7 +19,7 @@ const PALETTE_SWATCHES: Record<ColorPalette, string[]> = {
export default function CustomizeOnboardingScreen() {
const router = useRouter();
const posthog = usePostHog();
const posthog = useSafeAnalytics();
const {
session,
isDarkMode,

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: 'GreenLens Pflanzen erkennen & Pflege-App',
template: '%s | GreenLens',
template: '%s',
},
description:
'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`,
lastModified: new Date('2026-04-27'),
lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly',
priority: 0.85,
},
{
url: `${baseUrl}/blumen-scanner`,
lastModified: new Date('2026-04-27'),
lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/pflanzen-bestimmen`,
lastModified: new Date('2026-04-27'),
lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly',
priority: 0.8,
},
@@ -72,7 +72,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
},
{
url: `${baseUrl}/vs/google-lens`,
lastModified: new Date('2026-04-27'),
lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly',
priority: 0.75,
},
@@ -108,19 +108,19 @@ export default function sitemap(): MetadataRoute.Sitemap {
},
{
url: `${baseUrl}/pflanzen-krankheiten-erkennen`,
lastModified: new Date('2026-04-27'),
lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/pflanzen-pflege-app`,
lastModified: new Date('2026-04-27'),
lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/zimmerpflanzen-bestimmen`,
lastModified: new Date('2026-04-27'),
lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly',
priority: 0.8,
},

View File

@@ -529,9 +529,9 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
'google-lens': {
slug: 'google-lens',
name: 'Google Lens',
metaTitle: 'GreenLens vs Google Lens Pflanzen erkennen App im Vergleich',
metaTitle: 'GreenLens vs Google Lens: Pflanzen bestimmen | GreenLens',
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:
'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: [
@@ -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.',
whyPeopleCompare: [
'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 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:
'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?',
answer:

View File

@@ -335,16 +335,16 @@ const seoPageProfiles: Record<string, SeoPageProfile> = {
'pflanzen-erkennen-app': {
slug: 'pflanzen-erkennen-app',
metaTitle: 'Pflanzen erkennen App GreenLens',
metaTitle: 'Pflanzen erkennen App per Foto | GreenLens',
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',
h1: 'Pflanzen erkennen App',
tagline: 'Pflanze fotografieren — Name, Pflegeanleitung und Diagnose in einer Sekunde.',
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:
'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',
includeAppSchema: true,
featureTable: {
@@ -408,6 +408,11 @@ const seoPageProfiles: Record<string, SeoPageProfile> = {
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.',
},
{
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?',
answer:
@@ -415,21 +420,31 @@ const seoPageProfiles: Record<string, SeoPageProfile> = {
},
],
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',
label: 'Plant Identifier App (English)',
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> = {
'blumen-scanner': {
slug: 'blumen-scanner',
metaTitle: 'Blumen Scanner App — Pflanzen per Foto erkennen | GreenLens',
metaTitle: 'Blumen Scanner App: Blumen per Foto erkennen | GreenLens',
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',
h1: 'Blumen Scanner',
tagline: 'Blume fotografieren — sofort Name, Herkunft und Pflegeplan.',
tagline: 'Blume fotografieren — sofort Name, Herkunft, Pflegeplan und Erinnerung.',
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:
'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',
includeAppSchema: true,
featureTable: {
@@ -517,6 +532,16 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
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.',
},
{
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?',
answer:
@@ -524,6 +549,11 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
},
],
relatedLinks: [
{
href: '/zimmerpflanzen-bestimmen',
label: 'Zimmerpflanzen bestimmen',
description: 'Zimmerpflanzen per Foto erkennen und passenden Pflegeplan erhalten.',
},
{
href: '/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.',
},
{
href: '/plant-disease-identifier',
label: 'Plant Disease Identifier',
href: '/pflanzen-krankheiten-erkennen',
label: 'Pflanzenkrankheiten erkennen',
description: 'Diagnose für Symptome: wenn eine Blume oder Pflanze krank aussieht.',
},
],
@@ -544,16 +574,16 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
'pflanzen-bestimmen': {
slug: 'pflanzen-bestimmen',
metaTitle: 'Pflanzen bestimmen per Foto — besser als Google | GreenLens',
metaTitle: 'Pflanzen bestimmen per Foto: App statt Google | GreenLens',
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',
h1: 'Pflanzen bestimmen per Foto',
tagline: 'Pflanze fotografieren — sofort bestimmt, mit Pflegeplan und ohne Google-Umweg.',
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.',
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',
includeAppSchema: true,
featureTable: {
@@ -627,6 +657,16 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
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.',
},
{
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?',
answer:
@@ -634,6 +674,11 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
},
],
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',
label: 'Pflanzen erkennen App',
@@ -644,11 +689,6 @@ const additionalSeoPages: Record<string, SeoPageProfile> = {
label: 'Blumen Scanner',
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> = {
'pflanzen-krankheiten-erkennen': {
slug: 'pflanzen-krankheiten-erkennen',
metaTitle: 'Pflanzenkrankheiten erkennen — App für Diagnose & Hilfe | GreenLens',
metaTitle: 'Pflanzenkrankheiten erkennen per Foto | GreenLens',
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',
h1: 'Pflanzenkrankheiten erkennen',
tagline: 'Symptom fotografieren — Ursache erfahren, nächsten Schritt erhalten.',
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:
'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',
includeAppSchema: true,
featureTable: {
@@ -958,6 +998,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
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.',
},
{
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?',
answer:
@@ -985,14 +1035,14 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
'pflanzen-pflege-app': {
slug: 'pflanzen-pflege-app',
metaTitle: 'Pflanzen Pflege App Gießerinnerungen & Gesundheitscheck | GreenLens',
metaTitle: 'Pflanzen Pflege App mit Gießerinnerung | GreenLens',
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',
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:
'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:
'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',
@@ -1009,9 +1059,9 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
'Manuelle Timer ohne pflanzenspezifischen Kontext.',
},
{
feature: 'Gießhinweise',
feature: 'Gießerinnerung',
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:
'Festes Intervall (z.B. alle 7 Tage) unabhängig vom Pflanzenzustand.',
},
@@ -1068,6 +1118,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
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.',
},
{
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?',
answer:
@@ -1075,6 +1135,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
},
],
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',
label: 'Pflanzen erkennen App',
@@ -1085,26 +1155,21 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
label: 'Pflanzenkrankheiten erkennen',
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': {
slug: 'zimmerpflanzen-bestimmen',
metaTitle: 'Zimmerpflanzen bestimmen per Foto — sofort mit Pflegeplan | GreenLens',
metaTitle: 'Zimmerpflanzen bestimmen per Foto | GreenLens',
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',
h1: 'Zimmerpflanzen bestimmen',
tagline: 'Zimmerpflanze fotografieren — Artname, Pflegebedarf und Gießplan in einer Sekunde.',
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.',
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',
includeAppSchema: true,
featureTable: {
@@ -1178,6 +1243,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
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.',
},
{
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?',
answer:
@@ -1185,6 +1260,16 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
},
],
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',
label: 'Pflanzen erkennen App',
@@ -1195,11 +1280,6 @@ const germanSeoPages2: Record<string, SeoPageProfile> = {
label: 'Pflanzen bestimmen',
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",
"version": "2.2.3",
"version": "2.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "greenlens",
"version": "2.2.3",
"version": "2.2.6",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@google/genai": "^1.38.0",

View File

@@ -1,6 +1,6 @@
{
"name": "greenlens",
"version": "2.2.3",
"version": "2.2.6",
"main": "expo-router/entry",
"private": true,
"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

@@ -96,11 +96,15 @@ export const AuthService = {
const raw = await SecureStore.getItemAsync(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AuthSession>;
if (!parsed.token || !parsed.serverUserId || !parsed.userId) {
await clearStoredSession();
return null;
}
return parsed as AuthSession;
if (!parsed.token || !parsed.serverUserId || !parsed.userId) {
await clearStoredSession();
return null;
}
if (!AuthDb.getUserById(parsed.userId)) {
await clearStoredSession();
return null;
}
return parsed as AuthSession;
} catch {
await clearStoredSession();
return null;

View File

@@ -1,36 +1,57 @@
import * as SQLite from 'expo-sqlite';
import { Plant, CareInfo, Language, AppearanceMode, ColorPalette } from '../types';
// ─── DB-Instanz ────────────────────────────────────────────────────────────────
let _db: SQLite.SQLiteDatabase | null = null;
let _isDatabaseInitialized = false;
export const getDb = (): SQLite.SQLiteDatabase => {
if (!_db) _db = SQLite.openDatabaseSync('greenlens.db');
return _db;
};
// ─── Schema ────────────────────────────────────────────────────────────────────
export const initDatabase = (): void => {
if (_isDatabaseInitialized) return;
const db = getDb();
try {
db.execSync(`
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
import * as SQLite from 'expo-sqlite';
import { Plant, CareInfo, Language, AppearanceMode, ColorPalette } from '../types';
// ─── DB-Instanz ────────────────────────────────────────────────────────────────
let _db: SQLite.SQLiteDatabase | null = null;
let _isDatabaseInitialized = false;
export const getDb = (): SQLite.SQLiteDatabase => {
if (!_db) _db = SQLite.openDatabaseSync('greenlens.db');
return _db;
};
// ─── 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 => {
if (_isDatabaseInitialized) return;
const db = getDb();
try {
db.execSync('PRAGMA foreign_keys = OFF;');
db.execSync(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
language TEXT NOT NULL DEFAULT 'de',
@@ -51,103 +72,111 @@ export const initDatabase = (): void => {
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS plants (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
botanical_name TEXT NOT NULL DEFAULT '',
image_uri TEXT NOT NULL DEFAULT '',
date_added TEXT NOT NULL,
care_info TEXT NOT NULL DEFAULT '{}',
last_watered TEXT NOT NULL,
watering_history TEXT NOT NULL DEFAULT '[]',
gallery TEXT NOT NULL DEFAULT '[]',
description TEXT,
notifications_enabled INTEGER NOT NULL DEFAULT 0,
health_checks TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS lexicon_search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
query TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
_isDatabaseInitialized = true;
} catch (error) {
console.error('Failed to initialize SQLite schema.', error);
throw error;
}
// Migration: add language_set column to existing databases
try {
db.runSync('ALTER TABLE user_settings ADD COLUMN language_set INTEGER NOT NULL DEFAULT 0');
} catch (_) { /* column already exists */ }
};
// ─── App Meta ─────────────────────────────────────────────────────────────────
export const AppMetaDb = {
get(key: string): string | null {
const row = getDb().getFirstSync<{ value: string }>(
'SELECT value FROM app_meta WHERE key = ?',
[key],
);
return row?.value ?? null;
},
set(key: string, value: string): void {
getDb().runSync(
'INSERT INTO app_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
[key, value],
);
},
};
// ─── Auth ──────────────────────────────────────────────────────────────────────
// Credential management has moved to the server (server/lib/auth.js + JWT).
// AuthDb only manages the local device user (id=1) used for plants/settings queries.
export interface DbUser {
id: number;
email: string;
name: string;
}
export const AuthDb = {
// Ensures a local device user exists. Maps email to a unique local ID.
ensureLocalUser(email: string, name: string): { id: number } {
const db = getDb();
const normalizedEmail = email.trim().toLowerCase();
// Check if this specific email already has a local account
const existing = db.getFirstSync<{ id: number }>('SELECT id FROM users WHERE email = ?', [normalizedEmail]);
if (existing) {
// Update name just in case it changed on server
db.runSync('UPDATE users SET name = ? WHERE id = ?', [name.trim(), existing.id]);
return { id: existing.id };
}
// Create a new local user if it doesn't exist
const result = db.runSync(
'INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?)',
[normalizedEmail, name.trim(), 'server-auth'],
);
const newUserId = result.lastInsertRowId;
db.runSync('INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)', [newUserId]);
return { id: newUserId };
},
CREATE TABLE IF NOT EXISTS plants (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
botanical_name TEXT NOT NULL DEFAULT '',
image_uri TEXT NOT NULL DEFAULT '',
date_added TEXT NOT NULL,
care_info TEXT NOT NULL DEFAULT '{}',
last_watered TEXT NOT NULL,
watering_history TEXT NOT NULL DEFAULT '[]',
gallery TEXT NOT NULL DEFAULT '[]',
description TEXT,
notifications_enabled INTEGER NOT NULL DEFAULT 0,
health_checks TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS lexicon_search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
query TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
} catch (error) {
console.error('Failed to initialize SQLite schema.', error);
throw error;
}
// Migration: add language_set column to existing databases
try {
db.runSync('ALTER TABLE user_settings ADD COLUMN language_set INTEGER NOT NULL DEFAULT 0');
} 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 ─────────────────────────────────────────────────────────────────
export const AppMetaDb = {
get(key: string): string | null {
const row = getDb().getFirstSync<{ value: string }>(
'SELECT value FROM app_meta WHERE key = ?',
[key],
);
return row?.value ?? null;
},
set(key: string, value: string): void {
getDb().runSync(
'INSERT INTO app_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
[key, value],
);
},
};
// ─── Auth ──────────────────────────────────────────────────────────────────────
// Credential management has moved to the server (server/lib/auth.js + JWT).
// AuthDb only manages the local device user (id=1) used for plants/settings queries.
export interface DbUser {
id: number;
email: string;
name: string;
}
export const AuthDb = {
// Ensures a local device user exists. Maps email to a unique local ID.
ensureLocalUser(email: string, name: string): { id: number } {
const db = getDb();
const normalizedEmail = email.trim().toLowerCase();
// Check if this specific email already has a local account
const existing = db.getFirstSync<{ id: number }>('SELECT id FROM users WHERE email = ?', [normalizedEmail]);
if (existing) {
// Update name just in case it changed on server
db.runSync('UPDATE users SET name = ? WHERE id = ?', [name.trim(), existing.id]);
return { id: existing.id };
}
// Create a new local user if it doesn't exist
const result = db.runSync(
'INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?)',
[normalizedEmail, name.trim(), 'server-auth'],
);
const newUserId = result.lastInsertRowId;
db.runSync('INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)', [newUserId]);
return { id: newUserId };
},
getUserById(id: number): DbUser | null {
const db = getDb();
const user = db.getFirstSync<DbUser>(
@@ -158,56 +187,61 @@ export const AuthDb = {
},
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]);
},
};
// ─── Settings ──────────────────────────────────────────────────────────────────
export const SettingsDb = {
get(userId: number) {
const db = getDb();
db.runSync('INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)', [userId]);
return db.getFirstSync<{
language: string;
language_set: number;
appearance_mode: string;
color_palette: string;
profile_image: string | null;
onboarding_done: number;
}>('SELECT * FROM user_settings WHERE user_id = ?', [userId])!;
},
setLanguage(userId: number, lang: Language) {
getDb().runSync('UPDATE user_settings SET language = ?, language_set = 1 WHERE user_id = ?', [lang, userId]);
},
setAppearanceMode(userId: number, mode: AppearanceMode) {
getDb().runSync('UPDATE user_settings SET appearance_mode = ? WHERE user_id = ?', [mode, userId]);
},
setColorPalette(userId: number, palette: ColorPalette) {
getDb().runSync('UPDATE user_settings SET color_palette = ? WHERE user_id = ?', [palette, userId]);
},
setProfileImage(userId: number, uri: string | null) {
getDb().runSync('UPDATE user_settings SET profile_image = ? WHERE user_id = ?', [uri, userId]);
},
setOnboardingDone(userId: number, done: boolean) {
getDb().runSync(
'UPDATE user_settings SET onboarding_done = ? WHERE user_id = ?',
[done ? 1 : 0, userId],
);
},
setName(userId: number, name: string) {
getDb().runSync('UPDATE users SET name = ? WHERE id = ?', [name.trim(), userId]);
},
};
// ─── Plants ────────────────────────────────────────────────────────────────────
// ─── Settings ──────────────────────────────────────────────────────────────────
export const SettingsDb = {
get(userId: number) {
const db = getDb();
db.runSync('INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)', [userId]);
return db.getFirstSync<{
language: string;
language_set: number;
appearance_mode: string;
color_palette: string;
profile_image: string | null;
onboarding_done: number;
}>('SELECT * FROM user_settings WHERE user_id = ?', [userId])!;
},
setLanguage(userId: number, lang: Language) {
getDb().runSync('UPDATE user_settings SET language = ?, language_set = 1 WHERE user_id = ?', [lang, userId]);
},
setAppearanceMode(userId: number, mode: AppearanceMode) {
getDb().runSync('UPDATE user_settings SET appearance_mode = ? WHERE user_id = ?', [mode, userId]);
},
setColorPalette(userId: number, palette: ColorPalette) {
getDb().runSync('UPDATE user_settings SET color_palette = ? WHERE user_id = ?', [palette, userId]);
},
setProfileImage(userId: number, uri: string | null) {
getDb().runSync('UPDATE user_settings SET profile_image = ? WHERE user_id = ?', [uri, userId]);
},
setOnboardingDone(userId: number, done: boolean) {
getDb().runSync(
'UPDATE user_settings SET onboarding_done = ? WHERE user_id = ?',
[done ? 1 : 0, userId],
);
},
setName(userId: number, name: string) {
getDb().runSync('UPDATE users SET name = ? WHERE id = ?', [name.trim(), userId]);
},
};
// ─── Plants ────────────────────────────────────────────────────────────────────
export const OnboardingProfileDb = {
get(userId: number) {
const db = getDb();
@@ -283,151 +317,151 @@ export const OnboardingProfileDb = {
};
const DEFAULT_CARE_INFO: CareInfo = {
waterIntervalDays: 7,
light: 'Bright indirect light',
temp: '18-25 C',
};
const safeJsonParse = <T,>(value: unknown, fallback: T, fieldName: string, plantId: string): T => {
if (typeof value !== 'string' || !value.trim()) return fallback;
try {
return JSON.parse(value) as T;
} catch (error) {
console.warn('Failed to parse plant JSON field. Falling back to defaults.', {
plantId,
fieldName,
error: error instanceof Error ? error.message : String(error),
});
return fallback;
}
};
const parsePlant = (row: any): Plant => ({
id: row.id,
name: row.name,
botanicalName: row.botanical_name,
imageUri: row.image_uri,
dateAdded: row.date_added,
careInfo: safeJsonParse<CareInfo>(row.care_info, DEFAULT_CARE_INFO, 'care_info', row.id),
lastWatered: row.last_watered,
wateringHistory: safeJsonParse<string[]>(row.watering_history, [], 'watering_history', row.id),
gallery: safeJsonParse<string[]>(row.gallery, [], 'gallery', row.id),
description: row.description ?? undefined,
notificationsEnabled: row.notifications_enabled === 1,
healthChecks: safeJsonParse(row.health_checks, [], 'health_checks', row.id),
});
export const PlantsDb = {
getAll(userId: number): Plant[] {
const rows = getDb().getAllSync<any>(
'SELECT * FROM plants WHERE user_id = ? ORDER BY date_added DESC',
[userId],
);
return rows.map(parsePlant);
},
insert(userId: number, plant: Plant): void {
getDb().runSync(
`INSERT INTO plants
(id, user_id, name, botanical_name, image_uri, date_added,
care_info, last_watered, watering_history, gallery,
description, notifications_enabled, health_checks)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
plant.id,
userId,
plant.name,
plant.botanicalName,
plant.imageUri,
plant.dateAdded,
JSON.stringify(plant.careInfo),
plant.lastWatered,
JSON.stringify(plant.wateringHistory ?? []),
JSON.stringify(plant.gallery ?? []),
plant.description ?? null,
plant.notificationsEnabled ? 1 : 0,
JSON.stringify(plant.healthChecks ?? []),
],
);
},
update(userId: number, plant: Plant): void {
getDb().runSync(
`UPDATE plants SET
name = ?, botanical_name = ?, image_uri = ?,
care_info = ?, last_watered = ?, watering_history = ?,
gallery = ?, description = ?, notifications_enabled = ?, health_checks = ?
WHERE id = ? AND user_id = ?`,
[
plant.name,
plant.botanicalName,
plant.imageUri,
JSON.stringify(plant.careInfo),
plant.lastWatered,
JSON.stringify(plant.wateringHistory ?? []),
JSON.stringify(plant.gallery ?? []),
plant.description ?? null,
plant.notificationsEnabled ? 1 : 0,
JSON.stringify(plant.healthChecks ?? []),
plant.id,
userId,
],
);
},
delete(userId: number, plantId: string): void {
getDb().runSync('DELETE FROM plants WHERE id = ? AND user_id = ?', [plantId, userId]);
},
};
// ─── Lexicon Search History ────────────────────────────────────────────────────
const HISTORY_LIMIT = 10;
const normalize = (v: string) =>
v.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().replace(/\s+/g, ' ');
export const LexiconHistoryDb = {
getAll(userId: number): string[] {
return getDb()
.getAllSync<{ query: string }>(
'SELECT query FROM lexicon_search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
[userId, HISTORY_LIMIT],
)
.map((r) => r.query);
},
add(userId: number, query: string): void {
const trimmed = query.trim();
if (!trimmed) return;
const db = getDb();
// Duplikate entfernen
const normalized = normalize(trimmed);
const existing = db.getAllSync<{ id: number; query: string }>(
'SELECT id, query FROM lexicon_search_history WHERE user_id = ?',
[userId],
);
for (const row of existing) {
if (normalize(row.query) === normalized) {
db.runSync('DELETE FROM lexicon_search_history WHERE id = ?', [row.id]);
}
}
db.runSync(
'INSERT INTO lexicon_search_history (user_id, query) VALUES (?, ?)',
[userId, trimmed],
);
// Limit halten
const oldest = db.getAllSync<{ id: number }>(
'SELECT id FROM lexicon_search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT -1 OFFSET ?',
[userId, HISTORY_LIMIT],
);
for (const row of oldest) {
db.runSync('DELETE FROM lexicon_search_history WHERE id = ?', [row.id]);
}
},
clear(userId: number): void {
getDb().runSync('DELETE FROM lexicon_search_history WHERE user_id = ?', [userId]);
},
};
waterIntervalDays: 7,
light: 'Bright indirect light',
temp: '18-25 C',
};
const safeJsonParse = <T,>(value: unknown, fallback: T, fieldName: string, plantId: string): T => {
if (typeof value !== 'string' || !value.trim()) return fallback;
try {
return JSON.parse(value) as T;
} catch (error) {
console.warn('Failed to parse plant JSON field. Falling back to defaults.', {
plantId,
fieldName,
error: error instanceof Error ? error.message : String(error),
});
return fallback;
}
};
const parsePlant = (row: any): Plant => ({
id: row.id,
name: row.name,
botanicalName: row.botanical_name,
imageUri: row.image_uri,
dateAdded: row.date_added,
careInfo: safeJsonParse<CareInfo>(row.care_info, DEFAULT_CARE_INFO, 'care_info', row.id),
lastWatered: row.last_watered,
wateringHistory: safeJsonParse<string[]>(row.watering_history, [], 'watering_history', row.id),
gallery: safeJsonParse<string[]>(row.gallery, [], 'gallery', row.id),
description: row.description ?? undefined,
notificationsEnabled: row.notifications_enabled === 1,
healthChecks: safeJsonParse(row.health_checks, [], 'health_checks', row.id),
});
export const PlantsDb = {
getAll(userId: number): Plant[] {
const rows = getDb().getAllSync<any>(
'SELECT * FROM plants WHERE user_id = ? ORDER BY date_added DESC',
[userId],
);
return rows.map(parsePlant);
},
insert(userId: number, plant: Plant): void {
getDb().runSync(
`INSERT INTO plants
(id, user_id, name, botanical_name, image_uri, date_added,
care_info, last_watered, watering_history, gallery,
description, notifications_enabled, health_checks)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
plant.id,
userId,
plant.name,
plant.botanicalName,
plant.imageUri,
plant.dateAdded,
JSON.stringify(plant.careInfo),
plant.lastWatered,
JSON.stringify(plant.wateringHistory ?? []),
JSON.stringify(plant.gallery ?? []),
plant.description ?? null,
plant.notificationsEnabled ? 1 : 0,
JSON.stringify(plant.healthChecks ?? []),
],
);
},
update(userId: number, plant: Plant): void {
getDb().runSync(
`UPDATE plants SET
name = ?, botanical_name = ?, image_uri = ?,
care_info = ?, last_watered = ?, watering_history = ?,
gallery = ?, description = ?, notifications_enabled = ?, health_checks = ?
WHERE id = ? AND user_id = ?`,
[
plant.name,
plant.botanicalName,
plant.imageUri,
JSON.stringify(plant.careInfo),
plant.lastWatered,
JSON.stringify(plant.wateringHistory ?? []),
JSON.stringify(plant.gallery ?? []),
plant.description ?? null,
plant.notificationsEnabled ? 1 : 0,
JSON.stringify(plant.healthChecks ?? []),
plant.id,
userId,
],
);
},
delete(userId: number, plantId: string): void {
getDb().runSync('DELETE FROM plants WHERE id = ? AND user_id = ?', [plantId, userId]);
},
};
// ─── Lexicon Search History ────────────────────────────────────────────────────
const HISTORY_LIMIT = 10;
const normalize = (v: string) =>
v.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().replace(/\s+/g, ' ');
export const LexiconHistoryDb = {
getAll(userId: number): string[] {
return getDb()
.getAllSync<{ query: string }>(
'SELECT query FROM lexicon_search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
[userId, HISTORY_LIMIT],
)
.map((r) => r.query);
},
add(userId: number, query: string): void {
const trimmed = query.trim();
if (!trimmed) return;
const db = getDb();
// Duplikate entfernen
const normalized = normalize(trimmed);
const existing = db.getAllSync<{ id: number; query: string }>(
'SELECT id, query FROM lexicon_search_history WHERE user_id = ?',
[userId],
);
for (const row of existing) {
if (normalize(row.query) === normalized) {
db.runSync('DELETE FROM lexicon_search_history WHERE id = ?', [row.id]);
}
}
db.runSync(
'INSERT INTO lexicon_search_history (user_id, query) VALUES (?, ?)',
[userId, trimmed],
);
// Limit halten
const oldest = db.getAllSync<{ id: number }>(
'SELECT id FROM lexicon_search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT -1 OFFSET ?',
[userId, HISTORY_LIMIT],
);
for (const row of oldest) {
db.runSync('DELETE FROM lexicon_search_history WHERE id = ?', [row.id]);
}
},
clear(userId: number): void {
getDb().runSync('DELETE FROM lexicon_search_history WHERE user_id = ?', [userId]);
},
};