diff --git a/__tests__/server/authAccountDeletion.test.js b/__tests__/server/authAccountDeletion.test.js new file mode 100644 index 0000000..f415257 --- /dev/null +++ b/__tests__/server/authAccountDeletion.test.js @@ -0,0 +1,40 @@ +jest.mock('../../server/lib/postgres', () => ({ + get: jest.fn(), + run: jest.fn(), +})); + +const { get, run } = require('../../server/lib/postgres'); +const { deleteAccount, signUp } = require('../../server/lib/auth'); + +describe('server auth account deletion', () => { + beforeEach(() => { + jest.clearAllMocks(); + get.mockResolvedValue(null); + run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); + }); + + it('removes auth and billing rows so the same email can sign up again', async () => { + const email = 'same@example.com'; + + await signUp({}, email, 'First User', 'password-1'); + await deleteAccount({}, 'usr_deleted'); + await signUp({}, email, 'Second User', 'password-2'); + + const authDeletes = run.mock.calls.filter(([, sql]) => ( + typeof sql === 'string' && sql.includes('DELETE FROM auth_users') + )); + expect(authDeletes).toHaveLength(1); + + const billingAccountDeletes = run.mock.calls.filter(([, sql]) => ( + typeof sql === 'string' && sql.includes('DELETE FROM billing_accounts') + )); + expect(billingAccountDeletes).toHaveLength(1); + + const signupChecks = get.mock.calls.filter(([, sql], params) => ( + typeof sql === 'string' + && sql.includes('SELECT id FROM auth_users WHERE LOWER(email)') + && params?.[0] === email + )); + expect(signupChecks).toHaveLength(2); + }); +}); diff --git a/__tests__/services/mockBackendService.test.ts b/__tests__/services/mockBackendService.test.ts index 60e4225..b593e08 100644 --- a/__tests__/services/mockBackendService.test.ts +++ b/__tests__/services/mockBackendService.test.ts @@ -1,5 +1,6 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { mockBackendService } from '../../services/backend/mockBackendService'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { mockBackendService } from '../../services/backend/mockBackendService'; +import { openAiScanService } from '../../services/backend/openAiScanService'; jest.mock('@react-native-async-storage/async-storage', () => ({ getItem: jest.fn(), @@ -11,7 +12,7 @@ const asyncStorageMemory: Record = {}; const mockedAsyncStorage = AsyncStorage as jest.Mocked; -const runScan = async (userId: string, idempotencyKey: string) => { +const runScan = async (userId: string, idempotencyKey: string) => { const settledPromise = mockBackendService.scanPlant({ userId, idempotencyKey, @@ -26,7 +27,33 @@ const runScan = async (userId: string, idempotencyKey: string) => { const settled = await settledPromise; if (!settled.ok) throw settled.error; return settled.value; -}; +}; + +const runHealthCheck = async (userId: string, idempotencyKey: string) => { + const settledPromise = mockBackendService.healthCheck({ + userId, + idempotencyKey, + imageUri: `data:image/jpeg;base64,${idempotencyKey}`, + language: 'en', + plantContext: { + name: 'Monstera', + botanicalName: 'Monstera deliciosa', + careInfo: { + waterIntervalDays: 7, + light: 'Bright indirect light', + temp: '18-24C', + }, + }, + }).then( + value => ({ ok: true as const, value }), + error => ({ ok: false as const, error }), + ); + await Promise.resolve(); + await jest.runAllTimersAsync(); + const settled = await settledPromise; + if (!settled.ok) throw settled.error; + return settled.value; +}; describe('mockBackendService billing simulation', () => { beforeEach(() => { @@ -48,10 +75,11 @@ describe('mockBackendService billing simulation', () => { }); }); - afterEach(() => { - jest.useRealTimers(); - jest.clearAllMocks(); - }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); it('keeps simulatePurchase idempotent for same idempotency key', async () => { const userId = 'test-user-idempotency'; @@ -142,6 +170,40 @@ describe('mockBackendService billing simulation', () => { expect(second.billing.credits.available).toBe(first.billing.credits.available); }); + it('charges one credit for a normal scan after a two-credit health check', async () => { + const userId = 'test-user-health-then-scan-cost'; + jest.spyOn(openAiScanService, 'isConfigured').mockReturnValue(true); + jest.spyOn(openAiScanService, 'analyzePlantHealth').mockResolvedValue({ + overallHealthScore: 72, + status: 'watch', + analysisSummary: 'Mild stress signs are visible.', + likelyIssues: [ + { + title: 'Watering stress', + confidence: 0.62, + details: 'The leaf texture suggests inconsistent watering.', + }, + ], + actionsNow: ['Check soil moisture before watering.'], + plan7Days: ['Take a comparison photo in one week.'], + }); + + await mockBackendService.simulatePurchase({ + userId, + idempotencyKey: 'sub-health-then-scan-cost', + productId: 'monthly_pro', + }); + + const healthCheck = await runHealthCheck(userId, 'health-cost-1'); + expect(healthCheck.creditsCharged).toBe(2); + expect(healthCheck.billing.credits.usedThisCycle).toBe(2); + + const scan = await runScan(userId, 'scan-after-health-cost-1'); + expect(scan.modelPath).toContain('mock-review'); + expect(scan.creditsCharged).toBe(1); + expect(scan.billing.credits.usedThisCycle).toBe(3); + }); + it('blocks free users from real scans', async () => { const userId = 'test-user-credit-limit'; let successfulScans = 0; diff --git a/app.json b/app.json index 62948d6..d72fcdb 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "GreenLens", "slug": "greenlens", - "version": "2.2.3", + "version": "2.2.4", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -15,11 +15,11 @@ "assetBundlePatterns": [ "**/*" ], - "ios": { - "supportsTablet": true, - "usesAppleSignIn": true, - "bundleIdentifier": "com.greenlens.app", - "buildNumber": "37", + "ios": { + "supportsTablet": true, + "usesAppleSignIn": true, + "bundleIdentifier": "com.greenlens.app", + "buildNumber": "38", "infoPlist": { "NSCameraUsageDescription": "GreenLens needs camera access to identify plants.", "NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.", @@ -32,7 +32,7 @@ "backgroundColor": "#111813" }, "package": "com.greenlens.app", - "versionCode": 4, + "versionCode": 5, "permissions": [ "android.permission.CAMERA", "android.permission.RECORD_AUDIO" @@ -47,9 +47,20 @@ "plugins": [ "expo-dev-client", "expo-router", - "expo-camera", - "expo-apple-authentication", - "expo-image-picker", + [ + "expo-share-intent", + { + "iosActivationRules": { + "NSExtensionActivationSupportsImageWithMaxCount": 1 + }, + "androidIntentFilters": ["image/*"], + "iosShareExtensionName": "GreenLens Share", + "iosAppGroupIdentifier": "group.com.greenlens.app" + } + ], + "expo-camera", + "expo-apple-authentication", + "expo-image-picker", "expo-secure-store", "expo-asset", "expo-font", diff --git a/app/_layout.tsx b/app/_layout.tsx index f13c710..3fd5eef 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,58 +1,59 @@ -import { useEffect, useRef, useState } from 'react'; -import { Animated, AppState, Easing, Image, StyleSheet, Text, View } from 'react-native'; -import { Redirect, Stack, usePathname } from 'expo-router'; -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'; -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 { AuthService } from '../services/authService'; -import { PostHogProvider, usePostHog } from 'posthog-react-native'; - -// 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 ensureInstallConsistency = async (): Promise => { - try { - const sqliteMarker = AppMetaDb.get('install_marker_v2'); - const secureMarker = await SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null); - - if (sqliteMarker === '1' && secureMarker === '1') { - return false; // Alles gut, keine Neuinstallation - } - - if (sqliteMarker === '1' || secureMarker === '1') { - // Teilweise vorhanden -> heilen, nicht löschen - AppMetaDb.set('install_marker_v2', '1'); - await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1'); - return false; - } - - // Fresh Install: Alles zurücksetzen - await AuthService.logout(); - await AsyncStorage.removeItem('greenlens_show_tour'); - AppMetaDb.set('install_marker_v2', '1'); - await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1'); - return true; - } catch (error) { - console.error('Failed to initialize install marker', error); - return false; - } -}; - - -import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen'; - +import { useEffect, useRef, useState } from 'react'; +import { Animated, AppState, Easing, Image, StyleSheet, 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'; +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 { AuthService } from '../services/authService'; +import { PostHogProvider, usePostHog } from 'posthog-react-native'; + +// 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 ensureInstallConsistency = async (): Promise => { + try { + const sqliteMarker = AppMetaDb.get('install_marker_v2'); + const secureMarker = await SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null); + + if (sqliteMarker === '1' && secureMarker === '1') { + return false; // Alles gut, keine Neuinstallation + } + + if (sqliteMarker === '1' || secureMarker === '1') { + // Teilweise vorhanden -> heilen, nicht löschen + AppMetaDb.set('install_marker_v2', '1'); + await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1'); + return false; + } + + // Fresh Install: Alles zurücksetzen + await AuthService.logout(); + await AsyncStorage.removeItem('greenlens_show_tour'); + AppMetaDb.set('install_marker_v2', '1'); + await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1'); + return true; + } catch (error) { + console.error('Failed to initialize install marker', error); + return false; + } +}; + + +import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen'; + function RootLayoutInner() { const { isDarkMode, @@ -66,96 +67,117 @@ function RootLayoutInner() { isLoadingBilling, syncRevenueCatState, } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const pathname = usePathname(); - 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; - } - - 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(); - } - } catch (error) { - console.error('Failed to align RevenueCat identity', error); - } - })(); - - 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'); - } - }); - return () => subscription.remove(); - }, [posthog]); - - useEffect(() => { - (async () => { - const didResetSessionForFreshInstall = await ensureInstallConsistency(); - if (didResetSessionForFreshInstall) { - await signOut(); - } - setInstallCheckDone(true); - })(); - }, [signOut]); - + const colors = useColors(isDarkMode, colorPalette); + const pathname = usePathname(); + const router = useRouter(); + const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent(); + 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; + } + + 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(); + } + } catch (error) { + console.error('Failed to align RevenueCat identity', error); + } + })(); + + 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'); + } + }); + return () => subscription.remove(); + }, [posthog]); + + useEffect(() => { + (async () => { + const didResetSessionForFreshInstall = await ensureInstallConsistency(); + if (didResetSessionForFreshInstall) { + await signOut(); + } + setInstallCheckDone(true); + })(); + }, [signOut]); + const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants; + + useEffect(() => { + if (!hasShareIntent || !isAppReady) return; + const sharedImage = shareIntent.files?.find((file) => file.mimeType?.startsWith('image/')); + if (!sharedImage) { + resetShareIntent(); + return; + } + const uri = sharedImage.path; + if (!uri) { + resetShareIntent(); + return; + } + resetShareIntent(); + router.push({ + pathname: '/scanner', + params: { sharedImageUri: uri }, + }); + }, [hasShareIntent, shareIntent, resetShareIntent, router, isAppReady]); const hasActiveEntitlement = isActivatingEntitlement || (billingSummary?.entitlement?.plan === 'pro' && billingSummary?.entitlement?.status === 'active'); @@ -167,22 +189,22 @@ function RootLayoutInner() { || pathname.includes('onboarding') || pathname.includes('scanner') || pathname.includes('profile/billing'); - - let content = null; - - if (isAppReady) { + + let content = null; + + if (isAppReady) { if (!session) { // Only redirect if we are not already on an auth-related page or the scanner if (!isAllowedWithoutSession) { content = ; } else { - content = ( - + content = ( + @@ -190,28 +212,28 @@ function RootLayoutInner() { - - - + + + ); } } else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) { - content = ; + content = ; } else { - content = ( - <> - + content = ( + <> + @@ -219,65 +241,65 @@ function RootLayoutInner() { - - - - - - - - - - - ); - } - } - - return ( - <> - - {content} - {!splashAnimationComplete && ( - setSplashAnimationComplete(true)} - /> - )} - - ); -} - -export default function RootLayout() { - initDatabase(); - - return ( + + + + + + + + + + + ); + } + } + + return ( + <> + + {content} + {!splashAnimationComplete && ( + setSplashAnimationComplete(true)} + /> + )} + + ); +} + +export default function RootLayout() { + initDatabase(); + + return ( - - - - - - - ); -} + + + + + + + ); +} diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 4860496..c6d05c0 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -14,18 +14,25 @@ import { import AsyncStorage from '@react-native-async-storage/async-storage'; import { Ionicons } from '@expo/vector-icons'; import { router } from 'expo-router'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { useApp } from '../../context/AppContext'; +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'; +const ONBOARDING_AUTH_BACKGROUND = { + light: '#fbfaf3', + dark: '#0a110b', +}; + export default function LoginScreen() { const { isDarkMode, colorPalette, hydrateSession, t } = useApp(); const colors = useColors(isDarkMode, colorPalette); const posthog = usePostHog(); + const screenBackground = isDarkMode + ? ONBOARDING_AUTH_BACKGROUND.dark + : ONBOARDING_AUTH_BACKGROUND.light; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -112,7 +119,7 @@ export default function LoginScreen() { await AsyncStorage.setItem('greenlens_show_tour', 'true'); } posthog.capture('apple_login_succeeded', { surface: 'login' }); - router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)'); + router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)'); } catch (e: any) { if (e?.code === 'ERR_REQUEST_CANCELED') { return; @@ -130,21 +137,26 @@ export default function LoginScreen() { }; return ( - - - + - {/* Logo / Header */} - - + router.back()} + > + + + GreenLens @@ -274,16 +286,27 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, paddingVertical: 48, }, - header: { - alignItems: 'center', - marginBottom: 32, - }, - logoIcon: { - width: 56, - height: 56, - borderRadius: 14, - marginBottom: 16, - }, + header: { + alignItems: 'center', + marginBottom: 32, + }, + backBtn: { + position: 'absolute', + left: 0, + top: 0, + width: 40, + height: 40, + borderRadius: 20, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + }, + logoIcon: { + width: 84, + height: 84, + borderRadius: 20, + marginBottom: 16, + }, appName: { fontSize: 30, fontWeight: '700', diff --git a/app/auth/signup.tsx b/app/auth/signup.tsx index 2a80249..a69690e 100644 --- a/app/auth/signup.tsx +++ b/app/auth/signup.tsx @@ -12,21 +12,28 @@ import { Image, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { router } from 'expo-router'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; 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'; + +const ONBOARDING_AUTH_BACKGROUND = { + light: '#fbfaf3', + dark: '#0a110b', +}; export default function SignupScreen() { const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp(); const colors = useColors(isDarkMode, colorPalette); const posthog = usePostHog(); const pendingPlant = getPendingPlant(); + const screenBackground = isDarkMode + ? ONBOARDING_AUTH_BACKGROUND.dark + : ONBOARDING_AUTH_BACKGROUND.light; const [name, setName] = useState(''); const [email, setEmail] = useState(''); @@ -78,7 +85,7 @@ export default function SignupScreen() { await hydrateSession(session); // Flag setzen: Tour beim nächsten App-Öffnen anzeigen await AsyncStorage.setItem('greenlens_show_tour', 'true'); - router.replace('/profile/billing'); + router.replace('/onboarding/source'); } catch (e: any) { if (e.message === 'EMAIL_TAKEN') { setError(t.errEmailTaken); @@ -127,7 +134,7 @@ export default function SignupScreen() { await hydrateSession(session); await AsyncStorage.setItem('greenlens_show_tour', 'true'); posthog.capture('apple_login_succeeded', { surface: 'signup' }); - router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)'); + router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)'); } catch (e: any) { if (e?.code === 'ERR_REQUEST_CANCELED') { return; @@ -145,12 +152,11 @@ export default function SignupScreen() { }; return ( - - - + new Animated.Value(0))).current; - - useEffect(() => { - Animated.sequence([ - Animated.parallel([ - Animated.timing(logoAnim, { toValue: 1, duration: 700, useNativeDriver: true }), - Animated.spring(logoScale, { toValue: 1, tension: 50, friction: 8, useNativeDriver: true }), - ]), - Animated.stagger(100, featureAnims.map(anim => - Animated.timing(anim, { toValue: 1, duration: 400, useNativeDriver: true }) - )), - Animated.timing(buttonsAnim, { toValue: 1, duration: 400, useNativeDriver: true }), - ]).start(); - }, []); - - return ( - - - - {/* Logo-Bereich */} - - - - - - GreenLens - - {t.onboardingTagline} - - - - {/* Feature-Liste */} - - {FEATURES.map((feat, i) => ( - - - - - {feat.label} - - ))} - - - {/* Buttons */} - - router.push('/scanner')} - activeOpacity={0.85} - > - - - {t.onboardingScanBtn} - - - - - router.push('/auth/signup')} - activeOpacity={0.82} - > - - {t.onboardingRegister} - - - - router.push('/auth/login')} - activeOpacity={0.82} - > - - {t.onboardingLogin} - - - - - router.push('/profile/billing')} - activeOpacity={0.82} - > - - - View Subscription Plans & Pricing - - - - - {t.onboardingDisclaimer} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingHorizontal: 32, - paddingTop: SCREEN_H * 0.12, - paddingBottom: 40, - }, - heroSection: { - alignItems: 'center', - marginBottom: 40, - }, - iconContainer: { - width: 120, - height: 120, - borderRadius: 28, - backgroundColor: '#fff', - elevation: 8, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, - shadowRadius: 12, - marginBottom: 24, - overflow: 'hidden', - }, - appIcon: { - width: '100%', - height: '100%', - }, - appName: { - fontSize: 40, - fontWeight: '900', - letterSpacing: -1.5, - marginBottom: 4, - }, - tagline: { - fontSize: 17, - fontWeight: '500', - opacity: 0.8, - }, - featuresSection: { - gap: 8, - flex: 1, - justifyContent: 'center', - }, - featureRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 16, - borderWidth: 1, - }, - featureIcon: { - width: 36, - height: 36, - borderRadius: 10, - justifyContent: 'center', - alignItems: 'center', - }, - featureText: { - flex: 1, - fontSize: 13, - fontWeight: '600', - letterSpacing: 0.1, - }, - buttonsSection: { - gap: 16, - marginTop: 20, - }, - primaryBtn: { - height: 58, - borderRadius: 20, - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: 12, - elevation: 4, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - primaryBtnText: { - fontSize: 17, - fontWeight: '700', - }, - authActions: { - flexDirection: 'row', - gap: 12, - }, - secondaryBtn: { - flex: 1, - height: 54, - borderRadius: 20, - borderWidth: 1.5, - justifyContent: 'center', - alignItems: 'center', - }, - secondaryBtnText: { - fontSize: 15, - fontWeight: '600', - }, - plansBtn: { - height: 48, - borderRadius: 16, - borderWidth: 1.5, - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: 8, - }, - plansBtnText: { - fontSize: 14, - fontWeight: '600', - }, - disclaimer: { - fontSize: 12, - textAlign: 'center', - opacity: 0.6, - marginTop: 8, - }, -}); - +import React from 'react'; +import { + Image, + ImageBackground, + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, + useWindowDimensions, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import Svg, { Path } from 'react-native-svg'; +import { useApp } from '../context/AppContext'; + +type Feature = { + icon: keyof typeof Ionicons.glyphMap; + title: string; + description: string; +}; + +export default function OnboardingScreen() { + const { t } = useApp(); + const { height, width } = useWindowDimensions(); + const compact = height < 760; + const sheetTop = compact ? 142 : 156; + const waveHeight = compact ? 148 : 170; + const bodyOffset = waveHeight - 2; + const contentTop = compact ? 94 : 108; + + const features: Feature[] = [ + { + icon: 'scan-outline', + title: t.welcomeFeatureIdentifyTitle, + description: t.welcomeFeatureIdentifyDesc, + }, + { + icon: 'notifications-outline', + title: t.welcomeFeatureReminderTitle, + description: t.welcomeFeatureReminderDesc, + }, + { + icon: 'book-outline', + title: t.welcomeFeatureLibraryTitle, + description: t.welcomeFeatureLibraryDesc, + }, + ]; + + return ( + + + + + + + + + GreenLens + + + + + + + + + + + + + {t.welcomeHeadline} + + {t.welcomeSubheadline} + + + {features.map((feature, index) => ( + + + + + + {feature.title} + {feature.description} + + + ))} + + + router.push('/scanner')} + activeOpacity={0.86} + > + + {t.welcomeDemoScan} + + + + + router.push('/auth/signup')} + activeOpacity={0.82} + > + {t.onboardingRegister} + + + router.push('/auth/login')} + activeOpacity={0.82} + > + + {t.onboardingLogin} + + + + + router.push('/profile/billing')} + activeOpacity={0.8} + > + + {t.welcomeSubscriptionPlans} + + + + {t.welcomeLegal} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0a110b', + }, + heroImage: { + height: '60%', + minHeight: 430, + }, + heroImageContent: { + backgroundColor: '#0a110b', + transform: [{ scale: 1.04 }], + }, + heroShadeTop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.08)', + }, + heroShadeBottom: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: 190, + backgroundColor: 'rgba(7,12,7,0.2)', + }, + safeArea: { + flex: 1, + }, + brandRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 15, + paddingHorizontal: 30, + paddingTop: 62, + }, + brandRowCompact: { + paddingTop: 42, + }, + logo: { + width: 68, + height: 68, + borderRadius: 16, + backgroundColor: '#fff', + }, + brandName: { + color: '#f8f7ef', + fontSize: 36, + fontWeight: '900', + }, + brandAccent: { + color: '#9bc76e', + }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + }, + sheetWave: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + }, + sheetBody: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + backgroundColor: '#fbfaf3', + }, + sheetContent: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: 24, + paddingBottom: 10, + }, + sheetContentCompact: { + paddingHorizontal: 22, + paddingBottom: 8, + }, + headline: { + color: '#101c12', + fontSize: 40, + lineHeight: 43, + fontWeight: '900', + marginBottom: 6, + maxWidth: 310, + }, + headlineCompact: { + fontSize: 34, + lineHeight: 37, + }, + subheadline: { + color: '#5f625d', + fontSize: 15, + lineHeight: 19, + fontWeight: '500', + marginBottom: 11, + }, + features: { + marginBottom: 10, + }, + featureRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 11, + paddingVertical: 6, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(16,28,18,0.14)', + }, + featureRowLast: { + borderBottomWidth: 0, + }, + featureIcon: { + width: 46, + height: 46, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#173817', + }, + featureCopy: { + flex: 1, + }, + featureTitle: { + color: '#101c12', + fontSize: 16, + fontWeight: '800', + marginBottom: 2, + }, + featureDescription: { + color: '#696b65', + fontSize: 13, + lineHeight: 16, + fontWeight: '500', + }, + demoButton: { + height: 60, + borderRadius: 7, + backgroundColor: '#437824', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + marginBottom: 8, + }, + demoButtonText: { + color: '#f8f7ef', + fontSize: 21, + fontWeight: '800', + }, + authRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 8, + }, + authButton: { + flex: 1, + height: 50, + borderRadius: 7, + borderWidth: 1.4, + borderColor: '#4b7c31', + alignItems: 'center', + justifyContent: 'center', + }, + loginButton: { + borderColor: '#101c12', + }, + authButtonText: { + color: '#4b7c31', + fontSize: 17, + fontWeight: '700', + }, + loginButtonText: { + color: '#101c12', + }, + subscriptionLink: { + minHeight: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + marginBottom: 7, + }, + subscriptionText: { + color: '#4b7c31', + fontSize: 15, + fontWeight: '800', + textAlign: 'center', + }, + legalText: { + color: '#6b6d68', + fontSize: 11, + lineHeight: 14, + fontWeight: '500', + textAlign: 'center', + }, +}); diff --git a/app/onboarding/experience.tsx b/app/onboarding/experience.tsx index 1c7ac0d..323465c 100644 --- a/app/onboarding/experience.tsx +++ b/app/onboarding/experience.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; @@ -9,18 +9,61 @@ import { useColors } from '../../constants/Colors'; import { useApp } from '../../context/AppContext'; import { OnboardingProgressService } from '../../services/onboardingProgressService'; +const ONBOARDING_BACKGROUND = { + light: '#fbfaf3', + dark: '#0a110b', +}; + const EXPERIENCE_OPTIONS = [ { id: 'beginner', icon: 'leaf-outline' as const }, { id: 'intermediate', icon: 'sunny-outline' as const }, { id: 'advanced', icon: 'flask-outline' as const }, ]; +const getExperienceScreenCopy = (language: 'de' | 'en' | 'es') => { + if (language === 'de') { + return { + step: 'Schritt 3 von 4', + heroBadge: 'Pflege-Tiefe', + subtitles: { + beginner: 'Klare Sprache, sichere Defaults, weniger Fachbegriffe.', + intermediate: 'Praktische Schritte mit genug Kontext.', + advanced: 'Mehr botanische Details und engere Diagnose.', + }, + }; + } + + if (language === 'es') { + return { + step: 'Paso 3 de 4', + heroBadge: 'Nivel de cuidado', + subtitles: { + beginner: 'Lenguaje claro y recomendaciones seguras.', + intermediate: 'Pasos practicos con suficiente contexto.', + advanced: 'Mas detalle botanico y diagnostico preciso.', + }, + }; + } + + return { + step: 'Step 3 of 4', + heroBadge: 'Care depth', + subtitles: { + beginner: 'Clear language, fewer assumptions, safer defaults.', + intermediate: 'Practical care steps with enough detail.', + advanced: 'More botanical context and tighter diagnosis.', + }, + }; +}; + export default function OnboardingExperienceScreen() { const router = useRouter(); const posthog = usePostHog(); - const { session, isDarkMode, colorPalette, t } = useApp(); + const { session, isDarkMode, colorPalette, language, t } = useApp(); const colors = useColors(isDarkMode, colorPalette); + const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; const [selectedLevel, setSelectedLevel] = useState(null); + const copy = getExperienceScreenCopy(language); const levelLabels = useMemo( () => ({ @@ -39,17 +82,29 @@ export default function OnboardingExperienceScreen() { posthog.capture('onboarding_experience_completed', { experience_level: level ?? 'skipped', }); - router.replace('/(tabs)'); + router.replace('/onboarding/health-check'); }; return ( - - + + {isDarkMode ? : null} - - + + {copy.step} + + + + + {copy.heroBadge} + + {t.experienceOnboardingTitle} {t.experienceOnboardingSubtitle} @@ -73,7 +128,12 @@ export default function OnboardingExperienceScreen() { - {levelLabels[option.id as keyof typeof levelLabels]} + + {levelLabels[option.id as keyof typeof levelLabels]} + + {copy.subtitles[option.id as keyof typeof copy.subtitles]} + + {isActive && } ); @@ -104,26 +164,35 @@ export default function OnboardingExperienceScreen() { const styles = StyleSheet.create({ container: { flex: 1 }, - safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }, - header: { alignItems: 'center', gap: 10, marginBottom: 28 }, - headerIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' }, - title: { fontSize: 28, fontWeight: '800', textAlign: 'center', lineHeight: 32 }, - subtitle: { fontSize: 14, textAlign: 'center', lineHeight: 20, maxWidth: 320 }, - options: { gap: 12, flex: 1 }, + safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 }, + header: { alignItems: 'center', gap: 9, marginBottom: 14 }, + stepPill: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 }, + stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 }, + heroPreview: { width: '100%', height: 175, borderRadius: 24, borderWidth: 1, overflow: 'hidden', justifyContent: 'flex-end', alignItems: 'flex-start' }, + heroImage: { borderRadius: 24 }, + heroOverlay: { ...StyleSheet.absoluteFillObject }, + heroMetric: { margin: 12, borderRadius: 999, borderWidth: 1, paddingHorizontal: 11, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 6 }, + heroMetricText: { fontSize: 12, fontWeight: '800' }, + title: { fontSize: 25, fontWeight: '800', textAlign: 'center', lineHeight: 29 }, + subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 18, maxWidth: 320 }, + options: { gap: 8, flex: 1 }, optionCard: { - minHeight: 64, - borderRadius: 18, + flex: 1, + borderRadius: 15, borderWidth: 1.5, flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 16, - gap: 12, + paddingHorizontal: 14, + paddingVertical: 16, + gap: 10, }, - optionIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, - optionLabel: { flex: 1, fontSize: 15, fontWeight: '600' }, - footer: { flexDirection: 'row', gap: 12, marginTop: 16 }, - secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, + optionIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' }, + optionCopy: { flex: 1, gap: 3 }, + optionLabel: { fontSize: 14, fontWeight: '700' }, + optionSubtitle: { fontSize: 10.5, lineHeight: 14 }, + footer: { flexDirection: 'row', gap: 12, marginTop: 10 }, + secondaryBtn: { flex: 1, height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, secondaryBtnText: { fontSize: 15, fontWeight: '600' }, - primaryBtn: { flex: 1.2, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, + primaryBtn: { flex: 1.2, height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, primaryBtnText: { fontSize: 15, fontWeight: '700' }, }); diff --git a/app/onboarding/goal.tsx b/app/onboarding/goal.tsx index 50ee04f..896fe4a 100644 --- a/app/onboarding/goal.tsx +++ b/app/onboarding/goal.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; @@ -9,6 +9,11 @@ import { useColors } from '../../constants/Colors'; import { useApp } from '../../context/AppContext'; import { OnboardingProgressService } from '../../services/onboardingProgressService'; +const ONBOARDING_BACKGROUND = { + light: '#fbfaf3', + dark: '#0a110b', +}; + const GOAL_OPTIONS = [ { id: 'identify', icon: 'scan-outline' as const }, { id: 'care', icon: 'water-outline' as const }, @@ -16,12 +21,53 @@ const GOAL_OPTIONS = [ { id: 'learn', icon: 'book-outline' as const }, ]; +const getGoalScreenCopy = (language: 'de' | 'en' | 'es') => { + if (language === 'de') { + return { + step: 'Schritt 2 von 4', + heroBadge: 'Erstes Ziel', + subtitles: { + identify: 'Schnell erkennen, Pflege danach klaeren.', + care: 'Aus Symptomen konkrete Schritte machen.', + collection: 'Eine saubere Pflanzenbibliothek aufbauen.', + learn: 'Pflanzenwissen einfacher einsortieren.', + }, + }; + } + + if (language === 'es') { + return { + step: 'Paso 2 de 4', + heroBadge: 'Primer objetivo', + subtitles: { + identify: 'Respuesta rapida primero, cuidado despues.', + care: 'Convertir sintomas en pasos claros.', + collection: 'Crear una biblioteca de plantas ordenada.', + learn: 'Aprender plantas con explicaciones simples.', + }, + }; + } + + return { + step: 'Step 2 of 4', + heroBadge: 'First goal', + subtitles: { + identify: 'Fast answer first, care details after.', + care: 'Turn symptoms into a clear next step.', + collection: 'Build a tidy plant library over time.', + learn: 'Browse plants with simpler explanations.', + }, + }; +}; + export default function OnboardingGoalScreen() { const router = useRouter(); const posthog = usePostHog(); - const { session, isDarkMode, colorPalette, t } = useApp(); + const { session, isDarkMode, colorPalette, language, t } = useApp(); const colors = useColors(isDarkMode, colorPalette); + const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; const [selectedGoal, setSelectedGoal] = useState(null); + const copy = getGoalScreenCopy(language); const goalLabels = useMemo( () => ({ @@ -45,13 +91,25 @@ export default function OnboardingGoalScreen() { }; return ( - - + + {isDarkMode ? : null} - - + + {copy.step} + + + + + {copy.heroBadge} + + {t.goalOnboardingTitle} {t.goalOnboardingSubtitle} @@ -75,7 +133,12 @@ export default function OnboardingGoalScreen() { - {goalLabels[option.id as keyof typeof goalLabels]} + + {goalLabels[option.id as keyof typeof goalLabels]} + + {copy.subtitles[option.id as keyof typeof copy.subtitles]} + + {isActive && } ); @@ -106,26 +169,35 @@ export default function OnboardingGoalScreen() { const styles = StyleSheet.create({ container: { flex: 1 }, - safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }, - header: { alignItems: 'center', gap: 10, marginBottom: 28 }, - headerIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' }, - title: { fontSize: 28, fontWeight: '800', textAlign: 'center', lineHeight: 32 }, - subtitle: { fontSize: 14, textAlign: 'center', lineHeight: 20, maxWidth: 320 }, - options: { gap: 12, flex: 1 }, + safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 }, + header: { alignItems: 'center', gap: 9, marginBottom: 14 }, + stepPill: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 }, + stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 }, + heroPreview: { width: '100%', height: 175, borderRadius: 24, borderWidth: 1, overflow: 'hidden', justifyContent: 'flex-end', alignItems: 'flex-start' }, + heroImage: { borderRadius: 24 }, + heroOverlay: { ...StyleSheet.absoluteFillObject }, + heroBadge: { margin: 12, borderRadius: 999, paddingHorizontal: 11, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 6 }, + heroBadgeText: { fontSize: 12, fontWeight: '800' }, + title: { fontSize: 25, fontWeight: '800', textAlign: 'center', lineHeight: 29 }, + subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 18, maxWidth: 320 }, + options: { gap: 8, flex: 1 }, optionCard: { - minHeight: 64, - borderRadius: 18, + flex: 1, + borderRadius: 15, borderWidth: 1.5, flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 16, - gap: 12, + paddingHorizontal: 14, + paddingVertical: 12, + gap: 10, }, - optionIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, - optionLabel: { flex: 1, fontSize: 15, fontWeight: '600' }, - footer: { flexDirection: 'row', gap: 12, marginTop: 16 }, - secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, + optionIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' }, + optionCopy: { flex: 1, gap: 3 }, + optionLabel: { fontSize: 14, fontWeight: '700' }, + optionSubtitle: { fontSize: 10.5, lineHeight: 14 }, + footer: { flexDirection: 'row', gap: 12, marginTop: 10 }, + secondaryBtn: { flex: 1, height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, secondaryBtnText: { fontSize: 15, fontWeight: '600' }, - primaryBtn: { flex: 1.2, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, + primaryBtn: { flex: 1.2, height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, primaryBtnText: { fontSize: 15, fontWeight: '700' }, }); diff --git a/app/onboarding/health-check.tsx b/app/onboarding/health-check.tsx new file mode 100644 index 0000000..98a6224 --- /dev/null +++ b/app/onboarding/health-check.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { usePostHog } from 'posthog-react-native'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { useColors } from '../../constants/Colors'; +import { useApp } from '../../context/AppContext'; + +const ONBOARDING_BACKGROUND = { + light: '#fbfaf3', + dark: '#0a110b', +}; + +const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => { + if (language === 'de') { + return { + step: 'Schritt 4 von 4', + title: 'Wo ist der Health-Scan?', + subtitle: 'Du findest ihn auf jeder gespeicherten Pflanze, direkt unter der Beschreibung.', + buttonPreview: 'Health-Scan starten', + cta: 'Weiter', + skip: 'Spaeter', + flow: ['Pflanze scannen', 'Speichern', 'Detailseite oeffnen', 'Health-Scan starten'], + outputTitle: 'Was du danach bekommst', + outputs: [ + 'Gesundheits-Score mit Status: stabil, beobachten oder kritisch.', + 'Ausfuehrliche Analyse mit sichtbaren Hinweisen und Unsicherheit.', + 'Wahrscheinlichste Ursachen mit Confidence-Werten.', + 'Sofortmassnahmen plus konkreter 7-Tage-Pflegeplan.', + ], + guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.', + }; + } + + if (language === 'es') { + return { + step: 'Paso 4 de 4', + title: 'Donde esta el health-scan?', + subtitle: 'Lo encuentras en cada planta guardada, justo debajo de la descripcion.', + buttonPreview: 'Iniciar health-scan', + cta: 'Continuar', + skip: 'Mas tarde', + flow: ['Escanear planta', 'Guardar', 'Abrir detalle', 'Iniciar health-scan'], + outputTitle: 'Que recibes despues', + outputs: [ + 'Puntaje de salud con estado: estable, observar o critico.', + 'Analisis detallado con senales visibles e incertidumbre.', + 'Causas probables con valores de confianza.', + 'Acciones inmediatas y plan concreto de 7 dias.', + ], + guidanceNote: 'Consejo: fotografia la planta completa, el reverso de las hojas y el sustrato. Cuanto mas clara sea la foto, mas preciso sera el plan.', + }; + } + + return { + step: 'Step 4 of 4', + title: 'Where is the health scan?', + subtitle: 'It lives on every saved plant, directly below the plant description.', + buttonPreview: 'Start health scan', + cta: 'Continue', + skip: 'Later', + flow: ['Scan plant', 'Save', 'Open detail', 'Start health scan'], + outputTitle: 'What you get after', + outputs: [ + 'Health score with stable, watch, or critical status.', + 'Detailed analysis with visible signals and uncertainty.', + 'Most likely causes with confidence values.', + 'Immediate actions plus a concrete 7-day care plan.', + ], + guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.', + }; +}; + +export default function HealthCheckOnboardingScreen() { + const router = useRouter(); + const posthog = usePostHog(); + const { isDarkMode, colorPalette, language, billingSummary } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; + const copy = getHealthOnboardingCopy(language); + + const finish = (skipped = false) => { + posthog.capture('onboarding_health_check_explained', { + skipped, + plan: billingSummary?.entitlement?.plan ?? 'free', + }); + const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' + && billingSummary?.entitlement?.status === 'active'; + router.replace(hasActiveEntitlement ? '/(tabs)' : '/profile/billing'); + }; + + return ( + + {isDarkMode ? : null} + + + + {copy.step} + + {copy.title} + {copy.subtitle} + + + + + + + + + {copy.flow.map((item, index) => ( + + + + {index + 1} + + + {item} + + ))} + + + + {copy.outputTitle} + {copy.outputs.map((item) => ( + + + {item} + + ))} + + + + + {copy.guidanceNote} + + + + + finish(true)} + > + {copy.skip} + + finish(false)}> + {copy.cta} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }, + header: { gap: 9, marginBottom: 18 }, + stepPill: { alignSelf: 'flex-start', borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 }, + stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 }, + title: { fontSize: 30, lineHeight: 34, fontWeight: '900' }, + subtitle: { fontSize: 14, lineHeight: 20 }, + content: { gap: 14, paddingBottom: 12 }, + illustration: { height: 230, borderRadius: 28, borderWidth: 1, justifyContent: 'center', overflow: 'hidden' }, + illustrationImage: { borderRadius: 28 }, + illustrationOverlay: { ...StyleSheet.absoluteFillObject }, + phone: { width: 178, minHeight: 156, borderRadius: 26, borderWidth: 1, padding: 12, gap: 10, marginLeft: 16 }, + phoneHeader: { height: 58, borderRadius: 18, justifyContent: 'flex-end', padding: 10 }, + phoneTitle: { fontSize: 13, fontWeight: '800' }, + phoneRows: { gap: 8 }, + phoneRowLong: { height: 8, borderRadius: 999 }, + phoneRowShort: { width: '66%', height: 8, borderRadius: 999 }, + healthButtonPreview: { height: 34, borderRadius: 14, borderWidth: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 }, + healthButtonText: { fontSize: 10, fontWeight: '800' }, + scanCard: { position: 'absolute', right: 16, bottom: 20, width: 136, borderRadius: 20, borderWidth: 1, padding: 14, gap: 7 }, + scanScore: { fontSize: 25, lineHeight: 29, fontWeight: '900' }, + scanLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase' }, + scanLine: { height: 8, borderRadius: 999 }, + scanLineShort: { width: '68%', height: 8, borderRadius: 999 }, + flowCard: { borderRadius: 18, borderWidth: 1, padding: 14, gap: 10 }, + flowRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + flowIndex: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, + flowIndexText: { fontSize: 12, fontWeight: '900' }, + flowText: { flex: 1, fontSize: 14, fontWeight: '700' }, + outputCard: { borderRadius: 18, borderWidth: 1, padding: 16, gap: 11 }, + outputTitle: { fontSize: 15, fontWeight: '800' }, + outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 }, + outputText: { flex: 1, fontSize: 13, lineHeight: 18 }, + guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 }, + guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' }, + footer: { flexDirection: 'row', gap: 12, marginTop: 12 }, + secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, + secondaryBtnText: { fontSize: 15, fontWeight: '600' }, + primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, + primaryBtnText: { fontSize: 15, fontWeight: '700' }, +}); diff --git a/app/onboarding/source.tsx b/app/onboarding/source.tsx index 062004f..50b87d8 100644 --- a/app/onboarding/source.tsx +++ b/app/onboarding/source.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; @@ -9,21 +9,82 @@ import { useColors } from '../../constants/Colors'; import { useApp } from '../../context/AppContext'; import { OnboardingProgressService } from '../../services/onboardingProgressService'; +const ONBOARDING_BACKGROUND = { + light: '#fbfaf3', + dark: '#0a110b', +}; + const SOURCE_OPTIONS = [ - { id: 'app_store', icon: 'phone-portrait-outline' as const }, - { id: 'instagram', icon: 'logo-instagram' as const }, - { id: 'tiktok', icon: 'musical-notes-outline' as const }, - { id: 'friend', icon: 'people-outline' as const }, - { id: 'search', icon: 'search-outline' as const }, - { id: 'other', icon: 'ellipsis-horizontal-circle-outline' as const }, + { id: 'app_store', icon: 'storefront-outline' as const, signal: 'organic_store' }, + { id: 'instagram', icon: 'logo-instagram' as const, signal: 'social_visual' }, + { id: 'tiktok', icon: 'musical-notes-outline' as const, signal: 'social_video' }, + { id: 'friend', icon: 'people-outline' as const, signal: 'referral' }, + { id: 'search', icon: 'search-outline' as const, signal: 'high_intent_search' }, + { id: 'other', icon: 'ellipsis-horizontal-circle-outline' as const, signal: 'unclassified' }, ]; +const getSourceOnboardingCopy = (language: 'de' | 'en' | 'es') => { + if (language === 'de') { + return { + step: 'Schritt 1 von 4', + heroTitle: 'Dein Start wird danach personalisiert.', + heroMeta: 'Scan, Sammlung und Health-Check passen sich deinem Ziel an.', + valueTitle: 'Warum wir fragen', + valueBody: 'Die Antwort hilft, deinen Einstieg auf das auszurichten, was dich wirklich hierher gebracht hat.', + subtitles: { + app_store: 'Du hast aktiv nach Pflanzen- oder Pflegehilfe gesucht.', + instagram: 'Du kamst ueber visuelle Pflanzen-Inhalte.', + tiktok: 'Du kamst ueber kurze Videos oder Creator.', + friend: 'Persoenliche Empfehlung, hoher Vertrauens-Intent.', + search: 'Konkretes Problem oder schneller Pflanzen-Check.', + other: 'Passt nicht sauber in die anderen Quellen.', + }, + }; + } + + if (language === 'es') { + return { + step: 'Paso 1 de 4', + heroTitle: 'Tu inicio se adapta despues.', + heroMeta: 'Escaneo, coleccion y health-check segun tu objetivo.', + valueTitle: 'Por que preguntamos', + valueBody: 'La respuesta ayuda a adaptar el inicio a lo que realmente te trajo aqui.', + subtitles: { + app_store: 'Buscaste ayuda para plantas o cuidado.', + instagram: 'Llegaste desde contenido visual de plantas.', + tiktok: 'Llegaste desde videos cortos o creadores.', + friend: 'Recomendacion personal con alta confianza.', + search: 'Problema concreto o chequeo rapido.', + other: 'No encaja en las demas fuentes.', + }, + }; + } + + return { + step: 'Step 1 of 4', + heroTitle: 'Your first run adapts next.', + heroMeta: 'Scanner, collection, and health check based on your goal.', + valueTitle: 'Why we ask', + valueBody: 'This helps tailor the first steps to what actually brought you here.', + subtitles: { + app_store: 'You actively searched for plant or care help.', + instagram: 'You came from visual plant content.', + tiktok: 'You came from short videos or creators.', + friend: 'Personal referral with high trust intent.', + search: 'Concrete problem or quick plant check intent.', + other: 'Does not fit the other sources cleanly.', + }, + }; +}; + export default function OnboardingSourceScreen() { const router = useRouter(); const posthog = usePostHog(); - const { session, isDarkMode, colorPalette, t } = useApp(); + const { session, isDarkMode, colorPalette, language, t } = useApp(); const colors = useColors(isDarkMode, colorPalette); + const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; const [selectedSource, setSelectedSource] = useState(null); + const copy = getSourceOnboardingCopy(language); const sourceLabels = useMemo( () => ({ @@ -51,18 +112,40 @@ export default function OnboardingSourceScreen() { posthog.capture('onboarding_source_completed', { source: source ?? 'skipped', + revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped', }); router.replace('/onboarding/goal'); }; return ( - - + + {isDarkMode ? : null} - - + + {copy.step} + + + + + + + + + {copy.heroTitle} + + + {copy.heroMeta} + + + + {t.sourceOnboardingTitle} {t.sourceOnboardingSubtitle} @@ -86,8 +169,13 @@ export default function OnboardingSourceScreen() { - {sourceLabels[option.id as keyof typeof sourceLabels]} - {isActive && } + + {sourceLabels[option.id as keyof typeof sourceLabels]} + + {copy.subtitles[option.id as keyof typeof copy.subtitles]} + + + {isActive && } ); })} @@ -130,66 +218,123 @@ const styles = StyleSheet.create({ safeArea: { flex: 1, paddingHorizontal: 20, - paddingTop: 24, - paddingBottom: 20, + paddingTop: 12, + paddingBottom: 14, + justifyContent: 'space-between', }, header: { alignItems: 'center', - gap: 10, - marginBottom: 28, + gap: 9, }, - headerIcon: { - width: 64, - height: 64, - borderRadius: 32, + stepPill: { + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 7, + }, + stepLabel: { + fontSize: 12, + fontWeight: '800', + textTransform: 'uppercase', + letterSpacing: 0.4, + }, + heroPreview: { + width: '100%', + height: 175, + borderRadius: 24, + borderWidth: 1, + justifyContent: 'flex-end', + overflow: 'hidden', + }, + heroImage: { + borderRadius: 24, + }, + heroOverlay: { + ...StyleSheet.absoluteFillObject, + }, + heroContent: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: 12, + padding: 12, + }, + heroIcon: { + width: 36, + height: 36, + borderRadius: 14, alignItems: 'center', justifyContent: 'center', }, + heroCopy: { + flex: 1, + gap: 3, + }, + heroTitle: { + fontSize: 15, + lineHeight: 18, + fontWeight: '800', + }, + heroMeta: { + fontSize: 10.5, + lineHeight: 14, + fontWeight: '600', + }, title: { - fontSize: 28, + fontSize: 25, fontWeight: '800', textAlign: 'center', - lineHeight: 32, + lineHeight: 29, }, subtitle: { - fontSize: 14, + fontSize: 13, textAlign: 'center', - lineHeight: 20, + lineHeight: 18, maxWidth: 320, }, options: { - gap: 12, - flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, }, optionCard: { - minHeight: 64, - borderRadius: 18, + width: '48.8%', + minHeight: 68, + borderRadius: 15, borderWidth: 1.5, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - gap: 12, + padding: 9, + gap: 8, + position: 'relative', }, optionIcon: { - width: 36, - height: 36, - borderRadius: 18, + width: 34, + height: 34, + borderRadius: 17, alignItems: 'center', justifyContent: 'center', }, + optionCopy: { + gap: 3, + }, optionLabel: { - flex: 1, - fontSize: 15, - fontWeight: '600', + fontSize: 13, + fontWeight: '700', + }, + optionSubtitle: { + fontSize: 10, + lineHeight: 13, + }, + optionCheck: { + position: 'absolute', + right: 9, + top: 9, }, footer: { flexDirection: 'row', gap: 12, - marginTop: 16, }, secondaryBtn: { flex: 1, - height: 52, + height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', @@ -201,7 +346,7 @@ const styles = StyleSheet.create({ }, primaryBtn: { flex: 1.2, - height: 52, + height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center', diff --git a/app/plant/[id].tsx b/app/plant/[id].tsx index d9171c5..f600f46 100644 --- a/app/plant/[id].tsx +++ b/app/plant/[id].tsx @@ -57,20 +57,23 @@ const HEALTH_CHECK_CREDIT_COST = 2; const getHealthCopy = (language: 'de' | 'en' | 'es') => { if (language === 'de') { return { - title: 'Health Check', - action: 'Neues Foto + Health-Check', - running: 'Neues Foto wird analysiert...', - cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`, - creditsLabel: 'Credits', + title: 'Health Check', + action: 'Health-Scan starten', + running: 'Neues Foto wird analysiert...', + cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`, + intro: 'Fotografiere die ganze Pflanze plus auffaellige Blaetter. Danach bekommst du Diagnose, Dringlichkeit und einen konkreten Pflegeplan.', + creditsLabel: 'Credits', managePlan: 'Plan verwalten', noCreditsTitle: 'Nicht genug Credits', noCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`, insufficientInline: 'Nicht genug Credits fuer den Health-Check.', timeoutInline: 'Health-Check Timeout. Bitte erneut versuchen.', providerInline: 'Health-Check ist gerade nicht verfuegbar.', - issuesTitle: 'Moegliche Ursachen', - actionsTitle: 'Sofortmassnahmen', - planTitle: '7-Tage-Plan', + analysisTitle: 'Analyse', + analysisFallback: 'Die Pflanze wirkt insgesamt beurteilbar, aber die gespeicherte Analyse enthaelt noch keine ausformulierte Zusammenfassung. Orientiere dich deshalb an Score, Ursachen und Sofortmassnahmen. Pruefe zuerst die auffaelligsten Blaetter, danach Substratfeuchte und Standort. Wenn die Blaetter innerhalb von 48 Stunden weiter haengen, gelb werden oder Flecken ausbreiten, solltest du ein neues Foto bei hellem indirektem Licht aufnehmen. Ein neuer Health-Scan kann dann genauer zwischen Wasserstress, Lichtstress, Schaedlingen und normaler Blattalterung unterscheiden.', + issuesTitle: 'Wahrscheinlichste Ursachen', + actionsTitle: 'Sofortmassnahmen', + planTitle: '7-Tage-Plan', scoreLabel: 'Gesundheits-Score', healthy: 'Stabil', watch: 'Beobachten', @@ -81,18 +84,21 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => { if (language === 'es') { return { - title: 'Health Check', - action: 'Foto nuevo + Health-check', - running: 'Analizando foto nueva...', - cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`, - creditsLabel: 'Creditos', + title: 'Health Check', + action: 'Iniciar health-scan', + running: 'Analizando foto nueva...', + cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`, + intro: 'Fotografia la planta completa y las hojas llamativas. Luego recibes diagnostico, urgencia y un plan de cuidado concreto.', + creditsLabel: 'Creditos', managePlan: 'Gestionar plan', noCreditsTitle: 'Creditos insuficientes', noCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`, insufficientInline: 'No hay creditos suficientes para el health-check.', timeoutInline: 'Health-check agotado por tiempo. Intenta de nuevo.', providerInline: 'Health-check no disponible ahora.', - issuesTitle: 'Posibles causas', + analysisTitle: 'Analisis', + analysisFallback: 'La planta se puede evaluar en general, pero este chequeo guardado todavia no contiene un resumen completo. Usa el puntaje, las causas y las acciones inmediatas como guia principal. Revisa primero las hojas mas llamativas, despues la humedad del sustrato y la ubicacion. Si las hojas empeoran en 48 horas, amarillean o las manchas se expanden, toma una foto nueva con luz indirecta clara. Un nuevo health-scan podra diferenciar mejor entre exceso o falta de agua, luz, plagas y envejecimiento normal.', + issuesTitle: 'Causas mas probables', actionsTitle: 'Acciones inmediatas', planTitle: 'Plan de 7 dias', scoreLabel: 'Puntaje de salud', @@ -104,10 +110,11 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => { } return { - title: 'Health Check', - action: 'New Photo + Health Check', - running: 'Analyzing new photo...', - cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`, + title: 'Health Check', + action: 'Start health scan', + running: 'Analyzing new photo...', + cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`, + intro: 'Photograph the full plant plus any suspicious leaves. You will get a diagnosis, urgency level, and a concrete care plan.', creditsLabel: 'Credits', managePlan: 'Manage plan', noCreditsTitle: 'Not enough credits', @@ -115,7 +122,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => { insufficientInline: 'Not enough credits for the health check.', timeoutInline: 'Health check timed out. Please try again.', providerInline: 'Health check is unavailable right now.', - issuesTitle: 'Likely issues', + analysisTitle: 'Analysis', + analysisFallback: 'The plant is still assessable, but this saved check does not include a full written summary yet. Use the score, likely causes, and immediate actions as the primary guide. Start by inspecting the most unusual leaves, then check soil moisture and placement. If leaves droop further, yellowing spreads, or spots expand within 48 hours, take a new photo in bright indirect light. A fresh health scan can separate watering stress, light stress, pests, and normal leaf aging more accurately.', + issuesTitle: 'Most likely causes', actionsTitle: 'Actions now', planTitle: '7-day plan', scoreLabel: 'Health score', @@ -234,7 +243,7 @@ export default function PlantDetailScreen() { : colors.dangerSoft ) : colors.surfaceMuted; - const latestStatusColor = latestHealthCheck + const latestStatusColor = latestHealthCheck ? ( latestHealthCheck.status === 'healthy' ? colors.success @@ -242,7 +251,10 @@ export default function PlantDetailScreen() { ? colors.warning : colors.danger ) - : colors.textMuted; + : colors.textMuted; + const latestAnalysisSummary = latestHealthCheck + ? latestHealthCheck.analysisSummary || healthCopy.analysisFallback + : ''; const timelineEntries = useMemo(() => { const history = plant.wateringHistory && plant.wateringHistory.length > 0 @@ -576,9 +588,9 @@ export default function PlantDetailScreen() { - {healthCopy.title} - {healthCopy.cost} - + {healthCopy.title} + {healthCopy.intro} + - - {healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)} - - - + + {healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)} + + + {latestAnalysisSummary ? ( + + {healthCopy.analysisTitle} + + {latestAnalysisSummary} + + + ) : null} + + {healthCopy.issuesTitle} {latestHealthCheck.likelyIssues.map((issue, index) => ( @@ -1110,10 +1131,20 @@ const styles = StyleSheet.create({ healthTimestamp: { fontSize: 11, }, - healthListBlock: { - gap: 8, - }, - healthListTitle: { + healthListBlock: { + gap: 8, + }, + healthAnalysisBox: { + borderRadius: 16, + borderWidth: 1, + padding: 12, + gap: 6, + }, + healthAnalysisText: { + fontSize: 12, + lineHeight: 19, + }, + healthListTitle: { fontSize: 13, fontWeight: '700', }, diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 4b764b1..caa90e3 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -1,28 +1,31 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler } from 'react-native'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, 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, { - PACKAGE_TYPE, - PRODUCT_CATEGORY, - PurchasesOffering, - PurchasesPackage, - PurchasesStoreProduct, -} from 'react-native-purchases'; -import { useApp } from '../../context/AppContext'; -import { usePostHog } from 'posthog-react-native'; -import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; -import { Language } from '../../types'; -import { PurchaseProductId } from '../../services/backend/contracts'; - -type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; +import Constants from 'expo-constants'; +import Purchases, { + PACKAGE_TYPE, + PRODUCT_CATEGORY, + PurchasesOffering, + PurchasesPackage, + PurchasesStoreProduct, +} from 'react-native-purchases'; +import { useApp } from '../../context/AppContext'; +import { usePostHog } from 'posthog-react-native'; +import { useColors } from '../../constants/Colors'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { Language } from '../../types'; +import { PurchaseProductId } from '../../services/backend/contracts'; + +type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; type TopupProductId = Extract; type SubscriptionPackages = Partial>; type TopupProducts = Partial>; +type PaywallPlanId = 'weekly' | 'yearly'; + +const PAYWALL_BACKGROUND = require('../../assets/paywall_scan_background.png'); const TOPUP_CREDITS_BY_PRODUCT: Record = { topup_small: 30, @@ -33,66 +36,78 @@ const TOPUP_CREDITS_BY_PRODUCT: Record = { const isTopupProductId = (productId: PurchaseProductId): productId is TopupProductId => ( productId === 'topup_small' || productId === 'topup_medium' || productId === 'topup_large' ); - -const isMatchingPackage = ( - pkg: PurchasesPackage, - productId: SubscriptionProductId, - expectedPackageType: PACKAGE_TYPE, -) => { - return ( - pkg.product.identifier === productId - || pkg.identifier === productId - || pkg.packageType === expectedPackageType - ); -}; - -const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { - if (!offering) { - return {}; - } - - const availablePackages = [ - offering.monthly, - offering.annual, - ...offering.availablePackages, - ].filter((value): value is PurchasesPackage => Boolean(value)); - - return { - monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), - yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), - }; -}; - -const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { - if (!offering) { - return { identifier: null, packages: [] as Array> }; - } - - return { - identifier: offering.identifier, - packages: offering.availablePackages.map((pkg) => ({ - identifier: pkg.identifier, - packageType: pkg.packageType, - productIdentifier: pkg.product.identifier, - priceString: pkg.product.priceString, - })), - }; -}; - -const getBillingCopy = (language: Language) => { - if (language === 'de') { - return { - title: 'Abo und Credits', - planLabel: 'Aktueller Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Verfügbare Credits', - manageSubscription: 'Abo verwalten', - subscriptionTitle: 'Abos', - subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', + +const isMatchingPackage = ( + pkg: PurchasesPackage, + productId: SubscriptionProductId, + expectedPackageType: PACKAGE_TYPE, +) => { + return ( + pkg.product.identifier === productId + || pkg.identifier === productId + || pkg.packageType === expectedPackageType + ); +}; + +const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { + if (!offering) { + return {}; + } + + const availablePackages = [ + offering.monthly, + offering.annual, + ...offering.availablePackages, + ].filter((value): value is PurchasesPackage => Boolean(value)); + + return { + monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), + yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), + }; +}; + +const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { + if (!offering) { + return { identifier: null, packages: [] as Array> }; + } + + return { + identifier: offering.identifier, + packages: offering.availablePackages.map((pkg) => ({ + identifier: pkg.identifier, + packageType: pkg.packageType, + productIdentifier: pkg.product.identifier, + priceString: pkg.product.priceString, + })), + }; +}; + +const getBillingCopy = (language: Language) => { + if (language === 'de') { + return { + title: 'Abo und Credits', + planLabel: 'Aktueller Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Verfügbare Credits', + manageSubscription: 'Abo verwalten', + subscriptionTitle: 'Abos', + subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', paywallTitle: 'Vollstaendige Diagnose freischalten', paywallHint: 'Starte Pro fuer echte GPT-5.4 Scans, deinen 7-Tage-Rettungsplan und 100 Credits fuer AI-Scans und Follow-ups.', startTrial: '7 Tage kostenlos testen', + hardPaywallTitle: 'Pflanze scannen. Richtig pflegen.', + hardPaywallHint: 'GreenLens erkennt Art, Pflegebedarf und mögliche Probleme in Sekunden.', + identifiedChip: 'Monstera erkannt', + benefitUnlimitedScans: 'Unbegrenzte Scans', + benefitCarePlan: 'Pflegeplan + Erinnerungen', + weeklyPlanName: 'Monatlich', + yearlyPlanName: 'Jährlich', + weeklyPriceFallback: '5,99 € / Monat', + yearlyPriceFallback: '39,99 € / Jahr', + bestOffer: 'Bestes Angebot', + noPaymentToday: 'Keine Zahlung heute · jederzeit kündbar', + monthlyFooter: 'Monatlich kündbar · kein Gratis-Test', monthlyCta: 'Monatlich starten', yearlyCta: 'Jaehrlich starten', yearlyTrialBadge: '7 TAGE GRATIS', @@ -107,21 +122,21 @@ const getBillingCopy = (language: Language) => { perYear: '/ Jahr', perMonth: '/ Monat', freePlanName: 'Free', - freePlanPrice: '0 EUR / Monat', - proPlanName: 'Pro', - proPlanPrice: '4,99 € / Monat', - proBadgeText: 'EMPFOHLEN', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39,99 € / Jahr', - proYearlyBadgeText: 'SPAREN', - proBenefits: [ + freePlanPrice: '0 EUR / Monat', + proPlanName: 'Pro', + proPlanPrice: '4,99 € / Monat', + proBadgeText: 'EMPFOHLEN', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39,99 € / Jahr', + proYearlyBadgeText: 'SPAREN', + proBenefits: [ '100 Credits für AI-Scans und Follow-ups jeden Monat', - 'Pro-Scans mit GPT-5.4', - 'Unbegrenzte Historie & Galerie', - 'KI-Pflanzendoktor inklusive', - 'Priorisierter Support' - ], - topupTitle: 'Credits Aufladen', + 'Pro-Scans mit GPT-5.4', + 'Unbegrenzte Historie & Galerie', + 'KI-Pflanzendoktor inklusive', + 'Priorisierter Support' + ], + topupTitle: 'Credits Aufladen', topupHint: 'Für aktive Pro-Nutzer, wenn die Monatscredits nicht reichen.', topupSmall: '30 Credits – 2,99 €', topupMedium: '100 Credits – 6,99 €', @@ -129,34 +144,46 @@ const getBillingCopy = (language: Language) => { topupBestValue: 'BESTES ANGEBOT', topupRequiresProTitle: 'Pro erforderlich', topupRequiresProMessage: 'Top-ups sind für aktive Pro-Nutzer gedacht. Starte Pro, um zusätzliche Credits zu kaufen.', - cancelTitle: 'Schade, dass du gehst', - cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', - reasonTooExpensive: 'Es ist mir zu teuer', - reasonNotUsing: 'Ich nutze die App zu selten', - reasonOther: 'Ein anderer Grund', - offerTitle: 'Ein Geschenk für dich!', - offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', - offerAccept: 'Rabatt sichern', - offerDecline: 'Nein, Kündigung fortsetzen', - confirmCancelBtn: 'Jetzt kündigen', - restorePurchases: 'Käufe wiederherstellen', - autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - manageInSettings: 'In iOS-Einstellungen verwalten', - }; - } else if (language === 'es') { - return { - title: 'Suscripción y Créditos', - planLabel: 'Plan Actual', - planFree: 'Gratis', - planPro: 'Pro', - creditsAvailableLabel: 'Créditos Disponibles', - manageSubscription: 'Administrar Suscripción', - subscriptionTitle: 'Suscripciones', - subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', + cancelTitle: 'Schade, dass du gehst', + cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', + reasonTooExpensive: 'Es ist mir zu teuer', + reasonNotUsing: 'Ich nutze die App zu selten', + reasonOther: 'Ein anderer Grund', + offerTitle: 'Ein Geschenk für dich!', + offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', + offerAccept: 'Rabatt sichern', + offerDecline: 'Nein, Kündigung fortsetzen', + confirmCancelBtn: 'Jetzt kündigen', + restorePurchases: 'Käufe wiederherstellen', + autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + manageInSettings: 'In iOS-Einstellungen verwalten', + }; + } else if (language === 'es') { + return { + title: 'Suscripción y Créditos', + planLabel: 'Plan Actual', + planFree: 'Gratis', + planPro: 'Pro', + creditsAvailableLabel: 'Créditos Disponibles', + manageSubscription: 'Administrar Suscripción', + subscriptionTitle: 'Suscripciones', + subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', paywallTitle: 'Desbloquear diagnostico completo', paywallHint: 'Inicia Pro para escaneos reales con GPT-5.4, tu plan de rescate de 7 dias y 100 creditos para escaneos IA y seguimientos.', startTrial: 'Probar 7 dias gratis', + hardPaywallTitle: 'Escanea plantas. Cuídalas bien.', + hardPaywallHint: 'GreenLens identifica la especie, los cuidados y posibles problemas en segundos.', + identifiedChip: 'Monstera identificada', + benefitUnlimitedScans: 'Escaneos ilimitados', + benefitCarePlan: 'Plan de cuidado + recordatorios', + weeklyPlanName: 'Mensual', + yearlyPlanName: 'Anual', + weeklyPriceFallback: '5,99 € / mes', + yearlyPriceFallback: '39,99 € / año', + bestOffer: 'Mejor oferta', + noPaymentToday: 'Sin pago hoy · cancela cuando quieras', + monthlyFooter: 'Cancela mensualmente · sin prueba gratis', monthlyCta: 'Empezar mensual', yearlyCta: 'Empezar anual', yearlyTrialBadge: '7 DIAS GRATIS', @@ -171,21 +198,21 @@ const getBillingCopy = (language: Language) => { perYear: '/ ano', perMonth: '/ mes', freePlanName: 'Gratis', - freePlanPrice: '0 EUR / Mes', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Mes', - proBadgeText: 'RECOMENDADO', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Año', - proYearlyBadgeText: 'AHORRAR', - proBenefits: [ + freePlanPrice: '0 EUR / Mes', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Mes', + proBadgeText: 'RECOMENDADO', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Año', + proYearlyBadgeText: 'AHORRAR', + proBenefits: [ '100 créditos para escaneos IA y seguimientos cada mes', - 'Escaneos Pro con GPT-5.4', - 'Historial y galería ilimitados', - 'Doctor de plantas de IA incluido', - 'Soporte prioritario' - ], - topupTitle: 'Recargar Créditos', + 'Escaneos Pro con GPT-5.4', + 'Historial y galería ilimitados', + 'Doctor de plantas de IA incluido', + 'Soporte prioritario' + ], + topupTitle: 'Recargar Créditos', topupHint: 'Para usuarios Pro activos cuando los créditos mensuales no alcanzan.', topupSmall: '30 Créditos – 2,99 €', topupMedium: '100 Créditos – 6,99 €', @@ -193,34 +220,46 @@ const getBillingCopy = (language: Language) => { topupBestValue: 'MEJOR OFERTA', topupRequiresProTitle: 'Pro requerido', topupRequiresProMessage: 'Los top-ups son para usuarios Pro activos. Inicia Pro para comprar créditos adicionales.', - cancelTitle: 'Lamentamos verte ir', - cancelQuestion: '¿Podemos saber por qué cancelas?', - reasonTooExpensive: 'Es muy caro', - reasonNotUsing: 'No lo uso suficiente', - reasonOther: 'Otra razón', - offerTitle: '¡Un regalo para ti!', - offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', - offerAccept: 'Aceptar descuento', - offerDecline: 'No, continuar cancelando', - confirmCancelBtn: 'Cancelar ahora', - restorePurchases: 'Restaurar Compras', - autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - manageInSettings: 'Administrar en Ajustes de iOS', - }; - } - return { - title: 'Billing & Credits', - planLabel: 'Current Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Available Credits', - manageSubscription: 'Manage Subscription', - subscriptionTitle: 'Subscriptions', - subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', + cancelTitle: 'Lamentamos verte ir', + cancelQuestion: '¿Podemos saber por qué cancelas?', + reasonTooExpensive: 'Es muy caro', + reasonNotUsing: 'No lo uso suficiente', + reasonOther: 'Otra razón', + offerTitle: '¡Un regalo para ti!', + offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', + offerAccept: 'Aceptar descuento', + offerDecline: 'No, continuar cancelando', + confirmCancelBtn: 'Cancelar ahora', + restorePurchases: 'Restaurar Compras', + autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + manageInSettings: 'Administrar en Ajustes de iOS', + }; + } + return { + title: 'Billing & Credits', + planLabel: 'Current Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Available Credits', + manageSubscription: 'Manage Subscription', + subscriptionTitle: 'Subscriptions', + subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', paywallTitle: 'Unlock the full diagnosis', paywallHint: 'Start Pro for real GPT-5.4 scans, your 7-day rescue plan, and 100 credits for AI scans and follow-ups.', startTrial: 'Start 7-day free trial', + hardPaywallTitle: 'Scan plants. Care for them right.', + hardPaywallHint: 'GreenLens identifies the species, care needs, and possible problems in seconds.', + identifiedChip: 'Monstera identified', + benefitUnlimitedScans: 'Unlimited scans', + benefitCarePlan: 'Care plan + reminders', + weeklyPlanName: 'Monthly', + yearlyPlanName: 'Yearly', + weeklyPriceFallback: '$4.99 / month', + yearlyPriceFallback: '$39.99 / year', + bestOffer: 'Best offer', + noPaymentToday: 'No payment today · cancel anytime', + monthlyFooter: 'Cancel monthly · no free trial', monthlyCta: 'Start monthly', yearlyCta: 'Start yearly', yearlyTrialBadge: '7 DAYS FREE', @@ -235,21 +274,21 @@ const getBillingCopy = (language: Language) => { perYear: '/ year', perMonth: '/ month', freePlanName: 'Free', - freePlanPrice: '0 EUR / Month', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Month', - proBadgeText: 'RECOMMENDED', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Year', - proYearlyBadgeText: 'SAVE', - proBenefits: [ + freePlanPrice: '0 EUR / Month', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Month', + proBadgeText: 'RECOMMENDED', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Year', + proYearlyBadgeText: 'SAVE', + proBenefits: [ '100 credits for AI scans and follow-ups every month', - 'Pro scans with GPT-5.4', - 'Unlimited history & gallery', - 'AI Plant Doctor included', - 'Priority support' - ], - topupTitle: 'Topup Credits', + 'Pro scans with GPT-5.4', + 'Unlimited history & gallery', + 'AI Plant Doctor included', + 'Priority support' + ], + topupTitle: 'Topup Credits', topupHint: 'For active Pro users when monthly credits are not enough.', topupSmall: '30 Credits – €2.99', topupMedium: '100 Credits – €6.99', @@ -257,92 +296,95 @@ const getBillingCopy = (language: Language) => { topupBestValue: 'BEST VALUE', topupRequiresProTitle: 'Pro required', topupRequiresProMessage: 'Top-ups are for active Pro users. Start Pro to buy extra credits.', - cancelTitle: 'Sorry to see you go', - cancelQuestion: 'May we ask why you are cancelling?', - reasonTooExpensive: 'It is too expensive', - reasonNotUsing: 'I don\'t use it enough', - reasonOther: 'Other reason', - offerTitle: 'A gift for you!', - offerText: 'Stay with us and get your next month for just €2.49 (50% off).', - offerAccept: 'Claim discount', - offerDecline: 'No, continue cancelling', - confirmCancelBtn: 'Cancel now', - restorePurchases: 'Restore Purchases', - autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', - autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', - manageInSettings: 'Manage in iOS Settings', - }; -}; - - - -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 copy = getBillingCopy(language); - const isExpoGo = Constants.appOwnership === 'expo'; - - const [subModalVisible, setSubModalVisible] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [storeReady, setStoreReady] = useState(isExpoGo); - const [subscriptionPackages, setSubscriptionPackages] = useState({}); - const [topupProducts, setTopupProducts] = useState({}); - - // Cancel Flow State - const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); - + cancelTitle: 'Sorry to see you go', + cancelQuestion: 'May we ask why you are cancelling?', + reasonTooExpensive: 'It is too expensive', + reasonNotUsing: 'I don\'t use it enough', + reasonOther: 'Other reason', + offerTitle: 'A gift for you!', + offerText: 'Stay with us and get your next month for just €2.49 (50% off).', + offerAccept: 'Claim discount', + offerDecline: 'No, continue cancelling', + confirmCancelBtn: 'Cancel now', + restorePurchases: 'Restore Purchases', + autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', + autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', + manageInSettings: 'Manage in iOS Settings', + }; +}; + + + +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 copy = getBillingCopy(language); + const isExpoGo = Constants.appOwnership === 'expo'; + const { height: windowHeight } = useWindowDimensions(); + const compactPaywall = windowHeight < 760; + + const [subModalVisible, setSubModalVisible] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [storeReady, setStoreReady] = useState(isExpoGo); + const [subscriptionPackages, setSubscriptionPackages] = useState({}); + const [topupProducts, setTopupProducts] = useState({}); + const [selectedPaywallPlan, setSelectedPaywallPlan] = useState('yearly'); + + // Cancel Flow State + const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); + const planId = billingSummary?.entitlement?.plan || 'free'; const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0); - const showPaywallPlans = !session || planId !== 'pro'; - - useEffect(() => { - let cancelled = false; - - const loadStoreProducts = async () => { - if (isExpoGo) { - setStoreReady(true); - return; - } - - try { - const [offerings, topups] = await Promise.all([ - Purchases.getOfferings(), - Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), - ]); - - if (cancelled) return; - - const currentOffering = offerings.current; - const resolvedPackages = resolveSubscriptionPackages(currentOffering); - if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { - console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); - } - - setSubscriptionPackages(resolvedPackages); - - setTopupProducts({ - topup_small: topups.find((product) => product.identifier === 'topup_small'), - topup_medium: topups.find((product) => product.identifier === 'topup_medium'), - topup_large: topups.find((product) => product.identifier === 'topup_large'), - }); - } catch (error) { - console.warn('Failed to load RevenueCat products', error); - } finally { - if (!cancelled) { - setStoreReady(true); - } - } - }; - - loadStoreProducts(); - - return () => { - cancelled = true; - }; - }, [isExpoGo]); - + const showPaywallPlans = !session || (!isLoadingBilling && planId !== 'pro'); + + useEffect(() => { + let cancelled = false; + + const loadStoreProducts = async () => { + if (isExpoGo) { + setStoreReady(true); + return; + } + + try { + const [offerings, topups] = await Promise.all([ + Purchases.getOfferings(), + Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), + ]); + + if (cancelled) return; + + const currentOffering = offerings.current; + const resolvedPackages = resolveSubscriptionPackages(currentOffering); + if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { + console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); + } + + setSubscriptionPackages(resolvedPackages); + + setTopupProducts({ + topup_small: topups.find((product) => product.identifier === 'topup_small'), + topup_medium: topups.find((product) => product.identifier === 'topup_medium'), + topup_large: topups.find((product) => product.identifier === 'topup_large'), + }); + } catch (error) { + console.warn('Failed to load RevenueCat products', error); + } finally { + if (!cancelled) { + setStoreReady(true); + } + } + }; + + loadStoreProducts(); + + return () => { + cancelled = true; + }; + }, [isExpoGo]); + useEffect(() => { posthog.capture('paywall_viewed', { plan_id: planId }); if (showPaywallPlans) { @@ -352,19 +394,24 @@ export default function BillingScreen() { }); } }, [posthog, planId, session?.serverUserId, showPaywallPlans]); - - const monthlyPackage = subscriptionPackages.monthly_pro; - const yearlyPackage = subscriptionPackages.yearly_pro; - - const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; - const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; - - const topupLabels = useMemo(() => ({ + + const monthlyPackage = subscriptionPackages.monthly_pro; + const yearlyPackage = subscriptionPackages.yearly_pro; + + const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; + const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; + const weeklyDisplayPrice = copy.weeklyPriceFallback; + const yearlyDisplayPrice = copy.yearlyPriceFallback; + const selectedProductId: SubscriptionProductId = selectedPaywallPlan === 'yearly' ? 'yearly_pro' : 'monthly_pro'; + const paywallCtaLabel = selectedPaywallPlan === 'yearly' ? copy.startTrial : copy.monthlyCta; + const paywallFooterLabel = selectedPaywallPlan === 'yearly' ? copy.noPaymentToday : copy.monthlyFooter; + + const topupLabels = useMemo(() => ({ topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, topup_medium: topupProducts.topup_medium ? `${TOPUP_CREDITS_BY_PRODUCT.topup_medium} Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, topup_large: topupProducts.topup_large ? `${TOPUP_CREDITS_BY_PRODUCT.topup_large} Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge, - }), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]); - + }), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]); + const openAppleSubscriptions = async () => { await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); }; @@ -422,9 +469,9 @@ export default function BillingScreen() { setIsUpdating(true); posthog.capture('purchase_initiated', { product_id: productId }); - try { - if (isExpoGo) { - // ExpoGo has no native RevenueCat — use simulation for development only + try { + if (isExpoGo) { + // ExpoGo has no native RevenueCat — use simulation for development only setIsUpdating(false); if (productId === 'monthly_pro' || productId === 'yearly_pro') { Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [ @@ -435,24 +482,24 @@ export default function BillingScreen() { } await completeExpoGoSimulation(productId); return; - } else { - if (productId === 'monthly_pro' || productId === 'yearly_pro') { - if (planId === 'pro') { - await openAppleSubscriptions(); - setSubModalVisible(false); - return; - } - const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; - const latestOffering = !selectedPackage - ? await Purchases.getOfferings().then((offerings) => offerings.current) - : null; - if (!selectedPackage) { - console.warn('[Billing] Purchase blocked because subscription package was not resolved', { - productId, - offering: summarizeOfferingPackages(latestOffering), - }); - throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); - } + } else { + if (productId === 'monthly_pro' || productId === 'yearly_pro') { + if (planId === 'pro') { + await openAppleSubscriptions(); + setSubModalVisible(false); + return; + } + const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; + const latestOffering = !selectedPackage + ? await Purchases.getOfferings().then((offerings) => offerings.current) + : null; + if (!selectedPackage) { + console.warn('[Billing] Purchase blocked because subscription package was not resolved', { + productId, + offering: summarizeOfferingPackages(latestOffering), + }); + throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); + } const purchaseResult = await Purchases.purchasePackage(selectedPackage); // Apply RevenueCat entitlement locally and let backend sync finish in the background. const customerInfo = (purchaseResult as { customerInfo?: unknown }).customerInfo @@ -464,125 +511,257 @@ export default function BillingScreen() { setTimeout(() => router.replace('/(tabs)'), 0); return; } else { - const selectedProduct = topupProducts[productId]; - if (!selectedProduct) { - throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); - } - await Purchases.purchaseStoreProduct(selectedProduct); - const customerInfo = await Purchases.getCustomerInfo(); - await syncRevenueCatState(customerInfo as any, 'topup_purchase'); + const selectedProduct = topupProducts[productId]; + if (!selectedProduct) { + throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); + } + await Purchases.purchaseStoreProduct(selectedProduct); + const customerInfo = await Purchases.getCustomerInfo(); + await syncRevenueCatState(customerInfo as any, 'topup_purchase'); } } posthog.capture('topup_purchased', { product_id: productId }); setSubModalVisible(false); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); - + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); + if (userCancelled) { posthog.capture('purchase_cancelled', { product_id: productId }); posthog.capture('paywall_purchase_cancelled', { product_id: productId }); return; } - - // RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already - // owns this subscription on a different GreenLens account. Silently dismiss; - // the current account stays free. The user can restore via "Käufe wiederherstellen". - const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; - if (rcErrorCode === 7) { - setSubModalVisible(false); - return; - } - - console.error('Payment failed', e); - posthog.capture('purchase_failed', { product_id: productId, error: msg }); - Alert.alert('Unerwarteter Fehler', msg); - } finally { - setIsUpdating(false); - } - }; - - const handleRestore = async () => { - setIsUpdating(true); - try { - if (!isExpoGo) { - const customerInfo = await Purchases.restorePurchases(); - await syncRevenueCatState(customerInfo as any, 'restore'); - } - Alert.alert(copy.restorePurchases, '✓'); - } catch (e) { - Alert.alert('Error', e instanceof Error ? e.message : String(e)); - } finally { - setIsUpdating(false); - } - }; - - const handleDowngrade = async () => { - if (planId === 'free') return; - if (!isExpoGo) { - await openAppleSubscriptions(); - return; - } - // Expo Go / dev only: simulate cancel flow - setCancelStep('survey'); - }; - - const finalizeCancel = async () => { - setIsUpdating(true); - try { - await simulateWebhookEvent('entitlement_revoked'); - setCancelStep('none'); - setSubModalVisible(false); - } catch (e) { - console.error('Downgrade failed', e); - } finally { - setIsUpdating(false); - } - }; - - return ( - - - - + + // RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already + // owns this subscription on a different GreenLens account. Silently dismiss; + // the current account stays free. The user can restore via "Käufe wiederherstellen". + const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; + if (rcErrorCode === 7) { + setSubModalVisible(false); + return; + } + + console.error('Payment failed', e); + posthog.capture('purchase_failed', { product_id: productId, error: msg }); + Alert.alert('Unerwarteter Fehler', msg); + } finally { + setIsUpdating(false); + } + }; + + const handleRestore = async () => { + setIsUpdating(true); + try { + if (!isExpoGo) { + const customerInfo = await Purchases.restorePurchases(); + await syncRevenueCatState(customerInfo as any, 'restore'); + } + Alert.alert(copy.restorePurchases, '✓'); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : String(e)); + } finally { + setIsUpdating(false); + } + }; + + const handleDowngrade = async () => { + if (planId === 'free') return; + if (!isExpoGo) { + await openAppleSubscriptions(); + return; + } + // Expo Go / dev only: simulate cancel flow + setCancelStep('survey'); + }; + + const finalizeCancel = async () => { + setIsUpdating(true); + try { + await simulateWebhookEvent('entitlement_revoked'); + setCancelStep('none'); + setSubModalVisible(false); + } catch (e) { + console.error('Downgrade failed', e); + } finally { + setIsUpdating(false); + } + }; + + if (showPaywallPlans) { + return ( + + + + + + + + + + + + + + + + + + + {copy.identifiedChip} + + + + + + {copy.hardPaywallTitle} + + + {copy.hardPaywallHint} + + + + {[ + { icon: 'infinite-outline' as const, title: copy.benefitUnlimitedScans }, + { icon: 'calendar-outline' as const, title: copy.benefitCarePlan }, + ].map((benefit) => ( + + + + + + {benefit.title} + + + ))} + + + + {[ + { id: 'weekly' as PaywallPlanId, name: copy.weeklyPlanName, price: weeklyDisplayPrice }, + { id: 'yearly' as PaywallPlanId, name: copy.yearlyPlanName, price: yearlyDisplayPrice, badge: copy.bestOffer }, + ].map((plan) => { + const selected = selectedPaywallPlan === plan.id; + return ( + setSelectedPaywallPlan(plan.id)} + activeOpacity={0.88} + > + + {selected ? : null} + + + {plan.name} + {plan.price} + {plan.badge ? ( + + {plan.badge} + + ) : null} + + + ); + })} + + + handlePurchase(selectedProductId)} + disabled={isUpdating || !storeReady} + activeOpacity={0.9} + > + {isUpdating || !storeReady ? ( + + ) : ( + <> + + {paywallCtaLabel} + + + + )} + + + {paywallFooterLabel} + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + + + + ); + } + + return ( + + + + - - - {copy.title} - - - - - {isLoadingBilling && session ? ( - - ) : ( - <> + + + {copy.title} + + + + + {isLoadingBilling && session ? ( + + ) : ( + <> {session && planId === 'pro' && ( {copy.planLabel} - - {planId === 'pro' ? copy.planPro : copy.planFree} - - setSubModalVisible(true)} - > - {copy.manageSubscription} - - - - {copy.creditsAvailableLabel} - {credits} - - )} + + {planId === 'pro' ? copy.planPro : copy.planFree} + + setSubModalVisible(true)} + > + {copy.manageSubscription} + + + + {copy.creditsAvailableLabel} + {credits} + + )} {showPaywallPlans && ( {copy.paywallTitle} {copy.paywallHint} - + {copy.proBenefits.slice(0, 3).map((benefit, index) => ( @@ -633,7 +812,7 @@ export default function BillingScreen() { activeOpacity={0.9} > - + Monatlich {copy.monthlySubline} @@ -656,292 +835,582 @@ export default function BillingScreen() { )} - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - )} - + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + )} + {session && planId === 'pro' && !isExpoGo ? ( {copy.topupTitle} {copy.topupHint} - {([ - { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, - { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, - { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, - ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( - handlePurchase(pack.id)} - disabled={isUpdating || !storeReady} - > - - - - {isUpdating ? '...' : pack.label} - - - {pack.badge && ( - - {pack.badge} - - )} - - ))} - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - + {([ + { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, + { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, + { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, + ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( + handlePurchase(pack.id)} + disabled={isUpdating || !storeReady} + > + + + + {isUpdating ? '...' : pack.label} + + + {pack.badge && ( + + {pack.badge} + + )} + + ))} + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + {copy.restorePurchases} ) : null} - - )} - - - - setSubModalVisible(false)}> - - - - - {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} - - { - setSubModalVisible(false); - setCancelStep('none'); - }}> - - - - - {cancelStep === 'none' ? ( - <> - {copy.subscriptionHint} - - - - {copy.freePlanName} - {copy.freePlanPrice} - - {planId === 'free' && } - - - handlePurchase('monthly_pro')} - disabled={isUpdating || !storeReady} - > - - - {copy.proPlanName} - - {copy.proBadgeText} - - - {monthlyPrice} - {copy.autoRenewMonthly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - handlePurchase('yearly_pro')} - disabled={isUpdating || !storeReady} - > - - - {copy.proYearlyPlanName} - - {copy.proYearlyBadgeText} - - - {yearlyPrice} - {copy.autoRenewYearly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - ) : cancelStep === 'survey' ? ( - - {copy.cancelQuestion} - - {[ - { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, - { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, - { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, - ].map((reason) => ( - { - setCancelStep('offer'); - }} - > - - - - {reason.label} - - - ))} - - - ) : ( - - - - - - {copy.offerText} - - { - // Handle applying discount here (future implementation) - Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); - setCancelStep('none'); - setSubModalVisible(false); - }} - > - {copy.offerAccept} - - - - - {copy.offerDecline} - - - )} - {(isUpdating || (!storeReady && cancelStep === 'none')) && } - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { flex: 1 }, - header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, - backButton: { width: 40, height: 40, justifyContent: 'center' }, - title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, - scrollContent: { padding: 16, gap: 16 }, - card: { - padding: 16, - borderRadius: 16, - borderWidth: StyleSheet.hairlineWidth, - }, - sectionTitle: { - fontSize: 14, - fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 8, - }, - row: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - value: { - fontSize: 18, - fontWeight: '600', - }, - manageBtn: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - }, - manageBtnText: { - color: '#fff', - fontSize: 14, - fontWeight: '600', - }, + + )} + + + + setSubModalVisible(false)}> + + + + + {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} + + { + setSubModalVisible(false); + setCancelStep('none'); + }}> + + + + + {cancelStep === 'none' ? ( + <> + {copy.subscriptionHint} + + + + {copy.freePlanName} + {copy.freePlanPrice} + + {planId === 'free' && } + + + handlePurchase('monthly_pro')} + disabled={isUpdating || !storeReady} + > + + + {copy.proPlanName} + + {copy.proBadgeText} + + + {monthlyPrice} + {copy.autoRenewMonthly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + handlePurchase('yearly_pro')} + disabled={isUpdating || !storeReady} + > + + + {copy.proYearlyPlanName} + + {copy.proYearlyBadgeText} + + + {yearlyPrice} + {copy.autoRenewYearly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + ) : cancelStep === 'survey' ? ( + + {copy.cancelQuestion} + + {[ + { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, + { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, + { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, + ].map((reason) => ( + { + setCancelStep('offer'); + }} + > + + + + {reason.label} + + + ))} + + + ) : ( + + + + + + {copy.offerText} + + { + // Handle applying discount here (future implementation) + Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); + setCancelStep('none'); + setSubModalVisible(false); + }} + > + {copy.offerAccept} + + + + + {copy.offerDecline} + + + )} + {(isUpdating || (!storeReady && cancelStep === 'none')) && } + + + + + ); +} + +const styles = StyleSheet.create({ + hardPaywallScreen: { + flex: 1, + backgroundColor: '#101411', + }, + hardPaywallHero: { + flex: 1, + }, + hardPaywallHeroImage: { + transform: [{ translateY: -38 }, { scale: 1.06 }], + }, + hardPaywallSafe: { + flex: 1, + justifyContent: 'space-between', + }, + hardPaywallSafeCompact: { + justifyContent: 'flex-end', + }, + heroTopBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 4, + }, + heroIconButton: { + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#00000066', + }, + heroRestoreText: { + color: '#FFFFFF', + fontSize: 13, + fontWeight: '700', + textShadowColor: '#00000066', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, + }, + scanFrameOverlay: { + position: 'absolute', + top: '14%', + left: 54, + right: 54, + height: '28%', + }, + scanFrameOverlayCompact: { + display: 'none', + }, + scanCorner: { + position: 'absolute', + width: 44, + height: 44, + borderColor: '#FFFFFF', + }, + scanCornerTopLeft: { + top: 0, + left: 0, + borderTopWidth: 4, + borderLeftWidth: 4, + borderTopLeftRadius: 18, + }, + scanCornerTopRight: { + top: 0, + right: 0, + borderTopWidth: 4, + borderRightWidth: 4, + borderTopRightRadius: 18, + }, + scanCornerBottomLeft: { + bottom: 0, + left: 0, + borderBottomWidth: 4, + borderLeftWidth: 4, + borderBottomLeftRadius: 18, + }, + scanCornerBottomRight: { + bottom: 0, + right: 0, + borderBottomWidth: 4, + borderRightWidth: 4, + borderBottomRightRadius: 18, + }, + identifiedChip: { + position: 'absolute', + alignSelf: 'center', + top: '43%', + zIndex: 4, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderRadius: 18, + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: '#16351FCC', + }, + identifiedChipCompact: { + display: 'none', + }, + identifiedChipText: { + color: '#FFFFFF', + fontSize: 15, + fontWeight: '800', + }, + hardPaywallSheet: { + backgroundColor: '#FAFAF5', + borderTopLeftRadius: 30, + borderTopRightRadius: 30, + paddingHorizontal: 20, + paddingTop: 9, + paddingBottom: 18, + zIndex: 5, + }, + hardPaywallSheetCompact: { + paddingHorizontal: 18, + paddingTop: 8, + paddingBottom: 16, + }, + sheetHandle: { + alignSelf: 'center', + width: 42, + height: 5, + borderRadius: 999, + backgroundColor: '#C7C8C3', + marginBottom: 8, + }, + sheetHandleCompact: { + marginBottom: 8, + }, + hardPaywallTitle: { + color: '#101411', + fontSize: 23, + lineHeight: 28, + fontWeight: '900', + textAlign: 'center', + marginBottom: 5, + }, + hardPaywallTitleCompact: { + fontSize: 22, + lineHeight: 26, + }, + hardPaywallHint: { + color: '#676C66', + fontSize: 12, + lineHeight: 17, + textAlign: 'center', + marginBottom: 10, + }, + hardPaywallHintCompact: { + display: 'none', + }, + hardBenefits: { + gap: 5, + marginBottom: 7, + }, + hardBenefitsCompact: { + marginBottom: 10, + }, + hardBenefitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + hardBenefitRowCompact: { + gap: 9, + }, + hardBenefitIcon: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#E3EFD9', + }, + hardBenefitIconCompact: { + width: 32, + height: 32, + borderRadius: 16, + }, + hardBenefitText: { + color: '#101411', + fontSize: 13, + fontWeight: '800', + }, + hardBenefitTextCompact: { + fontSize: 13, + }, + hardPlanRow: { + flexDirection: 'row', + gap: 10, + marginBottom: 7, + }, + hardPlanCard: { + flex: 1, + minHeight: 76, + borderRadius: 14, + borderWidth: 1.5, + borderColor: '#D8DAD3', + padding: 9, + flexDirection: 'row', + gap: 10, + backgroundColor: '#FFFFFF', + }, + hardPlanCardCompact: { + minHeight: 76, + padding: 9, + gap: 8, + }, + hardPlanCardSelected: { + borderColor: '#1F5B34', + borderWidth: 2, + backgroundColor: '#FBFFF8', + }, + planRadio: { + width: 20, + height: 20, + borderRadius: 11, + borderWidth: 2, + borderColor: '#8C918A', + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + }, + planRadioSelected: { + backgroundColor: '#1F5B34', + borderColor: '#1F5B34', + }, + hardPlanName: { + color: '#101411', + fontSize: 14, + fontWeight: '900', + marginBottom: 4, + }, + hardPlanNameCompact: { + fontSize: 13, + marginBottom: 2, + }, + hardPlanPrice: { + color: '#626862', + fontSize: 11, + fontWeight: '700', + lineHeight: 18, + }, + hardPlanPriceCompact: { + fontSize: 11, + lineHeight: 15, + }, + bestOfferBadge: { + alignSelf: 'flex-start', + backgroundColor: '#DDEBCF', + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 3, + marginTop: 5, + }, + bestOfferText: { + color: '#1F5B34', + fontSize: 10, + fontWeight: '900', + }, + hardPaywallCta: { + minHeight: 50, + borderRadius: 14, + backgroundColor: '#1F5B34', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 12, + marginBottom: 5, + }, + hardPaywallCtaCompact: { + minHeight: 48, + marginBottom: 8, + }, + hardPaywallCtaText: { + color: '#FFFFFF', + fontSize: 15, + fontWeight: '900', + }, + hardPaywallCtaTextCompact: { + fontSize: 14, + }, + hardPaywallFooter: { + color: '#777C75', + fontSize: 12, + fontWeight: '600', + textAlign: 'center', + marginBottom: 5, + }, + safeArea: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, + backButton: { width: 40, height: 40, justifyContent: 'center' }, + title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, + scrollContent: { padding: 16, gap: 16 }, + card: { + padding: 16, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 8, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + value: { + fontSize: 18, + fontWeight: '600', + }, + manageBtn: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + manageBtnText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, creditsValue: { fontSize: 32, fontWeight: '700', @@ -972,162 +1441,162 @@ const styles = StyleSheet.create({ lineHeight: 18, }, topupBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 12, - borderWidth: 2, - gap: 8, - }, - topupText: { - fontSize: 16, - fontWeight: '600', - }, - modalOverlay: { - flex: 1, - backgroundColor: '#00000080', - justifyContent: 'flex-end', - }, - modalContent: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 24, - borderTopWidth: StyleSheet.hairlineWidth, - paddingBottom: 40, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - modalTitle: { - fontSize: 20, - fontWeight: '700', - }, - modalHint: { - fontSize: 14, - marginBottom: 24, - }, - plansContainer: { - gap: 12, - }, - planOption: { - padding: 16, - borderRadius: 12, - borderWidth: 2, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - planName: { - fontSize: 18, - fontWeight: '600', - }, - planPrice: { - fontSize: 14, - }, - planHeaderRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 2, - }, - proBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 6, - }, - proBadgeText: { - color: '#fff', - fontSize: 10, - fontWeight: '800', - }, - proBenefits: { - marginTop: 12, - gap: 6, - }, - benefitRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - benefitText: { - fontSize: 12, - fontWeight: '500', - }, - cancelFlowContainer: { - marginTop: 8, - }, - cancelHint: { - fontSize: 15, - marginBottom: 16, - }, - reasonList: { - gap: 12, - }, - reasonOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderWidth: 1, - borderRadius: 12, - }, - reasonIcon: { - width: 36, - height: 36, - borderRadius: 18, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - reasonText: { - flex: 1, - fontSize: 16, - fontWeight: '500', - }, - offerCard: { - borderRadius: 16, - padding: 24, - alignItems: 'center', - marginBottom: 16, - }, - offerIconWrap: { - width: 56, - height: 56, - borderRadius: 28, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - offerText: { - fontSize: 16, - textAlign: 'center', - lineHeight: 24, - marginBottom: 24, - fontWeight: '500', - }, - offerAcceptBtn: { - paddingHorizontal: 24, - paddingVertical: 14, - borderRadius: 24, - width: '100%', - alignItems: 'center', - }, - offerAcceptBtnText: { - color: '#fff', - fontSize: 16, - fontWeight: '700', - }, - offerDeclineBtn: { - paddingVertical: 12, - alignItems: 'center', - }, - offerDeclineBtnText: { - fontSize: 15, - fontWeight: '500', - }, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + borderWidth: 2, + gap: 8, + }, + topupText: { + fontSize: 16, + fontWeight: '600', + }, + modalOverlay: { + flex: 1, + backgroundColor: '#00000080', + justifyContent: 'flex-end', + }, + modalContent: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 24, + borderTopWidth: StyleSheet.hairlineWidth, + paddingBottom: 40, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + modalTitle: { + fontSize: 20, + fontWeight: '700', + }, + modalHint: { + fontSize: 14, + marginBottom: 24, + }, + plansContainer: { + gap: 12, + }, + planOption: { + padding: 16, + borderRadius: 12, + borderWidth: 2, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + planName: { + fontSize: 18, + fontWeight: '600', + }, + planPrice: { + fontSize: 14, + }, + planHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 2, + }, + proBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 6, + }, + proBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: '800', + }, + proBenefits: { + marginTop: 12, + gap: 6, + }, + benefitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + benefitText: { + fontSize: 12, + fontWeight: '500', + }, + cancelFlowContainer: { + marginTop: 8, + }, + cancelHint: { + fontSize: 15, + marginBottom: 16, + }, + reasonList: { + gap: 12, + }, + reasonOption: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderWidth: 1, + borderRadius: 12, + }, + reasonIcon: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + reasonText: { + flex: 1, + fontSize: 16, + fontWeight: '500', + }, + offerCard: { + borderRadius: 16, + padding: 24, + alignItems: 'center', + marginBottom: 16, + }, + offerIconWrap: { + width: 56, + height: 56, + borderRadius: 28, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + offerText: { + fontSize: 16, + textAlign: 'center', + lineHeight: 24, + marginBottom: 24, + fontWeight: '500', + }, + offerAcceptBtn: { + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 24, + width: '100%', + alignItems: 'center', + }, + offerAcceptBtnText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, + offerDeclineBtn: { + paddingVertical: 12, + alignItems: 'center', + }, + offerDeclineBtnText: { + fontSize: 15, + fontWeight: '500', + }, guestPlanCard: { borderWidth: 2, borderRadius: 12, @@ -1208,6 +1677,7 @@ const styles = StyleSheet.create({ borderRadius: 999, paddingHorizontal: 8, paddingVertical: 3, + flexShrink: 0, }, secondaryBadgeText: { fontSize: 10, @@ -1229,33 +1699,44 @@ const styles = StyleSheet.create({ justifyContent: 'center', gap: 8, }, - guestSubscribeBtn: { - marginTop: 14, - paddingVertical: 12, - borderRadius: 10, - alignItems: 'center', - }, - legalLinksRow: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - marginTop: 16, - }, - legalLink: { - fontSize: 12, - fontWeight: '500', - textDecorationLine: 'underline', - }, - legalSep: { - fontSize: 12, - }, - restoreBtn: { - alignItems: 'center', - paddingVertical: 8, - }, - autoRenewText: { - fontSize: 11, - marginTop: 2, - marginBottom: 4, - }, -}); + guestSubscribeBtn: { + marginTop: 14, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + legalLinksRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 16, + }, + legalLink: { + fontSize: 12, + fontWeight: '500', + textDecorationLine: 'underline', + }, + legalSep: { + fontSize: 12, + }, + restoreBtn: { + alignItems: 'center', + paddingVertical: 8, + }, + hardRestoreBtn: { + alignItems: 'center', + paddingVertical: 6, + marginTop: 4, + }, + hardRestoreText: { + color: '#7A8079', + fontSize: 12, + fontWeight: '500', + textDecorationLine: 'underline', + }, + autoRenewText: { + fontSize: 11, + marginTop: 2, + marginBottom: 4, + }, +}); diff --git a/app/profile/data.tsx b/app/profile/data.tsx index 0e30031..d02771d 100644 --- a/app/profile/data.tsx +++ b/app/profile/data.tsx @@ -5,8 +5,9 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useApp } from '../../context/AppContext'; import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; -import { Language } from '../../types'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { Language } from '../../types'; +import { AuthService } from '../../services/authService'; const getDataCopy = (language: Language) => { if (language === 'de') { @@ -118,15 +119,19 @@ export default function DataScreen() { Alert.alert(copy.deleteConfirmTitle, copy.deleteConfirmMessage, [ { text: 'Cancel', style: 'cancel' }, { - text: copy.deleteActionBtn, - style: 'destructive', - onPress: async () => { - // Future implementation: call backend to wipe user data and cancel active app subscriptions - await signOut(); - router.replace('/onboarding'); - }, - }, - ]); + text: copy.deleteActionBtn, + style: 'destructive', + onPress: async () => { + try { + await AuthService.deleteAccount(); + await signOut(); + router.replace('/onboarding'); + } catch { + Alert.alert(copy.genericErrorTitle, copy.genericErrorMessage); + } + }, + }, + ]); }; return ( diff --git a/app/scanner.tsx b/app/scanner.tsx index 3f9d926..d299a30 100644 --- a/app/scanner.tsx +++ b/app/scanner.tsx @@ -1,153 +1,170 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - View, Text, StyleSheet, TouchableOpacity, Image, Alert, Animated, Easing, -} from 'react-native'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Ionicons } from '@expo/vector-icons'; -import { CameraView, useCameraPermissions } from 'expo-camera'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import React, { useEffect, useRef, useState } from 'react'; +import { + View, Text, StyleSheet, TouchableOpacity, Image, Alert, Animated, Easing, +} from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; 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 { useApp } from '../context/AppContext'; -import { useColors } from '../constants/Colors'; -import { PlantRecognitionService } from '../services/plantRecognitionService'; -import { IdentificationResult } from '../types'; -import { ResultCard } from '../components/ResultCard'; +import { useApp } from '../context/AppContext'; +import { useColors } from '../constants/Colors'; +import { PlantRecognitionService } from '../services/plantRecognitionService'; +import { IdentificationResult } from '../types'; +import { ResultCard } from '../components/ResultCard'; import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient'; import { isBackendApiError } from '../services/backend/contracts'; import { createIdempotencyKey } from '../utils/idempotency'; import { AuthService } from '../services/authService'; - -const HEALTH_CHECK_CREDIT_COST = 2; - -const getBillingCopy = (language: 'de' | 'en' | 'es') => { - if (language === 'de') { - return { - creditsLabel: 'Credits', - noCreditsTitle: 'Keine Credits mehr', - noCreditsMessage: 'Du hast keine Credits mehr fuer KI-Scans. Upgrade oder Top-up im Profil.', - healthNoCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`, - managePlan: 'Plan verwalten', - dismiss: 'Schliessen', - genericErrorTitle: 'Fehler', - genericErrorMessage: 'Analyse fehlgeschlagen.', - noConnectionTitle: 'Keine Verbindung', - noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.', - timeoutTitle: 'Scan zu langsam', - timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.', - retryLabel: 'Erneut versuchen', - notAPlantTitle: 'Keine Pflanze erkannt', - notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.', - providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.', - healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.', +import { getMockPlantByImage } from '../services/backend/mockCatalog'; + +const HEALTH_CHECK_CREDIT_COST = 2; +const DEMO_SCAN_LIMIT = 5; + +const getBillingCopy = (language: 'de' | 'en' | 'es') => { + if (language === 'de') { + return { + creditsLabel: 'Credits', + noCreditsTitle: 'Keine Credits mehr', + noCreditsMessage: 'Du hast keine Credits mehr fuer KI-Scans. Upgrade oder Top-up im Profil.', + healthNoCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`, + managePlan: 'Plan verwalten', + dismiss: 'Schliessen', + genericErrorTitle: 'Fehler', + genericErrorMessage: 'Analyse fehlgeschlagen.', + noConnectionTitle: 'Keine Verbindung', + noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.', + timeoutTitle: 'Scan zu langsam', + timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.', + retryLabel: 'Erneut versuchen', + notAPlantTitle: 'Keine Pflanze erkannt', + notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.', + providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.', + healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.', healthTitle: 'Health Check', healthDoneTitle: 'Health Check abgeschlossen', healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.', signupLabel: 'Registrieren', demoTitle: 'Rettungsplan bereit', demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.', + demoNoCreditsTitle: 'Demo-Scans aufgebraucht', + demoNoCreditsMessage: 'Du hast deine 5 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.', + demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`, appleCta: 'Mit Apple fortfahren', emailCta: 'Mit E-Mail fortfahren', unlockCta: 'Vollständige Diagnose freischalten', }; - } - - if (language === 'es') { - return { - creditsLabel: 'Creditos', - noCreditsTitle: 'Sin creditos', - noCreditsMessage: 'No tienes creditos para escaneos AI. Actualiza o compra top-up en Perfil.', - healthNoCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`, - managePlan: 'Gestionar plan', - dismiss: 'Cerrar', - genericErrorTitle: 'Error', - genericErrorMessage: 'Analisis fallido.', - noConnectionTitle: 'Sin conexión', - noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.', - timeoutTitle: 'Escaneo lento', - timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.', - retryLabel: 'Reintentar', - notAPlantTitle: 'No es una planta', - notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.', - providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.', - healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.', + } + + if (language === 'es') { + return { + creditsLabel: 'Creditos', + noCreditsTitle: 'Sin creditos', + noCreditsMessage: 'No tienes creditos para escaneos AI. Actualiza o compra top-up en Perfil.', + healthNoCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`, + managePlan: 'Gestionar plan', + dismiss: 'Cerrar', + genericErrorTitle: 'Error', + genericErrorMessage: 'Analisis fallido.', + noConnectionTitle: 'Sin conexión', + noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.', + timeoutTitle: 'Escaneo lento', + timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.', + retryLabel: 'Reintentar', + notAPlantTitle: 'No es una planta', + notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.', + providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.', + healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.', healthTitle: 'Health Check', healthDoneTitle: 'Health-check completado', healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.', signupLabel: 'Registrarse', demoTitle: 'Plan de rescate listo', demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.', + demoNoCreditsTitle: 'Escaneos demo agotados', + demoNoCreditsMessage: 'Ya usaste tus 5 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.', + demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`, appleCta: 'Continuar con Apple', emailCta: 'Continuar con email', unlockCta: 'Desbloquear diagnóstico completo', }; - } - - return { - creditsLabel: 'Credits', - noCreditsTitle: 'No credits left', - noCreditsMessage: 'You have no AI scan credits left. Upgrade or buy a top-up in Profile.', - healthNoCreditsMessage: `You need ${HEALTH_CHECK_CREDIT_COST} credits for the health check.`, - managePlan: 'Manage plan', - dismiss: 'Close', - genericErrorTitle: 'Error', - genericErrorMessage: 'Analysis failed.', - noConnectionTitle: 'No connection', - noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.', - timeoutTitle: 'Scan Too Slow', - timeoutMessage: 'Analysis took too long. Please try again.', - retryLabel: 'Try again', - notAPlantTitle: 'No plant detected', - notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.', - providerErrorMessage: 'AI scan is currently unavailable. Please try again.', - healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.', + } + + return { + creditsLabel: 'Credits', + noCreditsTitle: 'No credits left', + noCreditsMessage: 'You have no AI scan credits left. Upgrade or buy a top-up in Profile.', + healthNoCreditsMessage: `You need ${HEALTH_CHECK_CREDIT_COST} credits for the health check.`, + managePlan: 'Manage plan', + dismiss: 'Close', + genericErrorTitle: 'Error', + genericErrorMessage: 'Analysis failed.', + noConnectionTitle: 'No connection', + noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.', + timeoutTitle: 'Scan Too Slow', + timeoutMessage: 'Analysis took too long. Please try again.', + retryLabel: 'Try again', + notAPlantTitle: 'No plant detected', + notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.', + providerErrorMessage: 'AI scan is currently unavailable. Please try again.', + healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.', healthTitle: 'Health Check', healthDoneTitle: 'Health Check Complete', healthDoneMessage: 'The new photo was analyzed and added to gallery.', signupLabel: 'Sign Up', demoTitle: 'Rescue plan ready', demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.', + demoNoCreditsTitle: 'Demo scans used', + demoNoCreditsMessage: 'You used your 5 free demo scans on this device. Start Pro to keep scanning plants.', + demoCreditsRemaining: (count: number) => `${count} demo scans left`, appleCta: 'Continue with Apple', emailCta: 'Continue with email', unlockCta: 'Unlock full diagnosis', }; }; - -export default function ScannerScreen() { - const params = useLocalSearchParams<{ mode?: string; plantId?: string }>(); - const posthog = usePostHog(); - const { - isDarkMode, - colorPalette, - language, - t, - savePlant, - plants, - updatePlant, - billingSummary, - refreshBillingSummary, + +export default function ScannerScreen() { + const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageUri?: string }>(); + const posthog = usePostHog(); + const { + isDarkMode, + colorPalette, + language, + t, + savePlant, + plants, + updatePlant, + billingSummary, + refreshBillingSummary, isLoadingBilling, session, hydrateSession, setPendingPlant, + guestScanCount, + incrementGuestScanCount, } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const router = useRouter(); - const insets = useSafeAreaInsets(); - const billingCopy = getBillingCopy(language); - const isHealthMode = params.mode === 'health'; - const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId; + const colors = useColors(isDarkMode, colorPalette); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const billingCopy = getBillingCopy(language); + const isHealthMode = params.mode === 'health'; + const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId; const healthPlant = isHealthMode && healthPlantId ? plants.find((item) => item.id === healthPlantId) : null; + const sharedImageUri = Array.isArray(params.sharedImageUri) + ? params.sharedImageUri[0] + : params.sharedImageUri; const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' && billingSummary?.entitlement?.status === 'active'; const isDemoMode = !hasActiveEntitlement; const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0; + const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount); const [permission, requestPermission] = useCameraPermissions(); const [selectedImage, setSelectedImage] = useState(null); @@ -180,70 +197,82 @@ export default function ScannerScreen() { }; }, [isExpoGo]); + const hasProcessedSharedImage = useRef(false); + useEffect(() => { + if (!sharedImageUri || hasProcessedSharedImage.current) return; + hasProcessedSharedImage.current = true; + (async () => { + const analysisUri = await resizeForAnalysis(sharedImageUri); + setDemoResultVisible(false); + setSelectedImage(sharedImageUri); + analyzeImage(analysisUri, sharedImageUri); + })(); + }, [sharedImageUri]); + useEffect(() => { if (!isAnalyzing) { - scanLineProgress.stopAnimation(); - scanLineProgress.setValue(0); - scanPulse.stopAnimation(); - scanPulse.setValue(0); - return; - } - - const lineAnimation = Animated.loop( - Animated.sequence([ - Animated.timing(scanLineProgress, { - toValue: 1, - duration: 1500, - easing: Easing.inOut(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(scanLineProgress, { - toValue: 0, - duration: 1500, - easing: Easing.inOut(Easing.quad), - useNativeDriver: true, - }), - ]) - ); - - const pulseAnimation = Animated.loop( - Animated.sequence([ - Animated.timing(scanPulse, { toValue: 1, duration: 900, useNativeDriver: true }), - Animated.timing(scanPulse, { toValue: 0, duration: 900, useNativeDriver: true }), - ]) - ); - - lineAnimation.start(); - pulseAnimation.start(); - - return () => { - lineAnimation.stop(); - pulseAnimation.stop(); - }; - }, [isAnalyzing, scanLineProgress, scanPulse]); - - const resizeForAnalysis = async (uri: string): Promise => { - if (uri.startsWith('data:')) return uri; - try { - const result = await ImageManipulator.manipulateAsync( - uri, - [{ resize: { width: 768 } }], - { compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true }, - ); - return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri; - } catch { - return uri; - } - }; - - const analyzeImage = async (imageUri: string, galleryImageUri?: string) => { - if (isAnalyzing) return; - - if (!isDemoMode && availableCredits <= 0) { - Alert.alert( - billingCopy.noCreditsTitle, - isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, - [ + scanLineProgress.stopAnimation(); + scanLineProgress.setValue(0); + scanPulse.stopAnimation(); + scanPulse.setValue(0); + return; + } + + const lineAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(scanLineProgress, { + toValue: 1, + duration: 1500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(scanLineProgress, { + toValue: 0, + duration: 1500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]) + ); + + const pulseAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(scanPulse, { toValue: 1, duration: 900, useNativeDriver: true }), + Animated.timing(scanPulse, { toValue: 0, duration: 900, useNativeDriver: true }), + ]) + ); + + lineAnimation.start(); + pulseAnimation.start(); + + return () => { + lineAnimation.stop(); + pulseAnimation.stop(); + }; + }, [isAnalyzing, scanLineProgress, scanPulse]); + + const resizeForAnalysis = async (uri: string): Promise => { + if (uri.startsWith('data:')) return uri; + try { + const result = await ImageManipulator.manipulateAsync( + uri, + [{ resize: { width: 768 } }], + { compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true }, + ); + return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri; + } catch { + return uri; + } + }; + + const analyzeImage = async (imageUri: string, galleryImageUri?: string) => { + if (isAnalyzing) return; + + if (isDemoMode && guestScanCount >= DEMO_SCAN_LIMIT) { + Alert.alert( + billingCopy.demoNoCreditsTitle, + billingCopy.demoNoCreditsMessage, + [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.managePlan, @@ -251,39 +280,59 @@ export default function ScannerScreen() { }, ], ); - return; - } - + return; + } + + if (!isDemoMode && availableCredits <= 0) { + Alert.alert( + billingCopy.noCreditsTitle, + isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, + [ + { text: billingCopy.dismiss, style: 'cancel' }, + { + text: billingCopy.managePlan, + onPress: () => router.replace('/profile/billing'), + }, + ], + ); + return; + } + setIsAnalyzing(true); setAnalysisProgress(0); setAnalysisResult(null); setDemoResultVisible(false); - - const startTime = Date.now(); - - const progressInterval = setInterval(() => { - setAnalysisProgress((prev) => { - if (prev < 30) return prev + Math.random() * 8; - if (prev < 70) return prev + Math.random() * 2; - if (prev < 90) return prev + 0.5; - return prev; - }); - }, 150); - + + const startTime = Date.now(); + + const progressInterval = setInterval(() => { + setAnalysisProgress((prev) => { + if (prev < 30) return prev + Math.random() * 8; + if (prev < 70) return prev + Math.random() * 2; + if (prev < 90) return prev + 0.5; + return prev; + }); + }, 150); + try { if (isDemoMode) { posthog.capture('demo_scan_started', { authenticated: Boolean(session), scan_type: isHealthMode ? 'health_check' : 'identification', + demo_scans_used: guestScanCount, + demo_scans_remaining: demoScansRemaining, }); await new Promise(resolve => setTimeout(resolve, 2100)); setAnalysisProgress(100); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); await new Promise(resolve => setTimeout(resolve, 350)); - setDemoResultVisible(true); + const demoResult = getMockPlantByImage(galleryImageUri || imageUri, language, true); + incrementGuestScanCount(); + setAnalysisResult(demoResult); posthog.capture('demo_scan_completed', { authenticated: Boolean(session), latency_ms: Date.now() - startTime, + demo_scans_used_after: guestScanCount + 1, }); return; } @@ -296,49 +345,49 @@ export default function ScannerScreen() { if (isHealthMode) { if (!healthPlant) { Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); - setSelectedImage(null); - setIsAnalyzing(false); - return; - } - - const response = await backendApiClient.runHealthCheck({ - idempotencyKey: createIdempotencyKey('health-check', healthPlant.id), - imageUri, - language, - plantContext: { - name: healthPlant.name, - botanicalName: healthPlant.botanicalName, - careInfo: healthPlant.careInfo, - description: healthPlant.description, - }, - }); - - posthog.capture('llm_generation', { - scan_type: 'health_check', - success: true, - latency_ms: Date.now() - startTime, - }); - + setSelectedImage(null); + setIsAnalyzing(false); + return; + } + + const response = await backendApiClient.runHealthCheck({ + idempotencyKey: createIdempotencyKey('health-check', healthPlant.id), + imageUri, + language, + plantContext: { + name: healthPlant.name, + botanicalName: healthPlant.botanicalName, + careInfo: healthPlant.careInfo, + description: healthPlant.description, + }, + }); + + posthog.capture('llm_generation', { + scan_type: 'health_check', + success: true, + latency_ms: Date.now() - startTime, + }); + const currentGallery = healthPlant.gallery || []; - const existingChecks = healthPlant.healthChecks || []; - const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6); - const updatedPlant = { - ...healthPlant, - gallery: galleryImageUri ? [...currentGallery, galleryImageUri] : currentGallery, - healthChecks: updatedChecks, - }; - await updatePlant(updatedPlant); - } else { - const result = await PlantRecognitionService.identify(imageUri, language, { - idempotencyKey: createIdempotencyKey('scan-plant'), - }); - - posthog.capture('llm_generation', { - scan_type: 'identification', - success: true, - latency_ms: Date.now() - startTime, - }); - + const existingChecks = healthPlant.healthChecks || []; + const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6); + const updatedPlant = { + ...healthPlant, + gallery: galleryImageUri ? [...currentGallery, galleryImageUri] : currentGallery, + healthChecks: updatedChecks, + }; + await updatePlant(updatedPlant); + } else { + const result = await PlantRecognitionService.identify(imageUri, language, { + idempotencyKey: createIdempotencyKey('scan-plant'), + }); + + posthog.capture('llm_generation', { + scan_type: 'identification', + success: true, + latency_ms: Date.now() - startTime, + }); + setAnalysisResult(result); } setAnalysisProgress(100); @@ -349,26 +398,26 @@ export default function ScannerScreen() { }); await new Promise(resolve => setTimeout(resolve, 500)); setIsAnalyzing(false); - if (isHealthMode && healthPlant) { - Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [ - { text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) }, - ]); - } - } catch (error) { - console.error('Analysis failed', error); - - posthog.capture('llm_generation', { - scan_type: isHealthMode ? 'health_check' : 'identification', - success: false, - error_type: isInsufficientCreditsError(error) ? 'insufficient_credits' : 'provider_error', - latency_ms: Date.now() - startTime, - }); - - if (isInsufficientCreditsError(error)) { - Alert.alert( - billingCopy.noCreditsTitle, - isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, - [ + if (isHealthMode && healthPlant) { + Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [ + { text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) }, + ]); + } + } catch (error) { + console.error('Analysis failed', error); + + posthog.capture('llm_generation', { + scan_type: isHealthMode ? 'health_check' : 'identification', + success: false, + error_type: isInsufficientCreditsError(error) ? 'insufficient_credits' : 'provider_error', + latency_ms: Date.now() - startTime, + }); + + if (isInsufficientCreditsError(error)) { + Alert.alert( + billingCopy.noCreditsTitle, + isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, + [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.managePlan, @@ -376,44 +425,44 @@ export default function ScannerScreen() { }, ], ); - } else if (isTimeoutError(error)) { - Alert.alert( - billingCopy.timeoutTitle, - billingCopy.timeoutMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, - ], - ); - } else if (isNetworkError(error)) { - Alert.alert( - billingCopy.noConnectionTitle, - billingCopy.noConnectionMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, - ], - ); - } else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') { - Alert.alert( - billingCopy.notAPlantTitle, - billingCopy.notAPlantMessage, - [{ text: billingCopy.dismiss, style: 'cancel' }], - ); - } else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') { - Alert.alert( - billingCopy.genericErrorTitle, - isHealthMode ? billingCopy.healthProviderErrorMessage : billingCopy.providerErrorMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, - ], - ); - } else { - Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); - } - setSelectedImage(null); - setIsAnalyzing(false); + } else if (isTimeoutError(error)) { + Alert.alert( + billingCopy.timeoutTitle, + billingCopy.timeoutMessage, + [ + { text: billingCopy.dismiss, style: 'cancel' }, + { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, + ], + ); + } else if (isNetworkError(error)) { + Alert.alert( + billingCopy.noConnectionTitle, + billingCopy.noConnectionMessage, + [ + { text: billingCopy.dismiss, style: 'cancel' }, + { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, + ], + ); + } else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') { + Alert.alert( + billingCopy.notAPlantTitle, + billingCopy.notAPlantMessage, + [{ text: billingCopy.dismiss, style: 'cancel' }], + ); + } else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') { + Alert.alert( + billingCopy.genericErrorTitle, + isHealthMode ? billingCopy.healthProviderErrorMessage : billingCopy.providerErrorMessage, + [ + { text: billingCopy.dismiss, style: 'cancel' }, + { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, + ], + ); + } else { + Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); + } + setSelectedImage(null); + setIsAnalyzing(false); } finally { clearInterval(progressInterval); setIsAnalyzing(false); @@ -422,45 +471,45 @@ export default function ScannerScreen() { } } }; - - const takePicture = async () => { - if (!cameraRef.current || isAnalyzing) return; - await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + const takePicture = async () => { + if (!cameraRef.current || isAnalyzing) return; + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 }); if (photo) { const analysisUri = await resizeForAnalysis(photo.uri); setDemoResultVisible(false); setSelectedImage(analysisUri); analyzeImage(analysisUri, photo.uri); - } - }; - - const pickImage = async () => { - if (isAnalyzing) return; - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ['images'], - quality: 1, - base64: false, - }); + } + }; + + const pickImage = async () => { + if (isAnalyzing) return; + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 1, + base64: false, + }); if (!result.canceled && result.assets[0]) { const asset = result.assets[0]; const analysisUri = await resizeForAnalysis(asset.uri); setDemoResultVisible(false); setSelectedImage(asset.uri); - analyzeImage(analysisUri, asset.uri); - } - }; - + analyzeImage(analysisUri, asset.uri); + } + }; + const handleSave = async () => { if (analysisResult && selectedImage) { - if (!session) { - // Guest mode: store result and go to signup - setPendingPlant(analysisResult, selectedImage); - router.replace('/auth/signup'); - return; - } - + if (!session) { + // Guest mode: store result and go to signup + setPendingPlant(analysisResult, selectedImage); + router.replace('/auth/signup'); + return; + } + try { await savePlant(analysisResult, selectedImage); if (router.canGoBack()) { @@ -469,9 +518,9 @@ export default function ScannerScreen() { router.replace('/(tabs)'); } } catch (error) { - console.error('Saving identified plant failed', error); - Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); - } + console.error('Saving identified plant failed', error); + Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); + } } }; @@ -517,7 +566,7 @@ export default function ScannerScreen() { }); await hydrateSession(nextSession); posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' }); - router.replace(nextSession.isNewUser ? '/profile/billing' : '/(tabs)'); + router.replace(nextSession.isNewUser ? '/onboarding/source' : '/(tabs)'); } catch (error: any) { if (error?.code === 'ERR_REQUEST_CANCELED') { return; @@ -544,151 +593,151 @@ export default function ScannerScreen() { } router.replace('/onboarding'); }; - - const controlsPaddingBottom = Math.max(20, insets.bottom + 10); - const controlsPanelHeight = 28 + 80 + controlsPaddingBottom; - const analysisBottomOffset = controlsPanelHeight + 12; - const scanLineTranslateY = scanLineProgress.interpolate({ - inputRange: [0, 1], - outputRange: [24, 280], - }); - const scanPulseScale = scanPulse.interpolate({ - inputRange: [0, 1], - outputRange: [0.98, 1.02], - }); - const scanPulseOpacity = scanPulse.interpolate({ - inputRange: [0, 1], - outputRange: [0.22, 0.55], - }); - - // Show result - if (!isHealthMode && analysisResult && selectedImage) { - return ( - - ); - } - - // Camera permission - if (!permission?.granted) { - return ( - - - Camera access is required to scan plants. - - Continue - - - ); - } - - return ( - - {/* Header */} - - - - - - {isHealthMode ? billingCopy.healthTitle : t.scanner} + + const controlsPaddingBottom = Math.max(20, insets.bottom + 10); + const controlsPanelHeight = 28 + 80 + controlsPaddingBottom; + const analysisBottomOffset = controlsPanelHeight + 12; + const scanLineTranslateY = scanLineProgress.interpolate({ + inputRange: [0, 1], + outputRange: [24, 280], + }); + const scanPulseScale = scanPulse.interpolate({ + inputRange: [0, 1], + outputRange: [0.98, 1.02], + }); + const scanPulseOpacity = scanPulse.interpolate({ + inputRange: [0, 1], + outputRange: [0.22, 0.55], + }); + + // Show result + if (!isHealthMode && analysisResult && selectedImage) { + return ( + + ); + } + + // Camera permission + if (!permission?.granted) { + return ( + + + Camera access is required to scan plants. + + Continue + + + ); + } + + return ( + + {/* Header */} + + + + + + {isHealthMode ? billingCopy.healthTitle : t.scanner} - {isDemoMode ? 'Demo' : `${billingCopy.creditsLabel}: ${availableCredits}`} + {isDemoMode ? billingCopy.demoCreditsRemaining(demoScansRemaining) : `${billingCopy.creditsLabel}: ${availableCredits}`} - - {/* Camera */} - - {selectedImage ? ( - - ) : ( - - )} - - {/* Scan Frame */} - - {selectedImage && ( - - )} - {isAnalyzing && ( - <> - - - - )} - - - - - - - - {/* Analyzing Overlay */} - {isAnalyzing && ( - - - - - - {analysisProgress < 100 ? t.analyzing : t.result} - - - - {Math.round(analysisProgress)}% - - - - - - - - - {t.aiProcessing} - - - {analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3} - - - + + {/* Camera */} + + {selectedImage ? ( + + ) : ( + + )} + + {/* Scan Frame */} + + {selectedImage && ( + + )} + {isAnalyzing && ( + <> + + + + )} + + + + + + + + {/* Analyzing Overlay */} + {isAnalyzing && ( + + + + + + {analysisProgress < 100 ? t.analyzing : t.result} + + + + {Math.round(analysisProgress)}% + + + + + + + + + {t.aiProcessing} + + + {analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3} + + + )} {demoResultVisible && !isAnalyzing ? ( @@ -748,141 +797,141 @@ export default function ScannerScreen() { {/* Bottom Controls */} - - - {t.gallery} - - - - - - - - - {t.help} - - - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1 }, - header: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 10, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingTop: 60, - paddingHorizontal: 24, - }, - headerTitle: { fontSize: 18, fontWeight: '600' }, - creditBadge: { - borderWidth: 1, - borderRadius: 14, - paddingHorizontal: 8, - paddingVertical: 4, - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - creditBadgeText: { fontSize: 10, fontWeight: '700' }, - cameraContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - scanFrame: { - width: 256, - height: 320, - borderWidth: 2.5, - borderColor: '#ffffff50', - borderRadius: 28, - overflow: 'hidden', - }, - scanPulseFrame: { - ...StyleSheet.absoluteFillObject, - borderWidth: 1.5, - borderRadius: 28, - }, - scanLine: { - position: 'absolute', - left: 16, - right: 16, - height: 2, - borderRadius: 999, - shadowColor: '#ffffff', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.8, - shadowRadius: 8, - elevation: 6, - }, - corner: { position: 'absolute', width: 24, height: 24 }, - tl: { top: 16, left: 16, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 12 }, - tr: { top: 16, right: 16, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 12 }, - bl: { bottom: 16, left: 16, borderBottomWidth: 4, borderLeftWidth: 4, borderBottomLeftRadius: 12 }, - br: { bottom: 16, right: 16, borderBottomWidth: 4, borderRightWidth: 4, borderBottomRightRadius: 12 }, - controls: { - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - paddingHorizontal: 32, - paddingTop: 28, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - controlBtn: { alignItems: 'center', gap: 6 }, - controlBtnDisabled: { opacity: 0.5 }, - controlLabel: { fontSize: 11, fontWeight: '500' }, - shutterBtn: { - width: 80, - height: 80, - borderRadius: 40, - borderWidth: 4, - justifyContent: 'center', - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - elevation: 8, - }, - shutterInner: { width: 64, height: 64, borderRadius: 32 }, - shutterBtnDisabled: { opacity: 0.6 }, + style={[ + styles.controls, + { + backgroundColor: colors.background, + paddingBottom: controlsPaddingBottom, + }, + ]} + > + + + {t.gallery} + + + + + + + + + {t.help} + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: 60, + paddingHorizontal: 24, + }, + headerTitle: { fontSize: 18, fontWeight: '600' }, + creditBadge: { + borderWidth: 1, + borderRadius: 14, + paddingHorizontal: 8, + paddingVertical: 4, + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + creditBadgeText: { fontSize: 10, fontWeight: '700' }, + cameraContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + scanFrame: { + width: 256, + height: 320, + borderWidth: 2.5, + borderColor: '#ffffff50', + borderRadius: 28, + overflow: 'hidden', + }, + scanPulseFrame: { + ...StyleSheet.absoluteFillObject, + borderWidth: 1.5, + borderRadius: 28, + }, + scanLine: { + position: 'absolute', + left: 16, + right: 16, + height: 2, + borderRadius: 999, + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, + shadowRadius: 8, + elevation: 6, + }, + corner: { position: 'absolute', width: 24, height: 24 }, + tl: { top: 16, left: 16, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 12 }, + tr: { top: 16, right: 16, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 12 }, + bl: { bottom: 16, left: 16, borderBottomWidth: 4, borderLeftWidth: 4, borderBottomLeftRadius: 12 }, + br: { bottom: 16, right: 16, borderBottomWidth: 4, borderRightWidth: 4, borderBottomRightRadius: 12 }, + controls: { + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + paddingHorizontal: 32, + paddingTop: 28, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + controlBtn: { alignItems: 'center', gap: 6 }, + controlBtnDisabled: { opacity: 0.5 }, + controlLabel: { fontSize: 11, fontWeight: '500' }, + shutterBtn: { + width: 80, + height: 80, + borderRadius: 40, + borderWidth: 4, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + }, + shutterInner: { width: 64, height: 64, borderRadius: 32 }, + shutterBtnDisabled: { opacity: 0.6 }, analysisSheet: { - position: 'absolute', - left: 16, - right: 16, - borderRadius: 20, - borderWidth: 1, - paddingHorizontal: 16, - paddingVertical: 14, - zIndex: 20, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.28, - shadowRadius: 14, + position: 'absolute', + left: 16, + right: 16, + borderRadius: 20, + borderWidth: 1, + paddingHorizontal: 16, + paddingVertical: 14, + zIndex: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.28, + shadowRadius: 14, elevation: 14, }, demoSheet: { @@ -945,25 +994,25 @@ const styles = StyleSheet.create({ fontWeight: '700', }, analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }, - analysisBadge: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - borderRadius: 999, - paddingHorizontal: 10, - paddingVertical: 5, - }, - analysisLabel: { fontWeight: '700', fontSize: 12, letterSpacing: 0.2 }, - analysisPercent: { fontFamily: 'monospace', fontSize: 12, fontWeight: '700' }, - progressBg: { height: 9, borderRadius: 999, overflow: 'hidden', marginBottom: 10 }, - progressFill: { height: '100%', borderRadius: 4 }, - analysisFooter: { gap: 4 }, - analysisStatusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, - statusDot: { width: 8, height: 8, borderRadius: 4 }, - analysisStage: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, - analysisStageDetail: { fontSize: 11, lineHeight: 16, fontWeight: '500' }, - permissionContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }, - permissionText: { fontSize: 16, textAlign: 'center', marginBottom: 20 }, - permissionBtn: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 }, - permissionBtnText: { fontWeight: '700', fontSize: 15 }, -}); + analysisBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 5, + }, + analysisLabel: { fontWeight: '700', fontSize: 12, letterSpacing: 0.2 }, + analysisPercent: { fontFamily: 'monospace', fontSize: 12, fontWeight: '700' }, + progressBg: { height: 9, borderRadius: 999, overflow: 'hidden', marginBottom: 10 }, + progressFill: { height: '100%', borderRadius: 4 }, + analysisFooter: { gap: 4 }, + analysisStatusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + statusDot: { width: 8, height: 8, borderRadius: 4 }, + analysisStage: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, + analysisStageDetail: { fontSize: 11, lineHeight: 16, fontWeight: '500' }, + permissionContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }, + permissionText: { fontSize: 16, textAlign: 'center', marginBottom: 20 }, + permissionBtn: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 }, + permissionBtnText: { fontWeight: '700', fontSize: 15 }, +}); diff --git a/assets/onboarding_experience_mockup.png b/assets/onboarding_experience_mockup.png new file mode 100644 index 0000000..a7c1fa9 Binary files /dev/null and b/assets/onboarding_experience_mockup.png differ diff --git a/assets/onboarding_goal_mockup.png b/assets/onboarding_goal_mockup.png new file mode 100644 index 0000000..ad783ce Binary files /dev/null and b/assets/onboarding_goal_mockup.png differ diff --git a/assets/onboarding_health_scan_mockup.png b/assets/onboarding_health_scan_mockup.png new file mode 100644 index 0000000..88bd7e4 Binary files /dev/null and b/assets/onboarding_health_scan_mockup.png differ diff --git a/assets/onboarding_source_mockup.png b/assets/onboarding_source_mockup.png new file mode 100644 index 0000000..f9aaf38 Binary files /dev/null and b/assets/onboarding_source_mockup.png differ diff --git a/assets/paywall_scan_background.png b/assets/paywall_scan_background.png new file mode 100644 index 0000000..218f89f Binary files /dev/null and b/assets/paywall_scan_background.png differ diff --git a/assets/welcome_botanical_header.png b/assets/welcome_botanical_header.png new file mode 100644 index 0000000..cf4f61a Binary files /dev/null and b/assets/welcome_botanical_header.png differ diff --git a/assets/welcome_botanical_hero.png b/assets/welcome_botanical_hero.png new file mode 100644 index 0000000..2427ca8 Binary files /dev/null and b/assets/welcome_botanical_hero.png differ diff --git a/marketing/ad_creatives_greenlens_2026-05-08.md b/marketing/ad_creatives_greenlens_2026-05-08.md new file mode 100644 index 0000000..746265a --- /dev/null +++ b/marketing/ad_creatives_greenlens_2026-05-08.md @@ -0,0 +1,312 @@ +# GreenLens Ad Creatives + +Datum: 2026-05-08 +Sprache: Deutsch +Basis: GreenLens Pro als Premium-App fuer Pflanzen-Scan, Diagnose, Pflegeplan, Health Check, Erinnerungen, Sammlung und Standort-/Licht-Tipps. + +## Annahmen + +- Hauptziel: App-Install oder Landing-Page-Klick mit anschliessendem Pro-Upgrade. +- Zielgruppe: Zimmerpflanzen-Besitzer, Pflanzen-Anfaenger, Urban-Jungle-Fans, Nutzer mit kranken Pflanzen. +- Positionierung: Nicht nur Pflanzen bestimmen, sondern Ursache erkennen, naechsten Schritt bekommen und Pflege langfristig tracken. +- Wichtige Funnel-Regel: Nicht als dauerhaft kostenlose App positionieren. Demo-Scans nur als Einstieg, Pro ist das eigentliche Produkt. + +## Creative Angles + +1. Diagnose statt Raten: Symptome brauchen Kontext. +2. Rettungsplan: Foto machen, Ursache verstehen, 7-Tage-Plan bekommen. +3. Pflege-System: Scannen, tracken, erinnern lassen. +4. Standort/Licht: Der falsche Platz ist oft das Problem. +5. Premium Calm: Pflanzenpflege fuehlt sich ruhiger an, wenn der naechste Schritt klar ist. + +## Meta / Instagram Feed Ads + +Empfohlene Limits: Primary Text vorne stark halten, Headline ca. 40 Zeichen, Description ca. 30 Zeichen. + +### Meta Ad 1 - Diagnose statt Raten + +- Visual: Close-up einer gelben Monstera- oder Pothos-Blattspitze auf warmem cremefarbenem Hintergrund. Links kurze Overlays: "Gelb ist kein Befund." Darunter App-Screen mit Health Check Ergebnis. +- Primary Text: Gelbe Blaetter? GreenLens scannt deine Pflanze und zeigt dir moegliche Ursachen plus naechste Schritte. +- Headline: Pflanze scannen. Klarheit bekommen. +- Description: Diagnose mit KI +- CTA: App installieren + +### Meta Ad 2 - Rettungsplan + +- Visual: Split Frame. Links gestresste Pflanze, rechts GreenLens Ergebnis mit "Moegliche Ursachen", "Sofortmassnahmen", "7-Tage-Plan". +- Primary Text: Deine Pflanze sieht schlecht aus? Mach ein Foto und bekomme einen klaren Rettungsplan statt Bauchgefuehl. +- Headline: Dein Pflanzen-Rettungsplan +- Description: In Sekunden starten +- CTA: Mehr erfahren + +### Meta Ad 3 - Pflege-System + +- Visual: Drei App-Karten als Bento: Scan, Giesstermin, Pflanzen-Sammlung. Ruhiger Botanical-Archive Look. +- Primary Text: GreenLens ist dein ruhiges System fuer Pflanzenpflege: scannen, giessen, Health Checks und Wachstum tracken. +- Headline: Scan it. Track it. Grow it. +- Description: Pflege in einer App +- CTA: App installieren + +### Meta Ad 4 - Standort-Check + +- Visual: Pflanze am Fenster, Lichtstrahlen, dezenter Light-Meter/Standort-Check Screen. +- Primary Text: Manchmal ist nicht Wasser das Problem. GreenLens hilft dir, Licht, Standort und Pflege besser einzuordnen. +- Headline: Steht deine Pflanze falsch? +- Description: Standort besser pruefen +- CTA: Mehr erfahren + +### Meta Ad 5 - Ueberwaesserung + +- Visual: Droopende Pflanze neben Giesskanne. Text: "Mehr Wasser ist nicht immer Hilfe." +- Primary Text: Ueberwaesserung sieht oft aus wie Durst. GreenLens hilft dir, erst zu pruefen und dann zu handeln. +- Headline: Nicht sofort giessen +- Description: Erst Ursache finden +- CTA: App installieren + +### Meta Ad 6 - Premium App Store + +- Visual: App-Store-artiger Hero mit echtem Produktvideo im Phone Frame, Botanical-Archive Typografie, GreenLens Logo. +- Primary Text: Was waere, wenn jede Pflanze eine Anleitung haette? Oeffne GreenLens, scanne ein Blatt und bekomme den naechsten Schritt. +- Headline: Jede Pflanze mit Anleitung +- Description: GreenLens Pro +- CTA: App installieren + +## TikTok / Reels Video Ads + +Empfohlene Laenge: 12-20 Sekunden. Format: 9:16. + +### TikTok Ad 1 - "Gelb ist kein Befund" + +- Hook Text: Gelbe Blaetter sind nicht die Diagnose. +- Szene 1: Gelbes Blatt in Nahaufnahme, schnelle Textblende: "Nicht sofort giessen." +- Szene 2: App oeffnet Scanner, Blatt wird fotografiert. +- Szene 3: Ergebnis: moegliche Ursachen, Sofortmassnahmen, 7-Tage-Plan. +- VO: "Gelbe Blaetter koennen Wasser, Licht, Wurzeln oder Stress bedeuten. GreenLens hilft dir, erst die Ursache zu finden." +- CTA Text: Scanne deine Pflanze mit GreenLens. +- Ad Text: Gelbe Blaetter? Erst Ursache finden, dann handeln. + +### TikTok Ad 2 - "Drooping Trap" + +- Hook Text: Drooping heisst nicht automatisch Durst. +- Szene 1: Traurige Pflanze, Hand greift zur Giesskanne. +- Szene 2: Freeze Frame: "Stopp." +- Szene 3: GreenLens Scan und Checkliste: Licht, Wasser, Erde, Schädlinge. +- VO: "Viele Pflanzen sehen durstig aus, obwohl die Wurzeln gestresst sind. Scan sie, bevor du mehr Wasser gibst." +- CTA Text: Nicht raten. GreenLens nutzen. +- Ad Text: Drooping? GreenLens prueft mehr als nur Wasser. + +### TikTok Ad 3 - "Plant ER" + +- Hook Text: Deine Pflanze braucht Triage, keine Panik. +- Szene 1: Drei schnelle Symptome: gelb, braun, haengend. +- Szene 2: App erkennt Pflanze. +- Szene 3: Health Score, Ursachen, Aktionen jetzt. +- VO: "Wenn eine Pflanze kippt, brauchst du Reihenfolge. GreenLens macht aus Panik einen Plan." +- CTA Text: Starte deinen Health Check. +- Ad Text: Pflanzen-Health-Check direkt aus einem Foto. + +### TikTok Ad 4 - "Every Plant Instructions" + +- Hook Text: Was, wenn jede Pflanze eine Anleitung haette? +- Szene 1: Ruhiger Pflanzen-Hero. +- Szene 2: Scan-Moment mit App. +- Szene 3: Pflegeplan, Giesserinnerung, Sammlung. +- VO: "Oeffne GreenLens. Scanne ein Blatt. Bekomme Name, Pflegeplan und den naechsten Schritt." +- CTA Text: Scan it. Track it. Grow it. +- Ad Text: GreenLens macht Pflanzenpflege klarer. + +### TikTok Ad 5 - "Falscher Standort" + +- Hook Text: Vielleicht ist nicht Wasser das Problem. +- Szene 1: Pflanze in dunkler Ecke. +- Szene 2: Licht am Fenster, Standort-Check Visual. +- Szene 3: GreenLens zeigt Pflege- und Licht-Hinweise. +- VO: "Viele Pflanzen werden am falschen Platz gepflegt. GreenLens hilft dir, Standort und Pflege zusammen zu betrachten." +- CTA Text: Pruefe den Standort mit GreenLens. +- Ad Text: Wasser ist nicht immer die Antwort. + +### TikTok Ad 6 - "Beginner Mistake" + +- Hook Text: Der groesste Anfaengerfehler: zu viel tun. +- Szene 1: Wasser, Duenger, Umtopfen, alles schnell hintereinander. +- Szene 2: Text: "Mehr Pflege kann Stress machen." +- Szene 3: GreenLens Health Check und ruhiger Pflegeplan. +- VO: "Wenn du nicht weisst, was los ist, wird jede Massnahme zum Risiko. GreenLens zeigt dir den naechsten sinnvollen Schritt." +- CTA Text: Erst scannen. Dann handeln. +- Ad Text: Gute Pflanzenpflege beginnt mit Klarheit. + +## Google Ads Responsive Search Ads + +### Headlines + +1. GreenLens Pflanzen App +2. Pflanze per Foto erkennen +3. Pflanzenkrankheit erkennen +4. Gelbe Blaetter? Scannen +5. Pflanzenpflege mit KI +6. Health Check fuer Pflanzen +7. Pflegeplan in Sekunden +8. Nie wieder Giessen raten +9. Scan it. Track it. Grow it. +10. Pflanze krank? Foto machen +11. Standort & Licht pruefen +12. Dein Pflanzen-Rettungsplan +13. Zimmerpflanzen bestimmen +14. KI Scan fuer Pflanzen +15. GreenLens Pro starten + +### Descriptions + +1. Scanne deine Pflanze und erhalte Name, Pflegeplan und naechste Schritte. +2. GreenLens hilft bei gelben Blaettern, Schädlingen, Licht und Pflegefehlern. +3. Tracke Giessen, Wachstum, Notizen und Health Checks in einer ruhigen App. +4. Starte GreenLens Pro und mach aus Pflanzenpflege einen klaren Plan. + +### Display Paths + +- pflanzen / scanner +- health / check +- pflege / plan + +## Google Ad Groups + +### Ad Group: Pflanzen Bestimmen + +- Keywords: pflanzen bestimmen app, pflanze per foto erkennen, pflanzen scanner, zimmerpflanzen bestimmen, plant identifier app +- Best headlines: "Pflanze per Foto erkennen", "GreenLens Pflanzen App", "Zimmerpflanzen bestimmen" +- Best descriptions: 1, 3 + +### Ad Group: Pflanzen Krankheit + +- Keywords: pflanze krank was tun, gelbe blaetter pflanze, pflanzenkrankheiten erkennen, plant disease identifier, pflanze haengt +- Best headlines: "Pflanzenkrankheit erkennen", "Gelbe Blaetter? Scannen", "Health Check fuer Pflanzen" +- Best descriptions: 2, 4 + +### Ad Group: Pflege App + +- Keywords: pflanzenpflege app, giess erinnerung pflanzen, pflanzen pflegeplan, indoor plant care app +- Best headlines: "Pflanzenpflege mit KI", "Pflegeplan in Sekunden", "Nie wieder Giessen raten" +- Best descriptions: 1, 3 + +## Static Image Concepts + +### Concept A - Symptom Is Not Diagnosis + +- Format: 1080x1350 und 1080x1920 +- Main Text: "Gelbe Blaetter sind nur ein Signal." +- Subtext: "GreenLens findet moegliche Ursachen und naechste Schritte." +- Visual: Premium Botanical Archive. Ein gelbes Blatt als Herbar-Beleg, daneben ein klarer App-Health-Check Screen. Creme-Papier, tiefe Gruentoene, wenig UI, hohe Lesbarkeit. +- CTA: "Jetzt scannen" + +### Concept B - 7-Tage-Rettungsplan + +- Format: 1080x1350 und 1080x1920 +- Main Text: "Aus Pflanzen-Panik wird ein Plan." +- Subtext: "Foto machen. Ursache verstehen. 7 Tage handeln." +- Visual: Vorher/nachher Komposition mit App-Karten: Ursachen, Sofortmassnahmen, 7-Tage-Plan. +- CTA: "Health Check starten" + +### Concept C - Light & Location + +- Format: 1080x1350 und 1080x1920 +- Main Text: "Vielleicht steht sie nur falsch." +- Subtext: "Pruefe Licht, Standort und Pflege zusammen." +- Visual: Pflanze am Fenster, Lichtverlauf, dezente Standort-Analyse im Phone Frame. +- CTA: "Standort pruefen" + +### Concept D - Calm Plant OS + +- Format: 1080x1350 und 1080x1920 +- Main Text: "Eine App fuer deine Pflanzen." +- Subtext: "Scan. Pflegeplan. Erinnerungen. Health Check." +- Visual: App-Bento mit Scan, Timeline, Collection und Health Score. +- CTA: "GreenLens Pro starten" + +## UGC Creator Briefs + +### UGC 1 - Beginner Rescue + +- Creator: Pflanzen-Anfaenger mit echter gestresster Pflanze. +- Opening line: "Ich dachte, meine Pflanze braucht einfach mehr Wasser." +- Beats: Symptom zeigen, falsche Annahme nennen, GreenLens Scan zeigen, Ergebnis/Plan zeigen, erster Schritt umsetzen. +- Must say: "Ich pruefe jetzt erst die Ursache, bevor ich irgendwas mache." + +### UGC 2 - Plant Parent Routine + +- Creator: Urban-Jungle / Home Decor. +- Opening line: "Das ist meine 2-Minuten-Routine fuer alle Pflanzen, die komisch aussehen." +- Beats: Foto, Scan, Health Check, Giesstermin, Notiz/Growth Photo. +- Must say: "GreenLens ist fuer mich nicht nur Scanner, sondern mein Pflege-System." + +### UGC 3 - Light Mistake + +- Creator: Pflanzenpflege Account. +- Opening line: "Diese Pflanze war nicht durstig. Sie stand einfach falsch." +- Beats: dunkler Standort, App-Check, Umstellen, Pflegeplan. +- Must say: "Wasser ist nicht immer die Antwort." + +## Testing Matrix + +Prioritaet 1: +- Angle: Diagnose statt Raten +- Plattform: Meta + TikTok +- KPI: Install CVR und Trial/Pro-Start +- Creatives: Meta Ad 1, TikTok Ad 1, Static Concept A + +Prioritaet 2: +- Angle: Rettungsplan +- Plattform: Meta + Google Search +- KPI: Landing-Page CVR und Paywall-View-to-Purchase +- Creatives: Meta Ad 2, TikTok Ad 3, Static Concept B + +Prioritaet 3: +- Angle: Pflege-System +- Plattform: Meta Retargeting +- KPI: Install-to-Onboarding-Complete +- Creatives: Meta Ad 3, TikTok Ad 4, Static Concept D + +## Compliance / Copy Guardrails + +- Keine garantierte Heilung versprechen. +- Nicht behaupten, Krankheiten "sicher" zu erkennen; besser: "moegliche Ursachen", "Health Check", "naechste Schritte". +- Nicht dauerhaft kostenlos positionieren. +- Keine medizinisch anmutende Sicherheitssprache wie "100% Diagnose" oder "rettet jede Pflanze". +- "In Sekunden" nur fuer Nutzererlebnis verwenden, nicht als technische Garantie fuer jedes Netzwerk. + +## Zeichenlimit-Check + +Maschinell geprueft am 2026-05-08. Ergebnis: 31 / 31 Texte innerhalb der Limits. + +| Asset | Zeichen / Limit | +| --- | ---: | +| Meta H1 | 35 / 40 | +| Meta H2 | 26 / 40 | +| Meta H3 | 27 / 40 | +| Meta H4 | 27 / 40 | +| Meta H5 | 20 / 40 | +| Meta H6 | 26 / 40 | +| TikTok 1 | 50 / 80 | +| TikTok 2 | 47 / 80 | +| TikTok 3 | 44 / 80 | +| TikTok 4 | 38 / 80 | +| TikTok 5 | 35 / 80 | +| TikTok 6 | 41 / 80 | +| RSA H1 | 22 / 30 | +| RSA H2 | 25 / 30 | +| RSA H3 | 26 / 30 | +| RSA H4 | 23 / 30 | +| RSA H5 | 21 / 30 | +| RSA H6 | 26 / 30 | +| RSA H7 | 22 / 30 | +| RSA H8 | 24 / 30 | +| RSA H9 | 27 / 30 | +| RSA H10 | 26 / 30 | +| RSA H11 | 24 / 30 | +| RSA H12 | 26 / 30 | +| RSA H13 | 24 / 30 | +| RSA H14 | 21 / 30 | +| RSA H15 | 21 / 30 | +| RSA D1 | 72 / 90 | +| RSA D2 | 76 / 90 | +| RSA D3 | 73 / 90 | +| RSA D4 | 67 / 90 | diff --git a/marketing/greenlens_20_plant_infographic_slideshows.md b/marketing/greenlens_20_plant_infographic_slideshows.md new file mode 100644 index 0000000..4559225 --- /dev/null +++ b/marketing/greenlens_20_plant_infographic_slideshows.md @@ -0,0 +1,1383 @@ +# GreenLens Pro: 20 Plant-Care Infographic Slideshows + +Purpose: image-generation prompts for TikTok / Instagram / Pinterest-style plant-care slideshows inspired by high-performing save-first plant infographics. + +Core rules across all slideshows: +- Every slideshow has 6 slides: 5 educational infographic slides plus 1 final GreenLens Pro CTA slide. +- The global prompt is repeated inside every slideshow so each set can be copied independently. +- Text should be centered on every slide. +- Color theme stays consistent: warm ivory background, dark forest green text, muted sage accents, soft botanical shadows. +- Claims are phrased conservatively where needed. Avoid hard claims like "plants clean your air" or "this plant guarantees no pests." + +Fact-check sources used: +- UMN Extension: Lighting for indoor plants: https://extension.umn.edu/planting-and-growing-guides/lighting-indoor-plants +- UMN Extension: Watering houseplants: https://extension.umn.edu/yard-and-garden-news/watering-houseplants +- Illinois Extension: Houseplant propagation: https://extension.illinois.edu/houseplants/propagation +- Illinois Extension: Houseplant care: https://extension.illinois.edu/houseplants/care +- Missouri Botanical Garden: Watering indoor plants: https://www.missouribotanicalgarden.org/gardens-gardening/your-garden/help-for-the-home-gardener/advice-tips-resources/visual-guides/how-to-water-indoor-plants +- Missouri Botanical Garden: Common indoor plant problems: https://www.missouribotanicalgarden.org/gardens-gardening/your-garden/help-for-the-home-gardener/advice-tips-resources/visual-guides/problems-common-to-many-indoor-plants +- Penn State Extension: Humidity and houseplants: https://extension.psu.edu/humidity-and-houseplants +- ASPCA toxic and non-toxic plant database: https://www.aspca.org/pet-care/animal-poison-control/toxic-and-non-toxic-plants +- EPA indoor air pollution note: https://www.epa.gov/sites/default/files/2015-01/documents/indoor_air_pollution.pdf +- Review on potted plants and indoor VOCs: https://pubmed.ncbi.nlm.nih.gov/31695112/ + +--- + +## Slideshow 1: 6 Plants You Can Grow From Cuttings + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "GROW THESE FROM CUTTINGS" +Subheadline centered: "Beginner-friendly propagation picks" + +Show six realistic botanical illustrations in two clean rows: Pothos, Tradescantia, Philodendron, Monstera cutting with node, Coleus, and Mint. Add small centered labels under each plant. Use subtle sage connector lines pointing to tiny water-glass rooting icons. +``` + +### Slide 2 Prompt + +```text +Headline centered: "LOOK FOR A NODE" + +Show a realistic stem cutting close-up in the center with one subtle sage circle around a node. Add three centered labels around it: "Node", "Leaf", "Clean cut". Keep the slide minimal and reference-card style. +``` + +### Slide 3 Prompt + +```text +Headline centered: "WATER OR SOIL?" + +Create a split but centered composition: left side a clear glass with a pothos cutting in water, right side a small pot with moist airy soil. Center the text: "Both can work. Keep roots moist, not soggy." Use thin sage divider line between both methods. +``` + +### Slide 4 Prompt + +```text +Headline centered: "ROOTING CHECKLIST" + +Create five centered checklist rows with tiny realistic icons: "Clean scissors", "Healthy parent plant", "Bright indirect light", "Warm spot", "Moist, not soggy". Use muted sage checkmarks and thin dividers. +``` + +### Slide 5 Prompt + +```text +Headline centered: "WHEN TO POT UP" + +Show a realistic cutting with visible white roots in a glass jar. Center the supporting text: "Wait for several healthy roots before moving to soil." Add a small terracotta pot illustration below. +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "NOT SURE WHAT YOUR PLANT NEEDS?" +Subheadline centered: "Use GreenLens Pro." + +Show one healthy realistic potted plant centered below the text. Add a subtle sage scan frame around one leaf and tiny icons for water, light, roots, and leaf health. Keep it clean, calm, premium, and not ad-heavy. +``` + +--- + +## Slideshow 2: Plant These For Low-Light Corners + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT THESE:" +Subheadline centered: "FOR LOW-LIGHT CORNERS" + +Show six realistic potted plants in a clean grid: ZZ Plant, Snake Plant, Pothos, Cast Iron Plant, Chinese Evergreen, Peace Lily. Add centered labels. Use a soft shaded corner scene, but keep the background ivory. +``` + +### Slide 2 Prompt + +```text +Headline centered: "LOW LIGHT IS NOT NO LIGHT" + +Show a simple realistic room corner with a north-facing window glow. Center text: "If you can read there during the day, some tolerant foliage plants may adapt." Use a soft book icon and a window icon. +``` + +### Slide 3 Prompt + +```text +Headline centered: "EXPECT SLOWER GROWTH" + +Show the same plant in two small centered comparison panels: bright indirect light with fuller growth, low light with slower compact growth. Add text: "Growth may slow. Variegation may fade." +``` + +### Slide 4 Prompt + +```text +Headline centered: "ROTATE EVERY FEW WEEKS" + +Show a realistic potted pothos with a subtle circular arrow around the pot. Center supporting text: "Even light helps the plant grow more evenly." +``` + +### Slide 5 Prompt + +```text +Headline centered: "WATCH THE SOIL" + +Show a small soil moisture illustration with a finger test icon. Center text: "Low light can mean slower drying. Check before watering." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "NOT SURE IF YOUR ROOM IS TOO DARK?" +Subheadline centered: "Use GreenLens Pro." + +Show a healthy potted ZZ plant centered with a subtle scan frame and small light-level icon. Keep premium, clean, centered, and calm. +``` + +--- + +## Slideshow 3: Yellow Leaves? Check These First + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "YELLOW LEAVES?" +Subheadline centered: "CHECK THESE FIRST" + +Show one realistic yellowing houseplant leaf centered with three subtle sage-orange symptom circles. Keep the same cream background and centered typography. +``` + +### Slide 2 Prompt + +```text +Headline centered: "TOO MUCH WATER?" + +Show a potted plant with damp dark soil and yellow lower leaves. Center three small labels: "Wet soil", "Soft stems", "Fungus gnats". Use tiny icons and thin dividers. +``` + +### Slide 3 Prompt + +```text +Headline centered: "TOO LITTLE LIGHT?" + +Show a pale plant leaning toward a soft window glow. Center text: "Pale growth and slow growth can point to low light." Keep it minimal and factual. +``` + +### Slide 4 Prompt + +```text +Headline centered: "ROOT STRESS?" + +Show a clean cutaway pot with circling roots. Center labels: "Crowded roots", "Fast-drying soil", "Stalled growth". Use botanical cutaway style. +``` + +### Slide 5 Prompt + +```text +Headline centered: "PESTS OR LEAF DAMAGE?" + +Show a realistic leaf underside with tiny speckles, small webbing, and subtle markers. Center labels: "Spots", "Sticky residue", "Webbing". Keep pests small and tasteful. +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "YELLOW LEAVES CAN MEAN MANY THINGS" +Subheadline centered: "Use GreenLens Pro." + +Show a centered plant leaf with a subtle scan frame and diagnosis icons for water, light, roots, and pests. Premium cream botanical style. +``` + +--- + +## Slideshow 4: Pet-Safer Plant Picks + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PET-SAFER PLANT PICKS" +Subheadline centered: "ASPCA-listed non-toxic options" + +Show five realistic potted plants in a clean row: Spider Plant, Boston Fern, Calathea, Parlor Palm, Peperomia. Add small centered labels. +``` + +### Slide 2 Prompt + +```text +Headline centered: "SPIDER PLANT" + +Show a realistic spider plant in a neutral hanging pot. Center text: "Often listed as non-toxic to cats and dogs." Add tiny cat and dog line icons below. +``` + +### Slide 3 Prompt + +```text +Headline centered: "BOSTON FERN" + +Show a lush realistic Boston fern in a simple pot. Center text: "A soft, pet-safer foliage option." Keep text centered and minimal. +``` + +### Slide 4 Prompt + +```text +Headline centered: "CALATHEA" + +Show a realistic Calathea with patterned leaves. Center text: "Pet-safer, but still needs the right humidity and light." Add small humidity and indirect light icons. +``` + +### Slide 5 Prompt + +```text +Headline centered: "PET-SAFER DOES NOT MEAN SNACK" + +Show a plant on a small stand with a subtle distance line from a pet bowl icon. Center text: "Chewing any plant can still upset a pet's stomach." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "CHECK BEFORE YOU BRING IT HOME" +Subheadline centered: "Use GreenLens Pro." + +Show a centered plant with a subtle scan frame and tiny pet-safe checklist icon. Keep the same warm ivory and forest green palette. +``` + +--- + +## Slideshow 5: 6 Plants You Cannot Kill Easily + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "6 PLANTS YOU CAN'T KILL EASILY" +Subheadline centered: "Forgiving picks for beginners" + +Show six realistic potted plants in two rows: Snake Plant, ZZ Plant, Pothos, Jade Plant, Aloe Vera, Ponytail Palm. Add centered labels. +``` + +### Slide 2 Prompt + +```text +Headline centered: "SNAKE PLANT" + +Show a realistic snake plant centered. Add centered text: "Tolerates low light and infrequent watering, but still needs drainage." +``` + +### Slide 3 Prompt + +```text +Headline centered: "ZZ PLANT" + +Show a realistic ZZ plant centered. Add centered text: "Good for beginners. Let soil dry between waterings." +``` + +### Slide 4 Prompt + +```text +Headline centered: "POTHOS" + +Show a trailing pothos in a simple pot. Center text: "Adaptable foliage plant. Bright indirect light keeps it fuller." +``` + +### Slide 5 Prompt + +```text +Headline centered: "WATER LESS, NOT NEVER" + +Show a small watering can, dry topsoil, and drainage hole icons arranged around a centered pot. Center text: "Easy-care plants still need the right timing." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "WANT A CARE PLAN FOR YOUR PLANT?" +Subheadline centered: "Use GreenLens Pro." + +Show the six plants as tiny icons around one centered scan frame. Keep it premium and clean. +``` + +--- + +## Slideshow 6: Plant These To Support Pollinators + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT THESE:" +Subheadline centered: "TO SUPPORT POLLINATORS" + +Show five realistic flowering plants in pots: Lavender, Salvia, Zinnia, Sunflower, Marigold. Add centered labels and tiny bee/butterfly icons below. +``` + +### Slide 2 Prompt + +```text +Headline centered: "BEES LIKE BLOOMS" + +Show lavender and salvia with small tasteful bee illustrations. Center text: "Choose flowers with nectar and pollen." Keep it clean and educational. +``` + +### Slide 3 Prompt + +```text +Headline centered: "BUTTERFLIES NEED LANDING SPOTS" + +Show zinnia and marigold flowers with a butterfly icon. Center text: "Flat, open blooms can be easier to visit." +``` + +### Slide 4 Prompt + +```text +Headline centered: "KEEP BLOOMS COMING" + +Show a small sequence of flowers across seasons. Center labels: "Spring", "Summer", "Fall". Use subtle sage arrows. +``` + +### Slide 5 Prompt + +```text +Headline centered: "SKIP HARSH SPRAYS" + +Show a flower with a small crossed-out pesticide bottle icon. Center text: "Avoid unnecessary pesticides around pollinator plants." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "FIND PLANTS THAT FIT YOUR SPACE" +Subheadline centered: "Use GreenLens Pro." + +Show a centered flowering pot with a subtle scan frame and small sun/water icons. Same cream and forest green style. +``` + +--- + +## Slideshow 7: Plant These For A Calm Room + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT THESE:" +Subheadline centered: "FOR A CALM ROOM" + +Show six realistic potted plants in a clean grid: Golden Pothos, Snake Plant, Orchid, Peace Lily, Spider Plant, Lavender. Add labels and a soft calm-room visual mood. +``` + +### Slide 2 Prompt + +```text +Headline centered: "UPRIGHT SHAPES" + +Show snake plant and peace lily centered. Text centered: "Tall shapes add structure without visual clutter." +``` + +### Slide 3 Prompt + +```text +Headline centered: "TRAILING VINES" + +Show pothos trailing from a shelf. Text centered: "Soft vines make shelves feel warmer." +``` + +### Slide 4 Prompt + +```text +Headline centered: "SOFT TEXTURE" + +Show spider plant and fern-like leaf texture. Text centered: "Fine leaves add movement and softness." +``` + +### Slide 5 Prompt + +```text +Headline centered: "MATCH CARE TO PLACEMENT" + +Show three centered icons: light, humidity, watering. Text centered: "A beautiful plant still needs the right spot." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "CHOOSE PLANTS THAT FIT YOUR HOME" +Subheadline centered: "Use GreenLens Pro." + +Show a centered room plant with a subtle scan frame and small care icons. Keep it premium and serene. +``` + +--- + +## Slideshow 8: Natural Pest Support Before Sprays + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PESTS?" +Subheadline centered: "TRY GENTLE STEPS FIRST" + +Show a realistic leaf with tiny pest speckles and a centered checklist motif. Keep pests small, clean, and not gross. +``` + +### Slide 2 Prompt + +```text +Headline centered: "ISOLATE THE PLANT" + +Show one potted plant slightly separated from three other small plant icons. Center text: "Keep it away from nearby plants while you check." +``` + +### Slide 3 Prompt + +```text +Headline centered: "CHECK UNDER LEAVES" + +Show a realistic leaf underside with a magnifying glass icon. Center labels: "Eggs", "Webbing", "Sticky residue". Use subtle markers. +``` + +### Slide 4 Prompt + +```text +Headline centered: "RINSE AND WIPE" + +Show a leaf being rinsed as a clean botanical illustration without hands. Center text: "A gentle rinse can remove some visible pests." +``` + +### Slide 5 Prompt + +```text +Headline centered: "MONITOR BEFORE ESCALATING" + +Show a simple 3-day plant check calendar with leaf icons. Center text: "Watch for new damage before choosing stronger treatment." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "NEED HELP IDENTIFYING THE DAMAGE?" +Subheadline centered: "Use GreenLens Pro." + +Show a centered leaf scan frame with tiny pest, water, and light icons. Same premium botanical style. +``` + +--- + +## Slideshow 9: Herbs That May Help Deter Insects + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT THESE:" +Subheadline centered: "FOR NATURAL PEST SUPPORT" + +Show five realistic potted herbs: Basil, Rosemary, Mint, Lemongrass, Lavender. Under them, show small pest icons: mosquitoes, flies, ants, moths, aphids. Use thin curved arrows. +``` + +### Slide 2 Prompt + +```text +Headline centered: "AROMATIC HERBS" + +Show basil, mint, rosemary, and lavender centered. Text centered: "Strong scents may help confuse some insects nearby." Avoid guarantee language. +``` + +### Slide 3 Prompt + +```text +Headline centered: "LEMONGRASS + CITRONELLA TYPES" + +Show lemongrass-style grass in a pot. Text centered: "Often used outdoors for scent-based mosquito support." Keep it carefully worded. +``` + +### Slide 4 Prompt + +```text +Headline centered: "CONTAINER PLACEMENT MATTERS" + +Show herbs grouped near a balcony table. Text centered: "Place herbs where you sit or cook, with enough sun." +``` + +### Slide 5 Prompt + +```text +Headline centered: "NOT A GUARANTEE" + +Show a balanced icon layout: herb pot, open window, screen, clean surface. Text centered: "Use herbs as support, not your only pest plan." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "FIND HERBS THAT FIT YOUR LIGHT" +Subheadline centered: "Use GreenLens Pro." + +Show the five herbs around one centered scan frame. Premium cream background, forest green text. +``` + +--- + +## Slideshow 10: Bathroom Plants That Like Humidity + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT THESE:" +Subheadline centered: "FOR HUMID ROOMS" + +Show five realistic potted plants: Boston Fern, Calathea, Prayer Plant, Bird's Nest Fern, Spider Plant. Add centered labels and tiny humidity icons. +``` + +### Slide 2 Prompt + +```text +Headline centered: "HUMIDITY HELPS" + +Show a soft bathroom shelf scene with a fern and calathea, no people. Center text: "Some tropical foliage prefers more humidity." +``` + +### Slide 3 Prompt + +```text +Headline centered: "LIGHT STILL MATTERS" + +Show a bathroom window icon and a plant. Center text: "A humid room still needs enough natural or grow light." +``` + +### Slide 4 Prompt + +```text +Headline centered: "POTS SHOULD NOT SIT IN WATER" + +Show a pot above a pebble tray with water below the stones. Center text: "Pebble trays can add humidity without soaking roots." +``` + +### Slide 5 Prompt + +```text +Headline centered: "GROUP PLANTS" + +Show three humidity-loving plants grouped together. Center text: "Grouping plants can raise local humidity slightly." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "NOT SURE IF YOUR ROOM WORKS?" +Subheadline centered: "Use GreenLens Pro." + +Show a centered fern with a scan frame and humidity/light icons. Keep it calm and premium. +``` + +--- + +## Slideshow 11: 5 Signs Your Plant Is Overwatered + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "OVERWATERED?" +Subheadline centered: "5 SIGNS TO CHECK" + +Show a realistic potted plant with yellow lower leaves and dark wet soil. Keep centered and clean. +``` + +### Slide 2 Prompt + +```text +Headline centered: "SOIL STAYS WET" + +Show a soil surface close-up with a moisture droplet icon. Center text: "Wet for days can stress roots." +``` + +### Slide 3 Prompt + +```text +Headline centered: "YELLOW LOWER LEAVES" + +Show lower leaves turning yellow on a realistic plant. Center text: "Yellowing can have many causes, but water is one to check." +``` + +### Slide 4 Prompt + +```text +Headline centered: "SOFT STEMS" + +Show a plant stem base with subtle dark soft area markers. Center text: "Mushy stems can signal root trouble." +``` + +### Slide 5 Prompt + +```text +Headline centered: "FUNGUS GNATS" + +Show a small pot with tiny tasteful gnat icons near wet soil. Center text: "Gnats often like consistently damp potting mix." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "CHECK BEFORE YOU WATER AGAIN" +Subheadline centered: "Use GreenLens Pro." + +Show a centered soil moisture check icon and a plant scan frame. Same cream, sage, forest green style. +``` + +--- + +## Slideshow 12: Weekly Plant Check Routine + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "10-MINUTE PLANT CHECK" +Subheadline centered: "Save this weekly routine" + +Show a clean centered checklist card with five botanical icons: soil, leaves, roots, light, new growth. +``` + +### Slide 2 Prompt + +```text +Headline centered: "CHECK SOIL" + +Show a finger-test soil icon and a potted plant. Center text: "Water based on soil moisture, not only the calendar." +``` + +### Slide 3 Prompt + +```text +Headline centered: "LOOK UNDER LEAVES" + +Show a leaf underside with magnifier icon. Center text: "Catch pests and spotting early." +``` + +### Slide 4 Prompt + +```text +Headline centered: "REMOVE DEAD LEAVES" + +Show a small clean scissors icon and a fallen yellow leaf. Center text: "Trim dead material with clean tools." +``` + +### Slide 5 Prompt + +```text +Headline centered: "NOTE NEW GROWTH" + +Show a new leaf unfurling. Center text: "New growth is one of the best care signals." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "TURN CHECKS INTO A CARE PLAN" +Subheadline centered: "Use GreenLens Pro." + +Show a centered potted plant with a subtle scan frame and checklist icons. Keep clean and calm. +``` + +--- + +## Slideshow 13: Best Plants For Forgetful Waterers + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "FORGET TO WATER?" +Subheadline centered: "Try these forgiving plants" + +Show five realistic potted plants: Snake Plant, ZZ Plant, Jade Plant, Aloe Vera, Ponytail Palm. Add centered labels. +``` + +### Slide 2 Prompt + +```text +Headline centered: "THICK LEAVES STORE WATER" + +Show jade plant and aloe vera close-ups. Center text: "Succulent leaves help the plant handle dry spells." +``` + +### Slide 3 Prompt + +```text +Headline centered: "RHIZOMES HELP ZZ PLANTS" + +Show a clean cutaway of ZZ plant rhizomes under soil. Center text: "ZZ plants store water below the soil." +``` + +### Slide 4 Prompt + +```text +Headline centered: "DRAINAGE STILL MATTERS" + +Show a pot with drainage holes and a small saucer. Center text: "Forgiving plants can still rot in soggy soil." +``` + +### Slide 5 Prompt + +```text +Headline centered: "WATER DEEPLY, THEN WAIT" + +Show a watering can and dry-to-moist soil meter icons. Center text: "Let the potting mix dry appropriately before watering again." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "NEED REMINDERS THAT MATCH YOUR PLANT?" +Subheadline centered: "Use GreenLens Pro." + +Show a centered plant with scan frame and small watering reminder icon. Premium botanical style. +``` + +--- + +## Slideshow 14: Kitchen Herb Garden Starter + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT THESE:" +Subheadline centered: "FOR A KITCHEN HERB STARTER" + +Show five realistic potted herbs: Basil, Mint, Parsley, Chives, Rosemary. Add centered labels and a clean kitchen-window hint. +``` + +### Slide 2 Prompt + +```text +Headline centered: "START WITH SUN" + +Show herbs on a bright windowsill. Center text: "Most kitchen herbs need bright light to grow well." +``` + +### Slide 3 Prompt + +```text +Headline centered: "BASIL LIKES WARMTH" + +Show basil centered in a terracotta pot. Center text: "Give basil warmth, light, and consistent moisture." +``` + +### Slide 4 Prompt + +```text +Headline centered: "MINT WANTS ITS OWN POT" + +Show mint in a separate pot with a subtle boundary line. Center text: "Mint spreads fast, so containers help control it." +``` + +### Slide 5 Prompt + +```text +Headline centered: "HARVEST LIGHTLY" + +Show herb sprigs with small scissors icon. Center text: "Pick small amounts often to encourage fresh growth." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "BUILD A HERB ROUTINE THAT WORKS" +Subheadline centered: "Use GreenLens Pro." + +Show a centered herb pot with scan frame and sun/water icons. Keep it clean and premium. +``` + +--- + +## Slideshow 15: Repotting Signs + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "TIME TO REPOT?" +Subheadline centered: "5 signs to check" + +Show a realistic potted plant with roots slightly visible from the bottom drainage hole. +``` + +### Slide 2 Prompt + +```text +Headline centered: "ROOTS CIRCLE THE POT" + +Show a clean cutaway pot with circling roots. Center text: "Crowded roots can limit water and nutrient uptake." +``` + +### Slide 3 Prompt + +```text +Headline centered: "WATER RUNS THROUGH FAST" + +Show water flowing quickly through a pot into a saucer. Center text: "Fast drainage can mean the root ball is crowded." +``` + +### Slide 4 Prompt + +```text +Headline centered: "GROWTH HAS SLOWED" + +Show a plant with tiny new leaf icon paused. Center text: "Slow growth can have many causes, including root space." +``` + +### Slide 5 Prompt + +```text +Headline centered: "GO ONE SIZE UP" + +Show three pots with the middle one highlighted. Center text: "Choose a slightly larger pot, not a huge one." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "NOT SURE IF IT NEEDS REPOTTING?" +Subheadline centered: "Use GreenLens Pro." + +Show a centered cutaway pot with scan frame and root icon. Same premium botanical style. +``` + +--- + +## Slideshow 16: Leaf Clues Your Plant Gives You + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "WHAT YOUR LEAVES MAY BE SAYING" +Subheadline centered: "Save this quick guide" + +Show five realistic leaf icons in a centered grid: yellow, crispy brown tip, soft dark spot, curled leaf, pale new growth. +``` + +### Slide 2 Prompt + +```text +Headline centered: "YELLOWING" + +Show a yellowing leaf. Center text: "Check watering, light, roots, pests, and stress." +``` + +### Slide 3 Prompt + +```text +Headline centered: "CRISPY TIPS" + +Show a leaf with brown crispy tips. Center text: "Often linked with dryness, salts, or watering stress." +``` + +### Slide 4 Prompt + +```text +Headline centered: "SOFT DARK SPOTS" + +Show a leaf with soft dark spots and subtle markers. Center text: "Check for overwatering, cold damage, or disease." +``` + +### Slide 5 Prompt + +```text +Headline centered: "PALE NEW GROWTH" + +Show pale new leaves emerging. Center text: "Check light and nutrients before changing care." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "SCAN THE LEAF, FIND THE CLUE" +Subheadline centered: "Use GreenLens Pro." + +Show a centered leaf with a subtle scan frame and small diagnosis icons. Keep it clean and premium. +``` + +--- + +## Slideshow 17: Office Desk Plants + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT THESE:" +Subheadline centered: "FOR OFFICE DESKS" + +Show five compact realistic potted plants: Peperomia, Pothos, ZZ Plant, Snake Plant, Haworthia. Add centered labels. +``` + +### Slide 2 Prompt + +```text +Headline centered: "MATCH THE LIGHT" + +Show a desk near a soft window and a small desk lamp icon. Center text: "Low-light tolerant does not mean no light." +``` + +### Slide 3 Prompt + +```text +Headline centered: "SMALL POTS DRY FAST" + +Show a tiny desk pot and soil moisture icon. Center text: "Check small pots more often." +``` + +### Slide 4 Prompt + +```text +Headline centered: "WEEKEND REALITY" + +Show a simple Friday-to-Monday calendar with a plant icon. Center text: "Choose plants that can handle your work schedule." +``` + +### Slide 5 Prompt + +```text +Headline centered: "KEEP IT TIDY" + +Show a compact plant with a clean saucer. Center text: "Use drainage and avoid standing water on desks." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "FIND A DESK PLANT THAT FITS" +Subheadline centered: "Use GreenLens Pro." + +Show a compact desk plant centered with scan frame and light/water icons. +``` + +--- + +## Slideshow 18: Balcony Plant Ideas + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "SMALL BALCONY?" +Subheadline centered: "Plant these in containers" + +Show six realistic container plants: Lavender, Rosemary, Basil, Nasturtium, Marigold, Trailing Ivy. Add centered labels. +``` + +### Slide 2 Prompt + +```text +Headline centered: "CHECK SUN HOURS" + +Show a balcony rail with sun icons. Center text: "Choose plants based on morning, afternoon, or shade exposure." +``` + +### Slide 3 Prompt + +```text +Headline centered: "HERBS LIKE CONTAINERS" + +Show basil, rosemary, and mint in separate pots. Center text: "Container herbs can work well when light is strong enough." +``` + +### Slide 4 Prompt + +```text +Headline centered: "FLOWERS ADD VISITORS" + +Show marigold and nasturtium with small pollinator icons. Center text: "Blooms can support visiting insects." +``` + +### Slide 5 Prompt + +```text +Headline centered: "WIND DRIES POTS FAST" + +Show a balcony pot with subtle wind lines. Center text: "Outdoor containers may need more frequent checks." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "BUILD A BALCONY THAT FITS YOUR LIGHT" +Subheadline centered: "Use GreenLens Pro." + +Show a centered balcony planter with scan frame and sun/water icons. +``` + +--- + +## Slideshow 19: Plant Care Myths To Rethink + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT CARE MYTHS" +Subheadline centered: "Save this before you water" + +Show five clean myth icons around a centered plant: ice cube, calendar, huge pot, mist bottle, yellow leaf. +``` + +### Slide 2 Prompt + +```text +Headline centered: "MYTH: WATER EVERY SUNDAY" + +Show a calendar and soil moisture icon. Center text: "Check the soil first. Plants do not all dry at the same speed." +``` + +### Slide 3 Prompt + +```text +Headline centered: "MYTH: BIGGER POT IS BETTER" + +Show a tiny plant in an oversized pot with a subtle caution icon. Center text: "Too much wet soil can stress roots." +``` + +### Slide 4 Prompt + +```text +Headline centered: "MYTH: MISTING FIXES DRY AIR" + +Show a mist bottle and humidity icon. Center text: "Misting is brief. Humidifiers, grouping, or pebble trays can help more." +``` + +### Slide 5 Prompt + +```text +Headline centered: "MYTH: YELLOW MEANS ONE THING" + +Show four tiny icons around a yellow leaf: water, light, roots, pests. Center text: "Yellow leaves need context." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "OBSERVE BEFORE YOU CHANGE CARE" +Subheadline centered: "Use GreenLens Pro." + +Show a centered plant scan frame with calm checklist icons. Keep the reference-card look. +``` + +--- + +## Slideshow 20: Plant These For A Better Sleep Corner + +### Global Prompt + +```text +Create a vertical 9:16 plant-care infographic slide, 1080x1920. + +Design style: premium botanical infographic inspired by save-worthy plant reference cards. Use a warm ivory / cream background with subtle paper grain, dark forest green typography, muted sage accent lines, soft beige shadows, and realistic botanical illustrations. The layout should feel cleaner, more premium, and more realistic than a vintage plant poster. + +Typography and layout: all main text must be centered. Use an elegant readable serif for large headings and a clean small sans-serif for plant labels. Keep generous spacing, strong hierarchy, and mobile-first readability. Use organized rows or grids with 4-8 plants or icons, thin curved connector lines, and small labels under each illustration. + +Visual rules: use realistic botanical illustrations, realistic potted plants, or clean botanical cutaway illustrations. Avoid cartoons, messy borders, heavy decoration, hard-to-read tiny text, misspelled labels, TikTok UI, watermarks, logos, avatars, people, and extra random text. + +Color consistency: warm ivory background, dark forest green text, muted sage dividers and arrows, terracotta or neutral ceramic pots, natural greens, soft lavender/yellow/orange flower accents only when botanically relevant. +``` + +### Slide 1 Prompt + +```text +Headline centered: "PLANT THESE:" +Subheadline centered: "FOR A CALM SLEEP CORNER" + +Show five realistic potted plants: Snake Plant, Pothos, Parlor Palm, Lavender, Peperomia. Add centered labels. Avoid medical sleep claims. +``` + +### Slide 2 Prompt + +```text +Headline centered: "KEEP IT SIMPLE" + +Show one bedside table with one compact plant. Center text: "A calm setup is easier to maintain." +``` + +### Slide 3 Prompt + +```text +Headline centered: "AVOID OVERCROWDING" + +Show three plants spaced with breathing room. Center text: "Good spacing helps airflow and makes care easier." +``` + +### Slide 4 Prompt + +```text +Headline centered: "CHECK BEDROOM LIGHT" + +Show a bedroom window with soft indirect light. Center text: "Choose plants that match your actual light." +``` + +### Slide 5 Prompt + +```text +Headline centered: "KEEP PETS IN MIND" + +Show plant shelf placement with tiny pet paw icon. Center text: "Check toxicity before placing plants near pets." +``` + +### Slide 6 CTA Prompt + +```text +Headline centered: "CREATE A PLANT CORNER THAT WORKS" +Subheadline centered: "Use GreenLens Pro." + +Show a centered potted plant near a subtle moon and light icon, plus a GreenLens-style scan frame. Keep it premium, quiet, and clean. +``` + diff --git a/memory/project_summary.md b/memory/project_summary.md new file mode 100644 index 0000000..0ce589d --- /dev/null +++ b/memory/project_summary.md @@ -0,0 +1,10 @@ +# GreenLns Project Memory + +## Product/Funnel Decisions + +- There is no Free tier anymore. +- The app should be treated as Pro/paywalled by default. +- Demo/fake scan should not be positioned as a free product tier. +- Planned Aha moment: allow limited real Plant-ID starter scans if implemented, but not as an ongoing Free plan. +- Health Check remains paid/Pro gated. +- Paid use case of interest: Standort-Check / Light Meter as a Pro feature. diff --git a/output/gsc-greenlenspro-opportunities.json b/output/gsc-greenlenspro-opportunities.json new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 27cf60c..7a799a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-share-intent": "^5.1.1", "expo-splash-screen": "~31.0.13", "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", @@ -6691,6 +6692,144 @@ "node": ">=20.16.0" } }, + "node_modules/expo-share-intent": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/expo-share-intent/-/expo-share-intent-5.1.1.tgz", + "integrity": "sha512-0sEf34+4w/ySQd7xZmnog/oOm1q+PUBHFoGU97mxTXIGijY4LNmSX806efrDkYMwCxgRA1iHxG/zBib4zBPmYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/achorein" + }, + "https://www.buymeacoffee.com/achorein" + ], + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~10.1.1", + "expo-constants": "~18.0.10", + "expo-linking": "~8.0.9" + }, + "peerDependencies": { + "expo": "^54", + "expo-constants": ">=18.0.8", + "expo-linking": ">=8.0.8", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-share-intent/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/expo-share-intent/node_modules/@expo/config-plugins": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-10.1.2.tgz", + "integrity": "sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^53.0.5", + "@expo/json-file": "~9.1.5", + "@expo/plist": "^0.3.5", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^10.4.2", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo-share-intent/node_modules/@expo/config-types": { + "version": "53.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-53.0.5.tgz", + "integrity": "sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g==", + "license": "MIT" + }, + "node_modules/expo-share-intent/node_modules/@expo/json-file": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.1.5.tgz", + "integrity": "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/expo-share-intent/node_modules/@expo/plist": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.3.5.tgz", + "integrity": "sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/expo-share-intent/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo-share-intent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/expo-share-intent/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo-share-intent/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-splash-screen": { "version": "31.0.13", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", diff --git a/package.json b/package.json index 94e9c7b..ba862fc 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-share-intent": "^5.1.1", "expo-splash-screen": "~31.0.13", "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", diff --git a/server/index.js b/server/index.js index 472e6d5..71e6f22 100644 --- a/server/index.js +++ b/server/index.js @@ -25,8 +25,9 @@ loadEnvFiles([ path.join(__dirname, '.env.local'), ]); -const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); +const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); const { + deleteAccount: authDeleteAccount, ensureAuthSchema, signUp: authSignUp, login: authLogin, @@ -70,10 +71,10 @@ const app = express(); const port = Number(process.env.PORT || 3000); const plantsPublicDir = path.join(__dirname, 'public', 'plants'); -const SCAN_PRIMARY_COST = 1; -const SCAN_REVIEW_COST = 1; -const SEMANTIC_SEARCH_COST = 2; -const HEALTH_CHECK_COST = 2; +const SCAN_PRIMARY_COST = 1; +const SCAN_REVIEW_COST = 0; +const SEMANTIC_SEARCH_COST = 2; +const HEALTH_CHECK_COST = 2; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; let catalogCache = null; @@ -525,6 +526,7 @@ app.get('/', (_request, response) => { 'POST /auth/signup', 'POST /auth/login', 'POST /auth/apple', + 'DELETE /auth/account', 'GET /v1/billing/summary', 'POST /v1/billing/sync-revenuecat', 'POST /v1/scan', @@ -909,11 +911,12 @@ app.post('/v1/health-check', async (request, response) => { : language === 'es' ? 'Volver a escanear cuando la conexión sea estable.' : 'Try scanning again when your connection is stable.'; - const fallbackHealthCheck = { - generatedAt: nowIso(), - overallHealthScore: 50, - status: 'watch', - likelyIssues: [{ + const fallbackHealthCheck = { + generatedAt: nowIso(), + overallHealthScore: 50, + status: 'watch', + analysisSummary: unavailableIssue, + likelyIssues: [{ title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable', confidence: 0.1, details: unavailableIssue, @@ -944,11 +947,12 @@ app.post('/v1/health-check', async (request, response) => { ); } - const healthCheck = { - generatedAt: nowIso(), - overallHealthScore: analysis.overallHealthScore, - status: analysis.status, - likelyIssues: analysis.likelyIssues, + const healthCheck = { + generatedAt: nowIso(), + overallHealthScore: analysis.overallHealthScore, + status: analysis.status, + analysisSummary: analysis.analysisSummary, + likelyIssues: analysis.likelyIssues, actionsNow: analysis.actionsNow, plan7Days: analysis.plan7Days, creditsCharged, @@ -1081,7 +1085,27 @@ app.post('/auth/apple', async (request, response) => { // ─── Startup ─────────────────────────────────────────────────────────────── -const start = async () => { +app.delete('/auth/account', async (request, response) => { + try { + const authHeader = request.header('authorization') || request.header('Authorization') || ''; + if (!authHeader.startsWith('Bearer ')) { + return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing bearer token.' }); + } + + const payload = verifyJwt(authHeader.slice(7)); + if (!payload?.sub) { + return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid bearer token.' }); + } + + await authDeleteAccount(db, String(payload.sub)); + response.status(204).send(); + } catch (error) { + const status = error.status || 500; + response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); + } +}); + +const start = async () => { db = await openDatabase(); await ensurePlantSchema(db); await ensureBillingSchema(db); diff --git a/server/lib/auth.js b/server/lib/auth.js index f725e88..cee0b2f 100644 --- a/server/lib/auth.js +++ b/server/lib/auth.js @@ -291,4 +291,55 @@ const signInWithApple = async (db, identityToken, profile = {}) => { return { id, email: normalizedEmail, name, isNewUser: true }; }; -module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken }; +const runInTransaction = async (db, worker) => { + const client = typeof db.connect === 'function' ? await db.connect() : db; + const release = typeof client.release === 'function' ? () => client.release() : () => {}; + + await run(client, 'BEGIN'); + try { + const result = await worker(client); + await run(client, 'COMMIT'); + return result; + } catch (error) { + try { + await run(client, 'ROLLBACK'); + } catch (rollbackError) { + console.error('Failed to rollback account deletion transaction.', rollbackError); + } + throw error; + } finally { + release(); + } +}; + +const deleteAccount = async (db, userId) => { + if (!userId || typeof userId !== 'string') { + const err = new Error('Valid user id is required.'); + err.code = 'BAD_REQUEST'; + err.status = 400; + throw err; + } + + return runInTransaction(db, async (tx) => { + await run(tx, 'DELETE FROM billing_accounts WHERE user_id = $1', [userId]); + await run( + tx, + `DELETE FROM billing_idempotency + WHERE id LIKE $1 OR id LIKE $2`, + [`endpoint:%:${userId}:%`, `charge:%:${userId}:%`], + ); + const result = await run(tx, 'DELETE FROM auth_users WHERE id = $1', [userId]); + return { deleted: result.changes > 0 }; + }); +}; + +module.exports = { + deleteAccount, + ensureAuthSchema, + signUp, + login, + signInWithApple, + issueToken, + verifyJwt, + verifyAppleIdentityToken, +}; diff --git a/server/lib/openai.js b/server/lib/openai.js index 2846fef..bf14c0d 100644 --- a/server/lib/openai.js +++ b/server/lib/openai.js @@ -142,9 +142,10 @@ const normalizeIdentifyResult = (raw, language) => { }; const normalizeHealthAnalysis = (raw, language) => { - const scoreRaw = getNumber(raw.overallHealthScore); - const statusRaw = getString(raw.status); - const issuesRaw = raw.likelyIssues; + const scoreRaw = getNumber(raw.overallHealthScore); + const statusRaw = getString(raw.status); + const analysisSummary = getString(raw.analysisSummary); + const issuesRaw = raw.likelyIssues; const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); @@ -180,9 +181,10 @@ const normalizeHealthAnalysis = (raw, language) => { ? 'La IA no pudo extraer senales de salud estables.' : 'AI could not extract stable health signals.'; return { - overallHealthScore: Math.round(clamp(score, 0, 100)), - status, - likelyIssues: [ + overallHealthScore: Math.round(clamp(score, 0, 100)), + status, + analysisSummary: analysisSummary || fallbackIssue, + likelyIssues: [ { title: language === 'de' ? 'Analyse unsicher' @@ -203,9 +205,10 @@ const normalizeHealthAnalysis = (raw, language) => { } return { - overallHealthScore: Math.round(clamp(score, 0, 100)), - status, - likelyIssues, + overallHealthScore: Math.round(clamp(score, 0, 100)), + status, + analysisSummary, + likelyIssues, actionsNow: actionsNowRaw, plan7Days: plan7DaysRaw, }; @@ -260,12 +263,13 @@ const buildHealthPrompt = (language, plantContext) => { 'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.', '', 'Return strict JSON only in this exact shape:', - '{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}', + '{"overallHealthScore":72,"status":"watch","analysisSummary":"...","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}', '', 'Rules:', '- "overallHealthScore": integer 0–100. 100=perfect health, 80–99=minor cosmetic only, 60–79=noticeable issues needing attention, 40–59=significant stress, below 40=severe/critical.', '- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 50–79, needs monitoring), "critical" (score<50, urgent action needed).', - '- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:', + `- "analysisSummary": 6 to 9 precise sentences in ${getLanguageLabel(language)} describing visible condition, symptom pattern, likely root cause, urgency, confidence limits, and what the owner should monitor next.`, + '- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:', ' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")', ' - "confidence": float 0.05–0.99 reflecting visual certainty', ' - "details": 2–4 sentence detailed explanation of what you observe visually, what causes it, and what happens if untreated. Be specific — mention leaf color, location, pattern.', diff --git a/services/authService.ts b/services/authService.ts index cd147e9..e41e953 100644 --- a/services/authService.ts +++ b/services/authService.ts @@ -47,9 +47,34 @@ const authPost = async (path: string, body: object): Promise<{ userId: string; e console.warn(`[Auth] ${path} failed:`, response.status, code, msg); throw new Error(code); } - return data as any; -}; - + return data as any; +}; + +const authDelete = async (path: string, token: string): Promise => { + const backendUrl = getConfiguredBackendRootUrl(); + const hasBackendUrl = Boolean(backendUrl); + const url = hasBackendUrl ? `${backendUrl}${path}` : path; + let response: Response; + try { + response = await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch { + if (!hasBackendUrl) { + throw new Error('BACKEND_URL_MISSING'); + } + throw new Error('NETWORK_ERROR'); + } + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const code = (data as any).code || 'AUTH_ERROR'; + const msg = (data as any).message || ''; + console.warn(`[Auth] ${path} failed:`, response.status, code, msg); + throw new Error(code); + } +}; + const buildSession = (data: { userId: string; email: string; name: string; token: string; isNewUser?: boolean }): AuthSession => { const localUser = AuthDb.ensureLocalUser(data.email, data.name); return { @@ -109,10 +134,22 @@ export const AuthService = { }, async logout(): Promise { - await clearStoredSession(); - }, - - async updateSessionName(name: string): Promise { + await clearStoredSession(); + }, + + async deleteAccount(): Promise { + const session = await this.getSession(); + if (!session) { + await clearStoredSession(); + return; + } + await authDelete('/auth/account', session.token); + AuthDb.deleteLocalUser(session.userId); + await clearStoredSession(); + await SecureStore.deleteItemAsync('greenlens_first_run_complete'); + }, + + async updateSessionName(name: string): Promise { const session = await this.getSession(); if (!session) return; await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...session, name })); diff --git a/services/backend/mockBackendService.ts b/services/backend/mockBackendService.ts index 6554158..8b449e0 100644 --- a/services/backend/mockBackendService.ts +++ b/services/backend/mockBackendService.ts @@ -34,10 +34,10 @@ const GUEST_TRIAL_CREDITS = 0; const TRIAL_MONTHLY_CREDITS = 30; const PRO_MONTHLY_CREDITS = 100; -const SCAN_PRIMARY_COST = 1; -const SCAN_REVIEW_COST = 1; -const SEMANTIC_SEARCH_COST = 2; -const HEALTH_CHECK_COST = 2; +const SCAN_PRIMARY_COST = 1; +const SCAN_REVIEW_COST = 0; +const SEMANTIC_SEARCH_COST = 2; +const HEALTH_CHECK_COST = 2; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; const FREE_SIMULATED_DELAY_MS = 1100; @@ -505,13 +505,18 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe 'Tag 7: Vergleichsfoto erstellen.', ]; - return { - generatedAt: nowIso(), - overallHealthScore: score, - status, - likelyIssues, - actionsNow, - plan7Days, + return { + generatedAt: nowIso(), + overallHealthScore: score, + status, + analysisSummary: status === 'critical' + ? 'Die Pflanze zeigt mehrere Stresssignale, die schnell stabilisiert werden sollten. Der wichtigste Verdacht ist zu viel Feuchtigkeit im Wurzelbereich, kombiniert mit schwacher Lichtversorgung. Achte besonders auf weiche gelbe Blaetter, dunkle Stellen am Stiel und Erde, die lange nass bleibt. Wenn diese Zeichen zunehmen, kann die Pflanze innerhalb weniger Tage weiter an Blattspannung verlieren. Die Diagnose ist ein Mock-Ergebnis, aber der Plan ist bewusst konkret. Pruefe zuerst Drainage und Substrat, bevor du Duenger oder einen kompletten Standortwechsel einsetzt.' + : status === 'watch' + ? 'Die Pflanze wirkt nicht akut gefaehrdet, zeigt aber erkennbare Pflege-Signale, die beobachtet werden sollten. Wahrscheinlich spielen Giessrhythmus, Licht und leichte Naehrstoffversorgung zusammen. Einzelne gelbliche oder matte Blaetter sind noch kein Notfall, koennen aber ein fruehes Muster anzeigen. Entscheidend ist, ob neue Blaetter stabil bleiben und ob die Erde zwischen den Wassergaben gleichmaessig abtrocknet. Der Plan fokussiert auf konstante Bedingungen statt hektische Eingriffe. Ein Vergleichsfoto nach einer Woche zeigt, ob die Anpassungen wirken.' + : 'Die Pflanze wirkt insgesamt stabil und braucht eher Feintuning als Rettungsmassnahmen. Einzelne Blattreaktionen koennen normale Alterung oder leichte Standortanpassung sein. Der Score spricht dafuer, dass keine akute Ursache dominiert. Beobachte trotzdem neue Flecken, haengende Triebe und Veraenderungen an den unteren Blaettern. Halte die Routine konstant, damit du echte Veraenderungen leichter erkennst. Nutze den naechsten Check als Verlaufskontrolle statt als Notfallmassnahme.', + likelyIssues, + actionsNow, + plan7Days, creditsCharged, imageUri: request.imageUri, }; @@ -604,13 +609,18 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe 'Dia 7: Tomar foto de comparacion.', ]; - return { - generatedAt: nowIso(), - overallHealthScore: score, - status, - likelyIssues, - actionsNow, - plan7Days, + return { + generatedAt: nowIso(), + overallHealthScore: score, + status, + analysisSummary: status === 'critical' + ? 'La planta muestra varias senales de estres que conviene estabilizar pronto. La sospecha principal es demasiada humedad en la zona de raices, combinada con luz insuficiente. Observa hojas amarillas blandas, manchas oscuras en tallos y sustrato que permanece mojado demasiado tiempo. Si estas senales aumentan, la planta puede perder firmeza en pocos dias. El diagnostico es simulado, pero el plan es concreto. Revisa drenaje y sustrato antes de fertilizar o cambiar toda la ubicacion.' + : status === 'watch' + ? 'La planta no parece en peligro inmediato, pero muestra senales que deben observarse. Probablemente influyen el ritmo de riego, la luz y una nutricion ligera. Algunas hojas amarillas o apagadas no son una emergencia, pero pueden indicar un patron temprano. Lo importante es ver si las hojas nuevas se mantienen firmes y si el sustrato seca de forma regular. El plan prioriza condiciones constantes, no cambios bruscos. Una foto comparativa en una semana mostrara si los ajustes funcionan.' + : 'La planta parece estable y necesita pequenos ajustes mas que medidas de rescate. Algunas hojas pueden reflejar envejecimiento normal o adaptacion al lugar. El puntaje indica que no domina una causa urgente. Aun asi, observa manchas nuevas, tallos caidos y cambios en hojas inferiores. Mantén la rutina constante para detectar cambios reales. Usa el proximo chequeo como comparacion de evolucion.', + likelyIssues, + actionsNow, + plan7Days, creditsCharged, imageUri: request.imageUri, }; @@ -702,12 +712,17 @@ const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: numbe 'Day 7: Take a comparison photo.', ]; - return { - generatedAt: nowIso(), - overallHealthScore: score, - status, - likelyIssues, - actionsNow, + return { + generatedAt: nowIso(), + overallHealthScore: score, + status, + analysisSummary: status === 'critical' + ? 'The plant shows multiple stress signals that should be stabilized soon. The main suspicion is excess moisture around the roots, possibly combined with weak light. Watch for soft yellow leaves, dark stem areas, and soil that stays wet too long. If those signs increase, the plant may lose more leaf firmness within a few days. This is a mock diagnosis, but the plan is intentionally concrete. Check drainage and substrate before fertilizing or changing the whole routine.' + : status === 'watch' + ? 'The plant does not look like an immediate emergency, but it has visible care signals worth tracking. Watering cadence, light level, and mild nutrition are the most likely levers. A few yellow or dull leaves are not automatically severe, but they can show an early pattern. The key is whether new leaves stay firm and whether soil dries predictably between watering. The plan focuses on stable conditions instead of abrupt changes. A comparison photo after one week will show whether the adjustments are working.' + : 'The plant looks broadly stable and needs fine-tuning rather than rescue care. Minor leaf reactions may reflect normal aging or placement adjustment. The score suggests no urgent single cause is dominating. Still, monitor new spots, drooping stems, and changes on lower leaves. Keep the routine steady so real changes are easier to see. Use the next check as a trend comparison rather than an emergency intervention.', + likelyIssues, + actionsNow, plan7Days, creditsCharged, imageUri: request.imageUri, @@ -1006,12 +1021,13 @@ export const mockBackendService = { HEALTH_CHECK_COST, ); - const healthCheck: PlantHealthCheck = { - generatedAt: nowIso(), - overallHealthScore: aiAnalysis.overallHealthScore, - status: aiAnalysis.status, - likelyIssues: aiAnalysis.likelyIssues, - actionsNow: aiAnalysis.actionsNow, + const healthCheck: PlantHealthCheck = { + generatedAt: nowIso(), + overallHealthScore: aiAnalysis.overallHealthScore, + status: aiAnalysis.status, + analysisSummary: aiAnalysis.analysisSummary, + likelyIssues: aiAnalysis.likelyIssues, + actionsNow: aiAnalysis.actionsNow, plan7Days: aiAnalysis.plan7Days, creditsCharged, imageUri: normalizedImageUri, diff --git a/services/backend/openAiScanService.ts b/services/backend/openAiScanService.ts index e191851..acdcd2a 100644 --- a/services/backend/openAiScanService.ts +++ b/services/backend/openAiScanService.ts @@ -9,12 +9,13 @@ export interface OpenAiHealthIssue { details: string; } -export interface OpenAiHealthAnalysis { - overallHealthScore: number; - status: 'healthy' | 'watch' | 'critical'; - likelyIssues: OpenAiHealthIssue[]; - actionsNow: string[]; - plan7Days: string[]; +export interface OpenAiHealthAnalysis { + overallHealthScore: number; + status: 'healthy' | 'watch' | 'critical'; + analysisSummary?: string; + likelyIssues: OpenAiHealthIssue[]; + actionsNow: string[]; + plan7Days: string[]; } const OPENAI_API_KEY = (process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim(); @@ -203,16 +204,18 @@ const buildHealthPrompt = ( return [ `Analyze this plant photo for real health condition signs with focus on yellowing leaves, watering stress, pests, and light stress.`, - `Return strict JSON only in this shape:`, - `{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}`, + `Return strict JSON only in this shape:`, + `{"overallHealthScore":72,"status":"watch","analysisSummary":"...","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}`, `Rules:`, `- "overallHealthScore" must be an integer between 0 and 100.`, `- "status" must be one of: "healthy", "watch", "critical".`, - `- "likelyIssues" must contain 1 to 4 items sorted by confidence descending.`, + `- "analysisSummary" must be 6 to 9 precise sentences. Cover the visible condition, symptom pattern, likely root cause, urgency, what evidence is uncertain, and what the owner should monitor next.`, + `- "likelyIssues" must contain 2 to 4 items sorted by confidence descending.`, `- "confidence" must be between 0 and 1.`, - `- "title", "details", "actionsNow", and "plan7Days" must be written in ${getLanguageLabel(language)}.`, - `- "actionsNow" should be immediate steps for the next 24 hours.`, - `- "plan7Days" should be short actionable steps for the next week.`, + `- "title", "details", "analysisSummary", "actionsNow", and "plan7Days" must be written in ${getLanguageLabel(language)}.`, + `- Each issue "details" value must be 2 to 4 sentences and explain visual evidence, likely cause, and risk if untreated.`, + `- "actionsNow" must contain 5 to 8 concrete steps for the next 24 to 48 hours.`, + `- "plan7Days" must contain 7 to 10 day-by-day or milestone steps for the next week.`, `- Do not include markdown, explanations, or extra keys.`, ...contextLines, ].join('\n'); @@ -229,9 +232,10 @@ const buildFallbackHealthAnalysis = ( ): OpenAiHealthAnalysis => { if (language === 'de') { return { - overallHealthScore: 58, - status: 'watch', - likelyIssues: [ + overallHealthScore: 58, + status: 'watch', + analysisSummary: `${plantContext?.name || 'Die Pflanze'} braucht eine erneute Bewertung mit einem scharfen Foto, weil die KI-Antwort nicht stabil genug war. Behandle sie bis dahin vorsichtig als Beobachtungsfall. Vermeide radikale Standortwechsel, grosse Wassermengen und starke Duengung. Pruefe zuerst, ob die auffaelligen Blaetter weich, trocken, fleckig oder eingerollt wirken. Kontrolliere danach die oberen 3 cm Erde und achte auf stehendes Wasser im Uebertopf. Die wichtigsten sichtbaren Signale sollten bei Tageslicht erneut geprueft werden. Wenn sich Blattfarbe oder Spannung innerhalb von 48 Stunden verschlechtern, starte einen neuen Health-Scan mit einem detailreicheren Foto.`, + likelyIssues: [ { title: 'Eingeschraenkte KI-Analyse', confidence: 0.42, @@ -253,9 +257,10 @@ const buildFallbackHealthAnalysis = ( if (language === 'es') { return { - overallHealthScore: 58, - status: 'watch', - likelyIssues: [ + overallHealthScore: 58, + status: 'watch', + analysisSummary: `${plantContext?.name || 'La planta'} necesita una nueva evaluacion con una foto mas nitida porque la respuesta de IA no fue suficientemente estable. Hasta entonces tratala como un caso de observacion. Evita cambios bruscos de ubicacion, exceso de agua y fertilizacion fuerte. Revisa si las hojas afectadas estan blandas, secas, manchadas o enrolladas. Comprueba despues los 3 cm superiores del sustrato y busca agua acumulada. Las senales visibles deben revisarse de nuevo con luz natural. Si el color o la firmeza empeoran en 48 horas, inicia otro health-scan con una foto mas detallada.`, + likelyIssues: [ { title: 'Analisis de IA limitado', confidence: 0.42, @@ -276,9 +281,10 @@ const buildFallbackHealthAnalysis = ( } return { - overallHealthScore: 58, - status: 'watch', - likelyIssues: [ + overallHealthScore: 58, + status: 'watch', + analysisSummary: `${plantContext?.name || 'This plant'} needs another assessment with a sharper photo because the AI response was not stable enough. Until then, treat it as a watch case. Avoid major placement changes, heavy watering, or strong fertilizing. First inspect whether the unusual leaves look soft, dry, spotted, or curled. Then check the top 3 cm of soil and look for standing water in the outer pot. Re-check the visible signals in daylight before making bigger care changes. If color or leaf firmness gets worse within 48 hours, run a new health scan with a more detailed photo.`, + likelyIssues: [ { title: 'Limited AI analysis', confidence: 0.42, @@ -302,11 +308,12 @@ const normalizeHealthAnalysis = ( raw: Record, language: Language, ): OpenAiHealthAnalysis | null => { - const scoreRaw = getNumber(raw.overallHealthScore); - const statusRaw = getString(raw.status); - const issuesRaw = raw.likelyIssues; - const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 6); - const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 7); + const scoreRaw = getNumber(raw.overallHealthScore); + const statusRaw = getString(raw.status); + const analysisSummary = getString(raw.analysisSummary); + const issuesRaw = raw.likelyIssues; + const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); + const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { return null; @@ -340,9 +347,10 @@ const normalizeHealthAnalysis = ( ? 'La IA no pudo extraer senales de salud estables.' : 'AI could not extract stable health signals.'; return { - overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), - status, - likelyIssues: [ + overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), + status, + analysisSummary: analysisSummary || fallbackIssue, + likelyIssues: [ { title: language === 'de' ? 'Analyse unsicher' @@ -363,9 +371,10 @@ const normalizeHealthAnalysis = ( } return { - overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), - status, - likelyIssues, + overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), + status, + analysisSummary, + likelyIssues, actionsNow: actionsNowRaw, plan7Days: plan7DaysRaw, }; diff --git a/services/database.ts b/services/database.ts index f3fed16..961f90a 100644 --- a/services/database.ts +++ b/services/database.ts @@ -148,15 +148,19 @@ export const AuthDb = { return { id: newUserId }; }, - getUserById(id: number): DbUser | null { - const db = getDb(); - const user = db.getFirstSync( - 'SELECT id, email, name FROM users WHERE id = ?', - [id], - ); - return user || null; - }, -}; + getUserById(id: number): DbUser | null { + const db = getDb(); + const user = db.getFirstSync( + 'SELECT id, email, name FROM users WHERE id = ?', + [id], + ); + return user || null; + }, + + deleteLocalUser(id: number): void { + getDb().runSync('DELETE FROM users WHERE id = ?', [id]); + }, +}; // ─── Settings ────────────────────────────────────────────────────────────────── diff --git a/types.ts b/types.ts index 71a3a78..2df18d6 100644 --- a/types.ts +++ b/types.ts @@ -33,13 +33,14 @@ export interface PlantHealthIssue { details: string; } -export interface PlantHealthCheck { - generatedAt: string; - overallHealthScore: number; - status: 'healthy' | 'watch' | 'critical'; - likelyIssues: PlantHealthIssue[]; - actionsNow: string[]; - plan7Days: string[]; +export interface PlantHealthCheck { + generatedAt: string; + overallHealthScore: number; + status: 'healthy' | 'watch' | 'critical'; + analysisSummary?: string; + likelyIssues: PlantHealthIssue[]; + actionsNow: string[]; + plan7Days: string[]; creditsCharged: number; imageUri?: string; } diff --git a/utils/translations.ts b/utils/translations.ts index ceacad8..065c2f6 100644 --- a/utils/translations.ts +++ b/utils/translations.ts @@ -247,12 +247,23 @@ export const translations = { onboardingFeatureScan: "Pflanzen scannen & erkennen", onboardingFeatureReminder: "Gießerinnerungen & Pflege", onboardingFeatureLexicon: "Digitales Pflanzen-Lexikon", - onboardingScanBtn: "Pflanze scannen", - onboardingRegister: "Registrieren", - onboardingLogin: "Anmelden", - onboardingDisclaimer: "Deine Daten bleiben privat und lokal auf deinem Gerät.", - - // Auth + onboardingScanBtn: "Pflanze scannen", + onboardingRegister: "Registrieren", + onboardingLogin: "Anmelden", + onboardingDisclaimer: "Deine Daten bleiben privat und lokal auf deinem Gerät.", + welcomeHeadline: "Pflanzenpflege\nbeginnt hier", + welcomeSubheadline: "Scanne ein Blatt, erkenne die Pflanze und halte sie gesund.", + welcomeFeatureIdentifyTitle: "KI-Pflanzenerkennung", + welcomeFeatureIdentifyDesc: "Teste bis zu 5 Demo-Scans direkt auf diesem Gerät.", + welcomeFeatureReminderTitle: "Pflegeplan & Erinnerungen", + welcomeFeatureReminderDesc: "Erhalte klare Tipps für Gießen, Licht und Standort.", + welcomeFeatureLibraryTitle: "Pflanzen speichern", + welcomeFeatureLibraryDesc: "Registriere dich, um deine gescannten Pflanzen zu sichern.", + welcomeDemoScan: "Demo-Scan testen", + welcomeSubscriptionPlans: "Abo-Pläne & Preise ansehen", + welcomeLegal: "Abo-Details, Wiederherstellen, Nutzungsbedingungen und Datenschutzrichtlinie werden vor dem Kauf angezeigt.", + + // Auth createAccount: "Konto erstellen", welcomeBack: "Willkommen zurück", namePlaceholder: "Dein Name", @@ -508,12 +519,23 @@ registerToSave: "Sign up to save", onboardingFeatureScan: "Scan & identify plants", onboardingFeatureReminder: "Watering reminders & care", onboardingFeatureLexicon: "Digital plant encyclopedia", - onboardingScanBtn: "Scan Plant", - onboardingRegister: "Sign Up", - onboardingLogin: "Log In", - onboardingDisclaimer: "Your data stays private and local on your device.", - - // Auth + onboardingScanBtn: "Scan Plant", + onboardingRegister: "Sign Up", + onboardingLogin: "Log In", + onboardingDisclaimer: "Your data stays private and local on your device.", + welcomeHeadline: "Plant care\nstarts here", + welcomeSubheadline: "Scan a leaf, learn the plant, keep it healthy.", + welcomeFeatureIdentifyTitle: "AI plant identification", + welcomeFeatureIdentifyDesc: "Try up to 5 demo scans on this device.", + welcomeFeatureReminderTitle: "Care plan & reminders", + welcomeFeatureReminderDesc: "Get clear guidance for water, light, and placement.", + welcomeFeatureLibraryTitle: "Save your plants", + welcomeFeatureLibraryDesc: "Sign up to keep scanned plants in your collection.", + welcomeDemoScan: "Try Demo Scan", + welcomeSubscriptionPlans: "View Subscription Plans & Pricing", + welcomeLegal: "Subscription details, Restore, Terms of Use, and Privacy Policy are shown before purchase.", + + // Auth createAccount: "Create Account", welcomeBack: "Welcome back", namePlaceholder: "Your name", @@ -769,12 +791,23 @@ registerToSave: "Regístrate para guardar", onboardingFeatureScan: "Escanea e identifica plantas", onboardingFeatureReminder: "Recordatorios de riego y cuidado", onboardingFeatureLexicon: "Enciclopedia digital de plantas", - onboardingScanBtn: "Escanear Planta", - onboardingRegister: "Registrarse", - onboardingLogin: "Iniciar sesión", - onboardingDisclaimer: "Tus datos permanecen privados y locales en tu dispositivo.", - - // Auth + onboardingScanBtn: "Escanear Planta", + onboardingRegister: "Registrarse", + onboardingLogin: "Iniciar sesión", + onboardingDisclaimer: "Tus datos permanecen privados y locales en tu dispositivo.", + welcomeHeadline: "El cuidado\nempieza aquí", + welcomeSubheadline: "Escanea una hoja, conoce la planta y mantenla sana.", + welcomeFeatureIdentifyTitle: "Identificación con IA", + welcomeFeatureIdentifyDesc: "Prueba hasta 5 escaneos demo en este dispositivo.", + welcomeFeatureReminderTitle: "Plan de cuidado y recordatorios", + welcomeFeatureReminderDesc: "Recibe consejos claros sobre riego, luz y ubicación.", + welcomeFeatureLibraryTitle: "Guardar tus plantas", + welcomeFeatureLibraryDesc: "Regístrate para conservar las plantas escaneadas.", + welcomeDemoScan: "Probar escaneo demo", + welcomeSubscriptionPlans: "Ver planes y precios", + welcomeLegal: "Los detalles de suscripción, Restaurar, Términos de uso y Política de privacidad se muestran antes de comprar.", + + // Auth createAccount: "Crear cuenta", welcomeBack: "Bienvenido de vuelta", namePlaceholder: "Tu nombre",