Onboarding flow

This commit is contained in:
2026-07-06 22:25:52 +02:00
parent 99cd885833
commit 6b57a2acfc
37 changed files with 1503 additions and 507 deletions

View File

@@ -19,14 +19,13 @@ import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types';
import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService } from '../services/authService';
import { getMockPlantByImage } from '../services/backend/mockCatalog';
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService } from '../services/authService';
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet';
const DEMO_SCAN_LIMIT = 5;
const DEMO_SCAN_LIMIT = 3;
const getBillingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
@@ -52,7 +51,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
demoTitle: 'Rettungsplan bereit',
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.',
demoNoCreditsTitle: 'Demo-Scans aufgebraucht',
demoNoCreditsMessage: 'Du hast deine 5 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.',
demoNoCreditsMessage: 'Du hast deine 3 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.',
demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`,
creditsRemaining: (count: number) => `${count} Scans übrig`,
appleCta: 'Mit Apple fortfahren',
@@ -84,7 +83,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
demoTitle: 'Plan de rescate listo',
demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.',
demoNoCreditsTitle: 'Escaneos demo agotados',
demoNoCreditsMessage: 'Ya usaste tus 5 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.',
demoNoCreditsMessage: 'Ya usaste tus 3 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.',
demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`,
creditsRemaining: (count: number) => `${count} escaneos restantes`,
appleCta: 'Continuar con Apple',
@@ -115,7 +114,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
demoTitle: 'Rescue plan ready',
demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.',
demoNoCreditsTitle: 'Demo scans used',
demoNoCreditsMessage: 'You used your 5 free demo scans on this device. Start Pro to keep scanning plants.',
demoNoCreditsMessage: 'You used your 3 free demo scans on this device. Start Pro to keep scanning plants.',
demoCreditsRemaining: (count: number) => `${count} demo scans left`,
creditsRemaining: (count: number) => `${count} scans left`,
appleCta: 'Continue with Apple',
@@ -161,7 +160,7 @@ export default function ScannerScreen() {
: params.sharedImageKey;
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
const isDemoMode = !session; // guests get the local demo scan; signed-in users burn real credits
const isDemoMode = !session; // guests get limited AI demo scans; signed-in users burn real credits
const availableCredits = billingSummary?.credits.available ?? 0;
const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount);
@@ -262,17 +261,8 @@ export default function ScannerScreen() {
if (isAnalyzing) return;
if (isDemoMode && guestScanCount >= DEMO_SCAN_LIMIT) {
Alert.alert(
billingCopy.demoNoCreditsTitle,
billingCopy.demoNoCreditsMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/profile/billing'),
},
],
);
posthog.capture('out_of_credits_shown', { trigger: 'demo_limit', scan_type: 'identification' });
setOutOfCreditsVisible(true);
return;
}
@@ -303,21 +293,22 @@ export default function ScannerScreen() {
}, 150);
try {
if (isDemoMode) {
posthog.capture('demo_scan_started', {
authenticated: Boolean(session),
scan_type: isHealthMode ? 'health_check' : 'identification',
demo_scans_used: guestScanCount,
demo_scans_remaining: demoScansRemaining,
});
await new Promise(resolve => setTimeout(resolve, 2100));
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 350));
const demoResult = getMockPlantByImage(galleryImageUri || imageUri, language, true);
incrementGuestScanCount();
setAnalysisResult(demoResult);
posthog.capture('demo_scan_completed', {
if (isDemoMode) {
posthog.capture('demo_scan_started', {
authenticated: Boolean(session),
scan_type: isHealthMode ? 'health_check' : 'identification',
demo_scans_used: guestScanCount,
demo_scans_remaining: demoScansRemaining,
});
const demoResult = await PlantRecognitionService.identify(imageUri, language, {
idempotencyKey: createIdempotencyKey('demo-scan-plant'),
});
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 350));
incrementGuestScanCount();
setAnalysisResult(demoResult);
posthog.capture('demo_scan_completed', {
authenticated: Boolean(session),
latency_ms: Date.now() - startTime,
demo_scans_used_after: guestScanCount + 1,
@@ -859,16 +850,27 @@ export default function ScannerScreen() {
renewsAtIso={billingSummary?.credits.cycleEndsAt}
onSeePlans={() => {
setOutOfCreditsVisible(false);
posthog.capture('paywall_opened', { source: 'out_of_credits' });
router.push('/profile/billing?view=paywall');
if (!session) {
posthog.capture('paywall_opened', { source: 'out_of_credits', authenticated: false });
router.replace('/profile/billing?view=paywall&context=out_of_credits');
return;
}
posthog.capture('billing_opened', { source: 'out_of_credits', authenticated: true });
router.replace('/profile/billing');
}}
onTopup={() => {
onTopup={(productId) => {
setOutOfCreditsVisible(false);
router.push('/profile/billing'); // topups live on the billing management screen
posthog.capture('topup_prompt_opened', { source: 'out_of_credits', product_id: productId, authenticated: Boolean(session) });
if (session) {
router.replace({ pathname: '/profile/billing', params: { topup: productId } });
return;
}
router.replace({ pathname: '/auth/signup', params: { returnTo: 'billing', topup: productId } });
}}
onDismiss={() => {
posthog.capture('paywall_dismissed', { source: 'out_of_credits_sheet' });
setOutOfCreditsVisible(false);
handleClose();
}}
/>
</View>