Onboarding
40
__tests__/server/authAccountDeletion.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {};
|
||||
|
||||
const mockedAsyncStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;
|
||||
|
||||
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;
|
||||
|
||||
31
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",
|
||||
|
||||
484
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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 = <Redirect href="/onboarding" />;
|
||||
} else {
|
||||
content = (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
content = (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
|
||||
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
|
||||
@@ -190,28 +212,28 @@ function RootLayoutInner() {
|
||||
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen
|
||||
name="scanner"
|
||||
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile/billing"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack.Screen
|
||||
name="scanner"
|
||||
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile/billing"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
} else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
|
||||
content = <Redirect href="/profile/billing" />;
|
||||
content = <Redirect href="/onboarding" />;
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
content = (
|
||||
<>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
|
||||
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
|
||||
@@ -219,65 +241,65 @@ function RootLayoutInner() {
|
||||
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="(tabs)" options={{ animation: 'none' }} />
|
||||
<Stack.Screen
|
||||
name="scanner"
|
||||
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="plant/[id]"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="lexicon"
|
||||
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile/preferences"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile/data"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile/billing"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
</Stack>
|
||||
<CoachMarksOverlay />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={isDarkMode ? 'light' : 'dark'} />
|
||||
{content}
|
||||
{!splashAnimationComplete && (
|
||||
<AnimatedSplashScreen
|
||||
isAppReady={isAppReady}
|
||||
onAnimationComplete={() => setSplashAnimationComplete(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
initDatabase();
|
||||
|
||||
return (
|
||||
<Stack.Screen name="(tabs)" options={{ animation: 'none' }} />
|
||||
<Stack.Screen
|
||||
name="scanner"
|
||||
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="plant/[id]"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="lexicon"
|
||||
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile/preferences"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile/data"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile/billing"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
</Stack>
|
||||
<CoachMarksOverlay />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={isDarkMode ? 'light' : 'dark'} />
|
||||
{content}
|
||||
{!splashAnimationComplete && (
|
||||
<AnimatedSplashScreen
|
||||
isAppReady={isAppReady}
|
||||
onAnimationComplete={() => setSplashAnimationComplete(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
initDatabase();
|
||||
|
||||
return (
|
||||
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
|
||||
host: 'https://us.i.posthog.com',
|
||||
enableSessionReplay: false,
|
||||
}}>
|
||||
<AppProvider>
|
||||
<CoachMarksProvider>
|
||||
<RootLayoutInner />
|
||||
</CoachMarksProvider>
|
||||
</AppProvider>
|
||||
</PostHogProvider>
|
||||
);
|
||||
}
|
||||
<AppProvider>
|
||||
<CoachMarksProvider>
|
||||
<RootLayoutInner />
|
||||
</CoachMarksProvider>
|
||||
</AppProvider>
|
||||
</PostHogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.flex, { backgroundColor: colors.background }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<ScrollView
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.flex, { backgroundColor: screenBackground }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Logo / Header */}
|
||||
<View style={styles.header}>
|
||||
<Image
|
||||
source={require('../../assets/icon.png')}
|
||||
style={styles.logoIcon}
|
||||
{/* Logo / Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Image
|
||||
source={require('../../assets/icon.png')}
|
||||
style={styles.logoIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.flex, { backgroundColor: colors.background }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<ScrollView
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.flex, { backgroundColor: screenBackground }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -391,12 +397,12 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoIcon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
marginBottom: 16,
|
||||
},
|
||||
logoIcon: {
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderRadius: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
appName: {
|
||||
fontSize: 30,
|
||||
fontWeight: '700',
|
||||
|
||||
@@ -1,278 +1,367 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Dimensions,
|
||||
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';
|
||||
|
||||
const { height: SCREEN_H, width: SCREEN_W } = Dimensions.get('window');
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const { isDarkMode, colorPalette, t } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: 'camera-outline' as const, label: t.onboardingFeatureScan },
|
||||
{ icon: 'notifications-outline' as const, label: t.onboardingFeatureReminder },
|
||||
{ icon: 'book-outline' as const, label: t.onboardingFeatureLexicon },
|
||||
];
|
||||
|
||||
// Entrance animations
|
||||
const logoAnim = useRef(new Animated.Value(0)).current;
|
||||
const logoScale = useRef(new Animated.Value(0.85)).current;
|
||||
const featuresAnim = useRef(new Animated.Value(0)).current;
|
||||
const buttonsAnim = useRef(new Animated.Value(0)).current;
|
||||
const featureAnims = useRef(FEATURES.map(() => 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 (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
|
||||
{/* Logo-Bereich */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.heroSection,
|
||||
{ opacity: logoAnim, transform: [{ scale: logoScale }] },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconContainer, { shadowColor: colors.primary }]}>
|
||||
<Image
|
||||
source={require('../assets/icon.png')}
|
||||
style={styles.appIcon}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
|
||||
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
|
||||
{t.onboardingTagline}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Feature-Liste */}
|
||||
<View style={styles.featuresSection}>
|
||||
{FEATURES.map((feat, i) => (
|
||||
<Animated.View
|
||||
key={feat.label}
|
||||
style={[
|
||||
styles.featureRow,
|
||||
{
|
||||
backgroundColor: colors.surface + '88', // Semi-transparent for backdrop effect
|
||||
borderColor: colors.border,
|
||||
opacity: featureAnims[i],
|
||||
transform: [{
|
||||
translateY: featureAnims[i].interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [20, 0],
|
||||
}),
|
||||
}],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.featureIcon, { backgroundColor: colors.primary + '15' }]}>
|
||||
<Ionicons name={feat.icon} size={18} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.featureText, { color: colors.text }]}>{feat.label}</Text>
|
||||
</Animated.View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<Animated.View style={[styles.buttonsSection, { opacity: buttonsAnim }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={() => router.push('/scanner')}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Ionicons name="scan" size={20} color={colors.onPrimary} />
|
||||
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>
|
||||
{t.onboardingScanBtn}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.authActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryBtn, { borderColor: colors.primary, backgroundColor: colors.surface }]}
|
||||
onPress={() => router.push('/auth/signup')}
|
||||
activeOpacity={0.82}
|
||||
>
|
||||
<Text style={[styles.secondaryBtnText, { color: colors.primary }]}>
|
||||
{t.onboardingRegister}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
|
||||
onPress={() => router.push('/auth/login')}
|
||||
activeOpacity={0.82}
|
||||
>
|
||||
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
|
||||
{t.onboardingLogin}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.plansBtn, { borderColor: colors.primary }]}
|
||||
onPress={() => router.push('/profile/billing')}
|
||||
activeOpacity={0.82}
|
||||
>
|
||||
<Ionicons name="pricetag-outline" size={16} color={colors.primary} />
|
||||
<Text style={[styles.plansBtnText, { color: colors.primary }]}>
|
||||
View Subscription Plans & Pricing
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.disclaimer, { color: colors.textMuted }]}>
|
||||
{t.onboardingDisclaimer}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<ImageBackground
|
||||
source={require('../assets/welcome_botanical_hero.png')}
|
||||
style={styles.heroImage}
|
||||
imageStyle={styles.heroImageContent}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<View style={styles.heroShadeTop} />
|
||||
<View style={styles.heroShadeBottom} />
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={[styles.brandRow, compact && styles.brandRowCompact]}>
|
||||
<Image
|
||||
source={require('../assets/icon.png')}
|
||||
style={styles.logo}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<Text style={styles.brandName}>
|
||||
Green<Text style={styles.brandAccent}>Lens</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</ImageBackground>
|
||||
|
||||
<View style={[styles.sheet, { top: sheetTop }]}>
|
||||
<Svg
|
||||
width={width}
|
||||
height={waveHeight}
|
||||
viewBox={`0 0 ${width} ${waveHeight}`}
|
||||
preserveAspectRatio="none"
|
||||
style={styles.sheetWave}
|
||||
>
|
||||
<Path
|
||||
d={`M0 34 C ${width * 0.08} 76 ${width * 0.14} 82 ${width * 0.24} 82 C ${width * 0.38} 82 ${width * 0.52} 82 ${width * 0.64} 82 C ${width * 0.78} 86 ${width * 0.88} 132 ${width} 156 L ${width} ${waveHeight} L 0 ${waveHeight} Z`}
|
||||
fill="#fbfaf3"
|
||||
/>
|
||||
</Svg>
|
||||
<View style={[styles.sheetBody, { top: bodyOffset }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.sheetContent,
|
||||
{ top: contentTop },
|
||||
compact && styles.sheetContentCompact,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.headline, compact && styles.headlineCompact]}>
|
||||
{t.welcomeHeadline}
|
||||
</Text>
|
||||
<Text style={styles.subheadline}>{t.welcomeSubheadline}</Text>
|
||||
|
||||
<View style={styles.features}>
|
||||
{features.map((feature, index) => (
|
||||
<View
|
||||
key={feature.title}
|
||||
style={[
|
||||
styles.featureRow,
|
||||
index === features.length - 1 && styles.featureRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.featureIcon}>
|
||||
<Ionicons name={feature.icon} size={22} color="#a6d66f" />
|
||||
</View>
|
||||
<View style={styles.featureCopy}>
|
||||
<Text style={styles.featureTitle}>{feature.title}</Text>
|
||||
<Text style={styles.featureDescription}>{feature.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.demoButton}
|
||||
onPress={() => router.push('/scanner')}
|
||||
activeOpacity={0.86}
|
||||
>
|
||||
<Ionicons name="scan" size={25} color="#f8f7ef" />
|
||||
<Text style={styles.demoButtonText}>{t.welcomeDemoScan}</Text>
|
||||
<Ionicons name="chevron-forward" size={26} color="#f8f7ef" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.authRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.authButton}
|
||||
onPress={() => router.push('/auth/signup')}
|
||||
activeOpacity={0.82}
|
||||
>
|
||||
<Text style={styles.authButtonText}>{t.onboardingRegister}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.authButton, styles.loginButton]}
|
||||
onPress={() => router.push('/auth/login')}
|
||||
activeOpacity={0.82}
|
||||
>
|
||||
<Text style={[styles.authButtonText, styles.loginButtonText]}>
|
||||
{t.onboardingLogin}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.subscriptionLink}
|
||||
onPress={() => router.push('/profile/billing')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="leaf-outline" size={21} color="#4b7c31" />
|
||||
<Text style={styles.subscriptionText}>{t.welcomeSubscriptionPlans}</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#4b7c31" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.legalText}>{t.welcomeLegal}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<View style={[styles.container, { backgroundColor: screenBackground }]}>
|
||||
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}>
|
||||
<Ionicons name="sparkles-outline" size={26} color={colors.primaryDark} />
|
||||
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
|
||||
</View>
|
||||
<ImageBackground
|
||||
source={require('../../assets/onboarding_experience_mockup.png')}
|
||||
style={[styles.heroPreview, { borderColor: colors.border }]}
|
||||
imageStyle={styles.heroImage}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.4)' : 'rgba(251, 250, 243, 0.24)' }]} />
|
||||
<View style={[styles.heroMetric, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Ionicons name="sparkles-outline" size={18} color={colors.primary} />
|
||||
<Text style={[styles.heroMetricText, { color: colors.text }]}>{copy.heroBadge}</Text>
|
||||
</View>
|
||||
</ImageBackground>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t.experienceOnboardingTitle}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.experienceOnboardingSubtitle}</Text>
|
||||
</View>
|
||||
@@ -73,7 +128,12 @@ export default function OnboardingExperienceScreen() {
|
||||
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
|
||||
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colors.text }]}>{levelLabels[option.id as keyof typeof levelLabels]}</Text>
|
||||
<View style={styles.optionCopy}>
|
||||
<Text style={[styles.optionLabel, { color: colors.text }]}>{levelLabels[option.id as keyof typeof levelLabels]}</Text>
|
||||
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
|
||||
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
|
||||
</Text>
|
||||
</View>
|
||||
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const copy = getGoalScreenCopy(language);
|
||||
|
||||
const goalLabels = useMemo(
|
||||
() => ({
|
||||
@@ -45,13 +91,25 @@ export default function OnboardingGoalScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<View style={[styles.container, { backgroundColor: screenBackground }]}>
|
||||
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}>
|
||||
<Ionicons name="flag-outline" size={26} color={colors.primaryDark} />
|
||||
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
|
||||
</View>
|
||||
<ImageBackground
|
||||
source={require('../../assets/onboarding_goal_mockup.png')}
|
||||
style={[styles.heroPreview, { borderColor: colors.border }]}
|
||||
imageStyle={styles.heroImage}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.22)' : 'rgba(251, 250, 243, 0.28)' }]} />
|
||||
<View style={[styles.heroBadge, { backgroundColor: colors.primary }]}>
|
||||
<Ionicons name="flag-outline" size={16} color={colors.onPrimary} />
|
||||
<Text style={[styles.heroBadgeText, { color: colors.onPrimary }]}>{copy.heroBadge}</Text>
|
||||
</View>
|
||||
</ImageBackground>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t.goalOnboardingTitle}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.goalOnboardingSubtitle}</Text>
|
||||
</View>
|
||||
@@ -75,7 +133,12 @@ export default function OnboardingGoalScreen() {
|
||||
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
|
||||
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colors.text }]}>{goalLabels[option.id as keyof typeof goalLabels]}</Text>
|
||||
<View style={styles.optionCopy}>
|
||||
<Text style={[styles.optionLabel, { color: colors.text }]}>{goalLabels[option.id as keyof typeof goalLabels]}</Text>
|
||||
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
|
||||
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
|
||||
</Text>
|
||||
</View>
|
||||
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
202
app/onboarding/health-check.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.container, { backgroundColor: screenBackground }]}>
|
||||
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{copy.subtitle}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<ImageBackground
|
||||
source={require('../../assets/onboarding_health_scan_mockup.png')}
|
||||
style={[styles.illustration, { borderColor: colors.border }]}
|
||||
imageStyle={styles.illustrationImage}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<View style={[styles.illustrationOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.08)' : 'rgba(251, 250, 243, 0.04)' }]} />
|
||||
</ImageBackground>
|
||||
|
||||
<View style={[styles.flowCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
{copy.flow.map((item, index) => (
|
||||
<View key={item} style={styles.flowRow}>
|
||||
<View style={[styles.flowIndex, { backgroundColor: index === 3 ? colors.primary : colors.surfaceMuted }]}>
|
||||
<Text style={[styles.flowIndexText, { color: index === 3 ? colors.onPrimary : colors.textMuted }]}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.flowText, { color: colors.text }]}>{item}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={[styles.outputCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Text style={[styles.outputTitle, { color: colors.text }]}>{copy.outputTitle}</Text>
|
||||
{copy.outputs.map((item) => (
|
||||
<View key={item} style={styles.outputRow}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||
<Text style={[styles.outputText, { color: colors.textSecondary }]}>{item}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={[styles.guidanceCard, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
|
||||
<Ionicons name="camera-outline" size={18} color={colors.primaryDark} />
|
||||
<Text style={[styles.guidanceText, { color: colors.primaryDark }]}>{copy.guidanceNote}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
|
||||
onPress={() => finish(true)}
|
||||
>
|
||||
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{copy.skip}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={() => finish(false)}>
|
||||
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.cta}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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' },
|
||||
});
|
||||
@@ -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<string | null>(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 (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<View style={[styles.container, { backgroundColor: screenBackground }]}>
|
||||
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}>
|
||||
<Ionicons name="paper-plane-outline" size={26} color={colors.primaryDark} />
|
||||
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
|
||||
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
|
||||
</View>
|
||||
<ImageBackground
|
||||
source={require('../../assets/onboarding_source_mockup.png')}
|
||||
style={[styles.heroPreview, { borderColor: colors.border }]}
|
||||
imageStyle={styles.heroImage}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.46)' : 'rgba(251, 250, 243, 0.32)' }]} />
|
||||
<View style={styles.heroContent}>
|
||||
<View style={[styles.heroIcon, { backgroundColor: colors.primary }]}>
|
||||
<Ionicons name="scan-outline" size={20} color={colors.onPrimary} />
|
||||
</View>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroTitle, { color: isDarkMode ? colors.textOnImage : colors.text }]}>
|
||||
{copy.heroTitle}
|
||||
</Text>
|
||||
<Text style={[styles.heroMeta, { color: isDarkMode ? '#d7ded9' : colors.textSecondary }]}>
|
||||
{copy.heroMeta}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ImageBackground>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t.sourceOnboardingTitle}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.sourceOnboardingSubtitle}</Text>
|
||||
</View>
|
||||
@@ -86,8 +169,13 @@ export default function OnboardingSourceScreen() {
|
||||
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
|
||||
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colors.text }]}>{sourceLabels[option.id as keyof typeof sourceLabels]}</Text>
|
||||
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
|
||||
<View style={styles.optionCopy}>
|
||||
<Text style={[styles.optionLabel, { color: colors.text }]}>{sourceLabels[option.id as keyof typeof sourceLabels]}</Text>
|
||||
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
|
||||
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
|
||||
</Text>
|
||||
</View>
|
||||
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} style={styles.optionCheck} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
<View style={[styles.healthActionCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={styles.healthActionRow}>
|
||||
<View style={styles.healthActionInfo}>
|
||||
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
|
||||
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.cost}</Text>
|
||||
</View>
|
||||
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
|
||||
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.intro}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.healthActionBtn,
|
||||
@@ -638,11 +650,20 @@ export default function PlantDetailScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.healthTimestamp, { color: colors.textMuted }]}>
|
||||
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
|
||||
</Text>
|
||||
|
||||
<View style={styles.healthListBlock}>
|
||||
<Text style={[styles.healthTimestamp, { color: colors.textMuted }]}>
|
||||
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
|
||||
</Text>
|
||||
|
||||
{latestAnalysisSummary ? (
|
||||
<View style={[styles.healthAnalysisBox, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
|
||||
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.analysisTitle}</Text>
|
||||
<Text style={[styles.healthAnalysisText, { color: colors.textSecondary }]}>
|
||||
{latestAnalysisSummary}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.healthListBlock}>
|
||||
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.issuesTitle}</Text>
|
||||
{latestHealthCheck.likelyIssues.map((issue, index) => (
|
||||
<View key={`${issue.title}-${index}`} style={styles.healthIssueWrap}>
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
1247
app/scanner.tsx
BIN
assets/onboarding_experience_mockup.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/onboarding_goal_mockup.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
assets/onboarding_health_scan_mockup.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/onboarding_source_mockup.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/paywall_scan_background.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/welcome_botanical_header.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
assets/welcome_botanical_hero.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
312
marketing/ad_creatives_greenlens_2026-05-08.md
Normal file
@@ -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 |
|
||||
1383
marketing/greenlens_20_plant_infographic_slideshows.md
Normal file
10
memory/project_summary.md
Normal file
@@ -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.
|
||||
0
output/gsc-greenlenspro-opportunities.json
Normal file
139
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> {
|
||||
await clearStoredSession();
|
||||
},
|
||||
|
||||
async updateSessionName(name: string): Promise<void> {
|
||||
await clearStoredSession();
|
||||
},
|
||||
|
||||
async deleteAccount(): Promise<void> {
|
||||
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<void> {
|
||||
const session = await this.getSession();
|
||||
if (!session) return;
|
||||
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...session, name }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -148,15 +148,19 @@ export const AuthDb = {
|
||||
return { id: newUserId };
|
||||
},
|
||||
|
||||
getUserById(id: number): DbUser | null {
|
||||
const db = getDb();
|
||||
const user = db.getFirstSync<DbUser>(
|
||||
'SELECT id, email, name FROM users WHERE id = ?',
|
||||
[id],
|
||||
);
|
||||
return user || null;
|
||||
},
|
||||
};
|
||||
getUserById(id: number): DbUser | null {
|
||||
const db = getDb();
|
||||
const user = db.getFirstSync<DbUser>(
|
||||
'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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
15
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||