From 4ddf16ed20737239108581d9f2d19576485ee669 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Mon, 6 Jul 2026 13:08:46 +0200 Subject: [PATCH] feat(app): scanner uses real credits for free users, demo mode only for guests Co-Authored-By: Claude Fable 5 --- app/scanner.tsx | 64 +++++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/app/scanner.tsx b/app/scanner.tsx index 050063e..a35e979 100644 --- a/app/scanner.tsx +++ b/app/scanner.tsx @@ -24,6 +24,7 @@ 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'; const HEALTH_CHECK_CREDIT_COST = 2; const DEMO_SCAN_LIMIT = 5; @@ -57,6 +58,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => { demoNoCreditsTitle: 'Demo-Scans aufgebraucht', demoNoCreditsMessage: 'Du hast deine 5 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.', demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`, + creditsRemaining: (count: number) => `${count} Scans übrig`, appleCta: 'Mit Apple fortfahren', emailCta: 'Mit E-Mail fortfahren', unlockCta: 'Vollständige Diagnose freischalten', @@ -91,6 +93,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => { demoNoCreditsTitle: 'Escaneos demo agotados', demoNoCreditsMessage: 'Ya usaste tus 5 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.', demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`, + creditsRemaining: (count: number) => `${count} escaneos restantes`, appleCta: 'Continuar con Apple', emailCta: 'Continuar con email', unlockCta: 'Desbloquear diagnóstico completo', @@ -124,6 +127,7 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => { demoNoCreditsTitle: 'Demo scans used', demoNoCreditsMessage: 'You used your 5 free demo scans on this device. Start Pro to keep scanning plants.', demoCreditsRemaining: (count: number) => `${count} demo scans left`, + creditsRemaining: (count: number) => `${count} scans left`, appleCta: 'Continue with Apple', emailCta: 'Continue with email', unlockCta: 'Unlock full diagnosis', @@ -167,8 +171,8 @@ export default function ScannerScreen() { : params.sharedImageKey; const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' && billingSummary?.entitlement?.status === 'active'; - const isDemoMode = !hasActiveEntitlement; - const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0; + const isDemoMode = !session; // guests get the local demo scan; signed-in users burn real credits + const availableCredits = billingSummary?.credits.available ?? 0; const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount); const [permission, requestPermission] = useCameraPermissions(); @@ -179,6 +183,7 @@ export default function ScannerScreen() { const [analysisProgress, setAnalysisProgress] = useState(0); const [analysisResult, setAnalysisResult] = useState(null); const [demoResultVisible, setDemoResultVisible] = useState(false); + const [outOfCreditsVisible, setOutOfCreditsVisible] = useState(false); const cameraRef = useRef(null); const scanLineProgress = useRef(new Animated.Value(0)).current; const scanPulse = useRef(new Animated.Value(0)).current; @@ -281,18 +286,10 @@ export default function ScannerScreen() { return; } - if (!isDemoMode && availableCredits <= 0) { - Alert.alert( - billingCopy.noCreditsTitle, - isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { - text: billingCopy.managePlan, - onPress: () => router.replace('/profile/billing'), - }, - ], - ); + const requiredCredits = isHealthMode ? 2 : 1; + if (!isDemoMode && availableCredits < requiredCredits) { + posthog.capture('out_of_credits_shown', { trigger: 'pre_check', scan_type: isHealthMode ? 'health_check' : 'identification' }); + setOutOfCreditsVisible(true); return; } @@ -412,17 +409,8 @@ export default function ScannerScreen() { }); if (isInsufficientCreditsError(error)) { - Alert.alert( - billingCopy.noCreditsTitle, - isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { - text: billingCopy.managePlan, - onPress: () => router.replace('/profile/billing'), - }, - ], - ); + posthog.capture('out_of_credits_shown', { trigger: 'server_402', scan_type: isHealthMode ? 'health_check' : 'identification' }); + setOutOfCreditsVisible(true); } else if (isTimeoutError(error)) { Alert.alert( billingCopy.timeoutTitle, @@ -689,7 +677,11 @@ export default function ScannerScreen() { - {isDemoMode ? billingCopy.demoCreditsRemaining(demoScansRemaining) : `${billingCopy.creditsLabel}: ${availableCredits}`} + {isDemoMode + ? billingCopy.demoCreditsRemaining(demoScansRemaining) + : !hasActiveEntitlement + ? billingCopy.creditsRemaining(availableCredits) + : `${billingCopy.creditsLabel}: ${availableCredits}`} @@ -865,6 +857,26 @@ export default function ScannerScreen() { {t.help} + + { + setOutOfCreditsVisible(false); + posthog.capture('paywall_opened', { source: 'out_of_credits' }); + router.push('/profile/billing?view=paywall'); + }} + onTopup={() => { + setOutOfCreditsVisible(false); + router.push('/profile/billing'); // topups live on the billing management screen + }} + onDismiss={() => { + posthog.capture('paywall_dismissed', { source: 'out_of_credits_sheet' }); + setOutOfCreditsVisible(false); + }} + /> ); }