From b2dbd3e65297dc077a89b8f161b18c414f4defa0 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Mon, 6 Jul 2026 13:31:02 +0200 Subject: [PATCH] feat(app): paywall is param-driven and dismissible; onboarding context routes X to sign-up Co-Authored-By: Claude Fable 5 --- app/profile/billing.tsx | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 5bc986e..433adb8 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -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 { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { useFocusEffect } from '@react-navigation/native'; import Constants from 'expo-constants'; import Purchases, { @@ -330,6 +330,9 @@ const getBillingCopy = (language: Language) => { export default function BillingScreen() { 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 colors = useColors(isDarkMode, colorPalette); const posthog = useSafeAnalytics(); @@ -351,7 +354,7 @@ export default function BillingScreen() { const planId = billingSummary?.entitlement?.plan || 'free'; const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0); - const showPaywallPlans = !session || (!isLoadingBilling && planId !== 'pro'); + const showPaywallPlans = (!session || paywallRequested) && planId !== 'pro'; useEffect(() => { let cancelled = false; @@ -439,6 +442,16 @@ export default function BillingScreen() { const handleBack = useCallback(() => { 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'); return; } @@ -447,7 +460,9 @@ export default function BillingScreen() { return; } router.replace('/(tabs)'); - }, [router, showPaywallPlans]); + }, [router, showPaywallPlans, onboardingContext, session, posthog]); + + const postPurchaseRoute = onboardingContext ? '/auth/signup' : '/(tabs)'; useFocusEffect( useCallback(() => { @@ -455,12 +470,12 @@ export default function BillingScreen() { if (!showPaywallPlans) { return false; } - router.replace('/onboarding'); + handleBack(); return true; }); return () => subscription.remove(); - }, [router, showPaywallPlans]), + }, [showPaywallPlans, handleBack]), ); const completeExpoGoSimulation = async (productId: PurchaseProductId) => { @@ -471,7 +486,7 @@ export default function BillingScreen() { posthog.capture('subscription_started', { product_id: productId, simulated: true }); posthog.capture('trial_started', { product_id: productId, simulated: true }); setSubModalVisible(false); - router.replace('/(tabs)'); + router.replace(postPurchaseRoute); } else { 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('trial_started', { product_id: productId }); setSubModalVisible(false); - setTimeout(() => router.replace('/(tabs)'), 0); + setTimeout(() => router.replace(postPurchaseRoute), 0); return; } else { const selectedProduct = topupProducts[productId];