feat(app): scanner uses real credits for free users, demo mode only for guests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Timo Knuth
2026-07-06 13:08:46 +02:00
parent 396477660b
commit 4ddf16ed20

View File

@@ -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<IdentificationResult | null>(null);
const [demoResultVisible, setDemoResultVisible] = useState(false);
const [outOfCreditsVisible, setOutOfCreditsVisible] = useState(false);
const cameraRef = useRef<CameraView>(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() {
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
<Ionicons name={isDemoMode ? 'sparkles-outline' : 'wallet-outline'} size={12} color={colors.text} />
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
{isDemoMode ? billingCopy.demoCreditsRemaining(demoScansRemaining) : `${billingCopy.creditsLabel}: ${availableCredits}`}
{isDemoMode
? billingCopy.demoCreditsRemaining(demoScansRemaining)
: !hasActiveEntitlement
? billingCopy.creditsRemaining(availableCredits)
: `${billingCopy.creditsLabel}: ${availableCredits}`}
</Text>
</View>
</View>
@@ -865,6 +857,26 @@ export default function ScannerScreen() {
<Text style={[styles.controlLabel, { color: colors.textMuted }]}>{t.help}</Text>
</TouchableOpacity>
</View>
<OutOfCreditsSheet
visible={outOfCreditsVisible}
language={language}
colors={colors}
renewsAtIso={billingSummary?.credits.cycleEndsAt}
onSeePlans={() => {
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);
}}
/>
</View>
);
}