Harte Paywall

This commit is contained in:
2026-04-29 21:16:16 +02:00
committed by Timo Knuth
parent 0f933da3c9
commit d37b49f1f6
10 changed files with 305 additions and 129 deletions

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import Constants from 'expo-constants';
import Purchases, {
PACKAGE_TYPE,
@@ -102,6 +103,7 @@ const getBillingCopy = (language: Language) => {
expoGoPurchaseTitle: 'Kauf nur im Dev Build',
expoGoPurchaseMessage: 'Expo Go kann keine Apple- oder RevenueCat-Kaufmaske anzeigen. Im Development Build oder TestFlight erscheint hier der echte 7-Tage-Trial. Fuer lokale Tests kannst du Pro simulieren.',
expoGoSimulate: 'Pro simulieren',
continueWithoutPro: 'Ohne Pro fortfahren',
perYear: '/ Jahr',
perMonth: '/ Monat',
freePlanName: 'Free',
@@ -165,6 +167,7 @@ const getBillingCopy = (language: Language) => {
expoGoPurchaseTitle: 'Compra solo en Dev Build',
expoGoPurchaseMessage: 'Expo Go no puede mostrar la compra nativa de Apple o RevenueCat. En Development Build o TestFlight aparecera el trial real de 7 dias. Para pruebas locales puedes simular Pro.',
expoGoSimulate: 'Simular Pro',
continueWithoutPro: 'Continuar sin Pro',
perYear: '/ ano',
perMonth: '/ mes',
freePlanName: 'Gratis',
@@ -228,6 +231,7 @@ const getBillingCopy = (language: Language) => {
expoGoPurchaseTitle: 'Purchase requires a dev build',
expoGoPurchaseMessage: 'Expo Go cannot show the native Apple or RevenueCat purchase sheet. In a Development Build or TestFlight this opens the real 7-day trial. For local testing you can simulate Pro.',
expoGoSimulate: 'Simulate Pro',
continueWithoutPro: 'Continue without Pro',
perYear: '/ year',
perMonth: '/ month',
freePlanName: 'Free',
@@ -290,7 +294,7 @@ export default function BillingScreen() {
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const planId = billingSummary?.entitlement?.plan || 'free';
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0);
const showPaywallPlans = !session || planId !== 'pro';
useEffect(() => {
@@ -365,13 +369,31 @@ export default function BillingScreen() {
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
};
const handleBack = () => {
const handleBack = useCallback(() => {
if (showPaywallPlans) {
router.replace(session ? '/scanner' : '/onboarding');
router.replace('/onboarding');
return;
}
router.back();
};
if (router.canGoBack()) {
router.back();
return;
}
router.replace('/(tabs)');
}, [router, showPaywallPlans]);
useFocusEffect(
useCallback(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
if (!showPaywallPlans) {
return false;
}
router.replace('/onboarding');
return true;
});
return () => subscription.remove();
}, [router, showPaywallPlans]),
);
const completeExpoGoSimulation = async (productId: PurchaseProductId) => {
setIsUpdating(true);
@@ -380,6 +402,8 @@ export default function BillingScreen() {
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
posthog.capture('subscription_started', { product_id: productId, simulated: true });
posthog.capture('trial_started', { product_id: productId, simulated: true });
setSubModalVisible(false);
router.replace('/(tabs)');
} else {
posthog.capture('topup_purchased', { product_id: productId, simulated: true });
}
@@ -404,7 +428,8 @@ export default function BillingScreen() {
setIsUpdating(false);
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
{ text: 'OK', style: 'cancel' },
{ text: copy.continueWithoutPro, style: 'cancel' },
{ text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) },
]);
return;
}
@@ -428,10 +453,16 @@ export default function BillingScreen() {
});
throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.');
}
await Purchases.purchasePackage(selectedPackage);
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
const purchaseResult = await Purchases.purchasePackage(selectedPackage);
// Apply RevenueCat entitlement locally and let backend sync finish in the background.
const customerInfo = (purchaseResult as { customerInfo?: unknown }).customerInfo
?? await Purchases.getCustomerInfo();
void syncRevenueCatState(customerInfo as any, 'subscription_purchase');
posthog.capture('subscription_started', { product_id: productId });
posthog.capture('trial_started', { product_id: productId });
setSubModalVisible(false);
setTimeout(() => router.replace('/(tabs)'), 0);
return;
} else {
const selectedProduct = topupProducts[productId];
if (!selectedProduct) {
@@ -442,12 +473,7 @@ export default function BillingScreen() {
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
}
}
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
posthog.capture('subscription_started', { product_id: productId });
posthog.capture('trial_started', { product_id: productId });
} else {
posthog.capture('topup_purchased', { product_id: productId });
}
posthog.capture('topup_purchased', { product_id: productId });
setSubModalVisible(false);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -532,9 +558,9 @@ export default function BillingScreen() {
) : (
<>
{session && planId === 'pro' && (
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text>
<View style={[styles.row, { marginBottom: 16 }]}>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text>
<View style={[styles.row, { marginBottom: 16 }]}>
<Text style={[styles.value, { color: colors.text }]}>
{planId === 'pro' ? copy.planPro : copy.planFree}
</Text>
@@ -597,7 +623,11 @@ export default function BillingScreen() {
</TouchableOpacity>
<TouchableOpacity
style={[styles.paywallPlanCardSecondary, { borderColor: colors.border }]}
style={[
styles.paywallPlanCardSecondary,
{ borderColor: colors.border },
(!storeReady || isUpdating) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady}
activeOpacity={0.9}
@@ -615,7 +645,16 @@ export default function BillingScreen() {
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text>
<Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perMonth}</Text>
</View>
<Text style={[styles.monthlyCtaText, { color: colors.primary }]}>{copy.monthlyCta}</Text>
<View style={[styles.monthlyCtaButton, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
{!storeReady ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<>
<Text style={[styles.monthlyCtaText, { color: colors.primary }]}>{copy.monthlyCta}</Text>
<Ionicons name="arrow-forward" size={14} color={colors.primary} />
</>
)}
</View>
</TouchableOpacity>
<View style={[styles.legalLinksRow, { marginTop: 16 }]}>
@@ -1106,6 +1145,9 @@ const styles = StyleSheet.create({
padding: 14,
marginTop: 10,
},
disabledPlanCard: {
opacity: 0.72,
},
guestPlanHeader: {
flexDirection: 'row',
alignItems: 'center',
@@ -1174,7 +1216,18 @@ const styles = StyleSheet.create({
monthlyCtaText: {
fontSize: 13,
fontWeight: '800',
marginTop: 8,
},
monthlyCtaButton: {
minHeight: 42,
borderRadius: 12,
borderWidth: 1,
marginTop: 12,
paddingHorizontal: 14,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
guestSubscribeBtn: {
marginTop: 14,