feat(app): paywall is param-driven and dismissible; onboarding context routes X to sign-up

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Timo Knuth
2026-07-06 13:31:02 +02:00
parent 88b12aeb00
commit b2dbd3e652

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, useWindowDimensions } from 'react-native'; import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, useWindowDimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import Purchases, { import Purchases, {
@@ -330,6 +330,9 @@ const getBillingCopy = (language: Language) => {
export default function BillingScreen() { export default function BillingScreen() {
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams<{ view?: string; context?: string }>();
const paywallRequested = params.view === 'paywall';
const onboardingContext = params.context === 'onboarding';
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
@@ -351,7 +354,7 @@ export default function BillingScreen() {
const planId = billingSummary?.entitlement?.plan || 'free'; const planId = billingSummary?.entitlement?.plan || 'free';
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0); const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0);
const showPaywallPlans = !session || (!isLoadingBilling && planId !== 'pro'); const showPaywallPlans = (!session || paywallRequested) && planId !== 'pro';
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -439,6 +442,16 @@ export default function BillingScreen() {
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
if (showPaywallPlans) { if (showPaywallPlans) {
posthog.capture('paywall_dismissed', { context: onboardingContext ? 'onboarding' : 'in_app' });
if (onboardingContext) {
router.replace('/auth/signup');
return;
}
if (session) {
if (router.canGoBack()) router.back();
else router.replace('/(tabs)');
return;
}
router.replace('/onboarding'); router.replace('/onboarding');
return; return;
} }
@@ -447,7 +460,9 @@ export default function BillingScreen() {
return; return;
} }
router.replace('/(tabs)'); router.replace('/(tabs)');
}, [router, showPaywallPlans]); }, [router, showPaywallPlans, onboardingContext, session, posthog]);
const postPurchaseRoute = onboardingContext ? '/auth/signup' : '/(tabs)';
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -455,12 +470,12 @@ export default function BillingScreen() {
if (!showPaywallPlans) { if (!showPaywallPlans) {
return false; return false;
} }
router.replace('/onboarding'); handleBack();
return true; return true;
}); });
return () => subscription.remove(); return () => subscription.remove();
}, [router, showPaywallPlans]), }, [showPaywallPlans, handleBack]),
); );
const completeExpoGoSimulation = async (productId: PurchaseProductId) => { const completeExpoGoSimulation = async (productId: PurchaseProductId) => {
@@ -471,7 +486,7 @@ export default function BillingScreen() {
posthog.capture('subscription_started', { product_id: productId, simulated: true }); posthog.capture('subscription_started', { product_id: productId, simulated: true });
posthog.capture('trial_started', { product_id: productId, simulated: true }); posthog.capture('trial_started', { product_id: productId, simulated: true });
setSubModalVisible(false); setSubModalVisible(false);
router.replace('/(tabs)'); router.replace(postPurchaseRoute);
} else { } else {
posthog.capture('topup_purchased', { product_id: productId, simulated: true }); posthog.capture('topup_purchased', { product_id: productId, simulated: true });
} }
@@ -534,7 +549,7 @@ export default function BillingScreen() {
posthog.capture('subscription_started', { product_id: productId }); posthog.capture('subscription_started', { product_id: productId });
posthog.capture('trial_started', { product_id: productId }); posthog.capture('trial_started', { product_id: productId });
setSubModalVisible(false); setSubModalVisible(false);
setTimeout(() => router.replace('/(tabs)'), 0); setTimeout(() => router.replace(postPurchaseRoute), 0);
return; return;
} else { } else {
const selectedProduct = topupProducts[productId]; const selectedProduct = topupProducts[productId];