Hard paywall

This commit is contained in:
2026-04-28 20:35:53 +02:00
parent 05efbb9910
commit 86631a9bc0
15 changed files with 15251 additions and 14164 deletions

View File

@@ -6,18 +6,20 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import * as Haptics from 'expo-haptics';
import { usePostHog } from 'posthog-react-native';
import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import * as Haptics from 'expo-haptics';
import * as AppleAuthentication from 'expo-apple-authentication';
import { usePostHog } from 'posthog-react-native';
import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types';
import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService } from '../services/authService';
const HEALTH_CHECK_CREDIT_COST = 2;
@@ -41,11 +43,16 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
healthTitle: 'Health Check',
healthDoneTitle: 'Health Check abgeschlossen',
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
signupLabel: 'Registrieren',
};
healthTitle: 'Health Check',
healthDoneTitle: 'Health Check abgeschlossen',
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
signupLabel: 'Registrieren',
demoTitle: 'Rettungsplan bereit',
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.',
appleCta: 'Mit Apple fortfahren',
emailCta: 'Mit E-Mail fortfahren',
unlockCta: 'Vollständige Diagnose freischalten',
};
}
if (language === 'es') {
@@ -67,11 +74,16 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.',
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
healthTitle: 'Health Check',
healthDoneTitle: 'Health-check completado',
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
signupLabel: 'Registrarse',
};
healthTitle: 'Health Check',
healthDoneTitle: 'Health-check completado',
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
signupLabel: 'Registrarse',
demoTitle: 'Plan de rescate listo',
demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.',
appleCta: 'Continuar con Apple',
emailCta: 'Continuar con email',
unlockCta: 'Desbloquear diagnóstico completo',
};
}
return {
@@ -92,12 +104,17 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
healthTitle: 'Health Check',
healthDoneTitle: 'Health Check Complete',
healthDoneMessage: 'The new photo was analyzed and added to gallery.',
signupLabel: 'Sign Up',
};
};
healthTitle: 'Health Check',
healthDoneTitle: 'Health Check Complete',
healthDoneMessage: 'The new photo was analyzed and added to gallery.',
signupLabel: 'Sign Up',
demoTitle: 'Rescue plan ready',
demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.',
appleCta: 'Continue with Apple',
emailCta: 'Continue with email',
unlockCta: 'Unlock full diagnosis',
};
};
export default function ScannerScreen() {
const params = useLocalSearchParams<{ mode?: string; plantId?: string }>();
@@ -112,36 +129,53 @@ export default function ScannerScreen() {
updatePlant,
billingSummary,
refreshBillingSummary,
isLoadingBilling,
session,
setPendingPlant,
guestScanCount,
incrementGuestScanCount,
} = useApp();
isLoadingBilling,
session,
hydrateSession,
setPendingPlant,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const router = useRouter();
const insets = useSafeAreaInsets();
const billingCopy = getBillingCopy(language);
const isHealthMode = params.mode === 'health';
const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId;
const healthPlant = isHealthMode && healthPlantId
? plants.find((item) => item.id === healthPlantId)
: null;
const availableCredits = session
? (billingSummary?.credits.available ?? 0)
: Math.max(0, 5 - guestScanCount);
const [permission, requestPermission] = useCameraPermissions();
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
const cameraRef = useRef<CameraView>(null);
const healthPlant = isHealthMode && healthPlantId
? plants.find((item) => item.id === healthPlantId)
: null;
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
const isDemoMode = !hasActiveEntitlement;
const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0;
const [permission, requestPermission] = useCameraPermissions();
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isAuthLoading, setIsAuthLoading] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
const [demoResultVisible, setDemoResultVisible] = useState(false);
const cameraRef = useRef<CameraView>(null);
const scanLineProgress = useRef(new Animated.Value(0)).current;
const scanPulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (!isAnalyzing) {
useEffect(() => {
let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (!isAnalyzing) {
scanLineProgress.stopAnimation();
scanLineProgress.setValue(0);
scanPulse.stopAnimation();
@@ -199,29 +233,25 @@ export default function ScannerScreen() {
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
if (isAnalyzing) return;
if (availableCredits <= 0) {
if (!session) {
// Guest: show paywall directly — no registration required to purchase
router.push('/profile/billing');
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('/(tabs)/profile'),
},
],
);
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/profile/billing'),
},
],
);
return;
}
setIsAnalyzing(true);
setAnalysisProgress(0);
setAnalysisResult(null);
setIsAnalyzing(true);
setAnalysisProgress(0);
setAnalysisResult(null);
setDemoResultVisible(false);
const startTime = Date.now();
@@ -234,10 +264,32 @@ export default function ScannerScreen() {
});
}, 150);
try {
if (isHealthMode) {
if (!healthPlant) {
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
try {
if (isDemoMode) {
posthog.capture('demo_scan_started', {
authenticated: Boolean(session),
scan_type: isHealthMode ? 'health_check' : 'identification',
});
await new Promise(resolve => setTimeout(resolve, 2100));
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 350));
setDemoResultVisible(true);
posthog.capture('demo_scan_completed', {
authenticated: Boolean(session),
latency_ms: Date.now() - startTime,
});
return;
}
posthog.capture('paid_scan_started', {
scan_type: isHealthMode ? 'health_check' : 'identification',
credits_available: availableCredits,
});
if (isHealthMode) {
if (!healthPlant) {
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
setSelectedImage(null);
setIsAnalyzing(false);
return;
@@ -261,11 +313,7 @@ export default function ScannerScreen() {
latency_ms: Date.now() - startTime,
});
if (!session) {
incrementGuestScanCount();
}
const currentGallery = healthPlant.gallery || [];
const currentGallery = healthPlant.gallery || [];
const existingChecks = healthPlant.healthChecks || [];
const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6);
const updatedPlant = {
@@ -285,16 +333,16 @@ export default function ScannerScreen() {
latency_ms: Date.now() - startTime,
});
if (!session) {
incrementGuestScanCount();
}
setAnalysisResult(result);
}
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 500));
setIsAnalyzing(false);
setAnalysisResult(result);
}
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
posthog.capture('paid_scan_completed', {
scan_type: isHealthMode ? 'health_check' : 'identification',
latency_ms: Date.now() - startTime,
});
await new Promise(resolve => setTimeout(resolve, 500));
setIsAnalyzing(false);
if (isHealthMode && healthPlant) {
Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [
{ text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) },
@@ -315,13 +363,13 @@ export default function ScannerScreen() {
billingCopy.noCreditsTitle,
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/(tabs)/profile'),
},
],
);
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/profile/billing'),
},
],
);
} else if (isTimeoutError(error)) {
Alert.alert(
billingCopy.timeoutTitle,
@@ -360,20 +408,24 @@ export default function ScannerScreen() {
}
setSelectedImage(null);
setIsAnalyzing(false);
} finally {
clearInterval(progressInterval);
await refreshBillingSummary();
}
};
} finally {
clearInterval(progressInterval);
setIsAnalyzing(false);
if (!isDemoMode) {
await refreshBillingSummary();
}
}
};
const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
if (photo) {
const analysisUri = await resizeForAnalysis(photo.uri);
setSelectedImage(analysisUri);
analyzeImage(analysisUri, photo.uri);
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
if (photo) {
const analysisUri = await resizeForAnalysis(photo.uri);
setDemoResultVisible(false);
setSelectedImage(analysisUri);
analyzeImage(analysisUri, photo.uri);
}
};
@@ -385,15 +437,16 @@ export default function ScannerScreen() {
quality: 1,
base64: false,
});
if (!result.canceled && result.assets[0]) {
const asset = result.assets[0];
const analysisUri = await resizeForAnalysis(asset.uri);
setSelectedImage(asset.uri);
if (!result.canceled && result.assets[0]) {
const asset = result.assets[0];
const analysisUri = await resizeForAnalysis(asset.uri);
setDemoResultVisible(false);
setSelectedImage(asset.uri);
analyzeImage(analysisUri, asset.uri);
}
};
const handleSave = async () => {
const handleSave = async () => {
if (analysisResult && selectedImage) {
if (!session) {
// Guest mode: store result and go to signup
@@ -409,10 +462,72 @@ export default function ScannerScreen() {
console.error('Saving identified plant failed', error);
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
}
}
};
const handleClose = () => {
}
};
const routeToHardPaywall = () => {
posthog.capture('auth_prompt_shown', {
authenticated: Boolean(session),
surface: 'demo_scan_result',
});
if (session) {
router.replace('/profile/billing');
return;
}
router.replace('/auth/signup');
};
const handleDemoAppleSignIn = async () => {
if (!appleAvailable) {
routeToHardPaywall();
return;
}
setIsAuthLoading(true);
posthog.capture('apple_login_started', { surface: 'scanner_demo' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const nextSession = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(nextSession);
posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' });
router.replace('/profile/billing');
} catch (error: any) {
if (error?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'scanner_demo',
error: error instanceof Error ? error.message : String(error),
});
Alert.alert(
billingCopy.genericErrorTitle,
error instanceof Error && error.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: billingCopy.genericErrorMessage,
);
} finally {
setIsAuthLoading(false);
}
};
const handleClose = () => {
router.back();
};
@@ -470,14 +585,14 @@ export default function ScannerScreen() {
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.iconOnImage }]}>
{isHealthMode ? billingCopy.healthTitle : t.scanner}
</Text>
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
<Ionicons name="wallet-outline" size={12} color={colors.text} />
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
{billingCopy.creditsLabel}: {availableCredits}
</Text>
</View>
</View>
</Text>
<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 ? 'Demo' : `${billingCopy.creditsLabel}: ${availableCredits}`}
</Text>
</View>
</View>
{/* Camera */}
<View style={styles.cameraContainer}>
@@ -560,10 +675,65 @@ export default function ScannerScreen() {
</Text>
</View>
</View>
)}
{/* Bottom Controls */}
<View
)}
{demoResultVisible && !isAnalyzing ? (
<View
style={[
styles.demoSheet,
{
backgroundColor: colors.background,
borderColor: colors.border,
bottom: analysisBottomOffset,
},
]}
>
<View style={[styles.demoIconWrap, { backgroundColor: colors.primarySoft }]}>
<Ionicons name="sparkles" size={22} color={colors.primary} />
</View>
<Text style={[styles.demoTitle, { color: colors.text }]}>{billingCopy.demoTitle}</Text>
<Text style={[styles.demoMessage, { color: colors.textSecondary }]}>{billingCopy.demoMessage}</Text>
{!session && appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.demoAppleButton}
onPress={handleDemoAppleSignIn}
/>
) : (
<TouchableOpacity
style={[styles.demoPrimaryBtn, { backgroundColor: colors.primary }]}
onPress={session ? routeToHardPaywall : handleDemoAppleSignIn}
disabled={isAuthLoading}
activeOpacity={0.85}
>
<Text style={[styles.demoPrimaryText, { color: colors.onPrimary }]}>
{isAuthLoading ? '...' : session ? billingCopy.unlockCta : billingCopy.appleCta}
</Text>
</TouchableOpacity>
)}
{!session ? (
<TouchableOpacity
style={[styles.demoSecondaryBtn, { borderColor: colors.borderStrong }]}
onPress={() => {
posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' });
router.replace('/auth/signup');
}}
activeOpacity={0.85}
>
<Text style={[styles.demoSecondaryText, { color: colors.text }]}>{billingCopy.emailCta}</Text>
</TouchableOpacity>
) : null}
</View>
) : null}
{/* Bottom Controls */}
<View
style={[
styles.controls,
{
@@ -686,7 +856,7 @@ const styles = StyleSheet.create({
},
shutterInner: { width: 64, height: 64, borderRadius: 32 },
shutterBtnDisabled: { opacity: 0.6 },
analysisSheet: {
analysisSheet: {
position: 'absolute',
left: 16,
right: 16,
@@ -699,9 +869,68 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.28,
shadowRadius: 14,
elevation: 14,
},
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
elevation: 14,
},
demoSheet: {
position: 'absolute',
left: 16,
right: 16,
borderRadius: 22,
borderWidth: 1,
padding: 18,
zIndex: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.24,
shadowRadius: 12,
elevation: 12,
},
demoIconWrap: {
width: 42,
height: 42,
borderRadius: 21,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 10,
},
demoTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 6,
},
demoMessage: {
fontSize: 14,
lineHeight: 20,
marginBottom: 14,
},
demoAppleButton: {
width: '100%',
height: 50,
marginBottom: 10,
},
demoPrimaryBtn: {
height: 50,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10,
},
demoPrimaryText: {
fontSize: 15,
fontWeight: '800',
},
demoSecondaryBtn: {
height: 48,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
demoSecondaryText: {
fontSize: 14,
fontWeight: '700',
},
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
analysisBadge: {
flexDirection: 'row',
alignItems: 'center',