App chrash + seo

This commit is contained in:
2026-05-10 22:37:01 +02:00
parent 9386ae1be7
commit 2658c37453
28 changed files with 13232 additions and 519 deletions

View File

@@ -1,11 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, 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 { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import Constants from 'expo-constants';
import Purchases, {
LOG_LEVEL,
PACKAGE_TYPE,
PRODUCT_CATEGORY,
PurchasesOffering,
@@ -13,7 +14,7 @@ import Purchases, {
PurchasesStoreProduct,
} from 'react-native-purchases';
import { useApp } from '../../context/AppContext';
import { usePostHog } from 'posthog-react-native';
import { useSafeAnalytics } from '../../services/analytics';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
@@ -82,6 +83,24 @@ const summarizeOfferingPackages = (offering: PurchasesOffering | null) => {
};
};
let revenueCatConfigured = false;
const ensureRevenueCatConfigured = () => {
if (revenueCatConfigured || Constants.appOwnership === 'expo') {
return;
}
Purchases.setLogLevel(LOG_LEVEL.WARN);
const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR';
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder';
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
}
revenueCatConfigured = true;
};
const getBillingCopy = (language: Language) => {
if (language === 'de') {
return {
@@ -319,7 +338,7 @@ export default function BillingScreen() {
const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog();
const posthog = useSafeAnalytics();
const copy = getBillingCopy(language);
const isExpoGo = Constants.appOwnership === 'expo';
const { height: windowHeight } = useWindowDimensions();
@@ -328,6 +347,7 @@ export default function BillingScreen() {
const [subModalVisible, setSubModalVisible] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [storeReady, setStoreReady] = useState(isExpoGo);
const [storeError, setStoreError] = useState<string | null>(null);
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('yearly');
@@ -349,6 +369,7 @@ export default function BillingScreen() {
}
try {
ensureRevenueCatConfigured();
const [offerings, topups] = await Promise.all([
Purchases.getOfferings(),
Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION),
@@ -369,8 +390,12 @@ export default function BillingScreen() {
topup_medium: topups.find((product) => product.identifier === 'topup_medium'),
topup_large: topups.find((product) => product.identifier === 'topup_large'),
});
setStoreError(null);
} catch (error) {
console.warn('Failed to load RevenueCat products', error);
if (!cancelled) {
setStoreError('Purchases are temporarily unavailable. Please try again later.');
}
} finally {
if (!cancelled) {
setStoreReady(true);
@@ -386,12 +411,14 @@ export default function BillingScreen() {
}, [isExpoGo]);
useEffect(() => {
posthog.capture('paywall_viewed', { plan_id: planId });
try { posthog.capture('paywall_viewed', { plan_id: planId }); } catch {}
if (showPaywallPlans) {
posthog.capture('hard_paywall_viewed', {
plan_id: planId,
authenticated: Boolean(session),
});
try {
posthog.capture('hard_paywall_viewed', {
plan_id: planId,
authenticated: Boolean(session),
});
} catch {}
}
}, [posthog, planId, session?.serverUserId, showPaywallPlans]);
@@ -467,6 +494,11 @@ export default function BillingScreen() {
return;
}
if (!isExpoGo && storeError) {
Alert.alert('Purchases unavailable', storeError);
return;
}
setIsUpdating(true);
posthog.capture('purchase_initiated', { product_id: productId });
try {
@@ -483,6 +515,7 @@ export default function BillingScreen() {
await completeExpoGoSimulation(productId);
return;
} else {
ensureRevenueCatConfigured();
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (planId === 'pro') {
await openAppleSubscriptions();
@@ -553,6 +586,7 @@ export default function BillingScreen() {
setIsUpdating(true);
try {
if (!isExpoGo) {
ensureRevenueCatConfigured();
const customerInfo = await Purchases.restorePurchases();
await syncRevenueCatState(customerInfo as any, 'restore');
}
@@ -681,10 +715,10 @@ export default function BillingScreen() {
style={[
styles.hardPaywallCta,
compactPaywall && styles.hardPaywallCtaCompact,
(!storeReady || isUpdating) && styles.disabledPlanCard,
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(selectedProductId)}
disabled={isUpdating || !storeReady}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.9}
>
{isUpdating || !storeReady ? (
@@ -699,6 +733,9 @@ export default function BillingScreen() {
)}
</TouchableOpacity>
{storeError ? (
<Text style={styles.hardPaywallFooter}>{storeError}</Text>
) : null}
<Text style={styles.hardPaywallFooter}>{paywallFooterLabel}</Text>
<View style={styles.legalLinksRow}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
@@ -778,7 +815,7 @@ export default function BillingScreen() {
<TouchableOpacity
style={[styles.paywallPlanCardPrimary, { borderColor: colors.primary, backgroundColor: colors.primary + '12' }]}
onPress={() => handlePurchase('yearly_pro')}
disabled={isUpdating || !storeReady}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.9}
>
<View style={styles.planTopRow}>
@@ -805,10 +842,10 @@ export default function BillingScreen() {
style={[
styles.paywallPlanCardSecondary,
{ borderColor: colors.border },
(!storeReady || isUpdating) && styles.disabledPlanCard,
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.9}
>
<View style={styles.planTopRow}>
@@ -876,7 +913,7 @@ export default function BillingScreen() {
}
]}
onPress={() => handlePurchase(pack.id)}
disabled={isUpdating || !storeReady}
disabled={isUpdating || !storeReady || Boolean(storeError)}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="flash" size={18} color={colors.primary} />
@@ -953,7 +990,7 @@ export default function BillingScreen() {
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady}
disabled={isUpdating || !storeReady || Boolean(storeError)}
>
<View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}>
@@ -984,7 +1021,7 @@ export default function BillingScreen() {
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={() => handlePurchase('yearly_pro')}
disabled={isUpdating || !storeReady}
disabled={isUpdating || !storeReady || Boolean(storeError)}
>
<View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}>
@@ -1070,8 +1107,8 @@ export default function BillingScreen() {
<TouchableOpacity
style={styles.offerDeclineBtn}
onPress={finalizeCancel}
disabled={isUpdating || !storeReady}
>
disabled={isUpdating}
>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>{copy.offerDecline}</Text>
</TouchableOpacity>
</View>