Initial commit for Greenlens

This commit is contained in:
Timo Knuth
2026-03-16 21:31:46 +01:00
parent 307135671f
commit 05d4f6e78b
573 changed files with 54233 additions and 1891 deletions

657
app/profile/billing.tsx Normal file
View File

@@ -0,0 +1,657 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import RevenueCatUI, { PAYWALL_RESULT } from "react-native-purchases-ui";
import { usePostHog } from 'posthog-react-native';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts';
const getBillingCopy = (language: Language) => {
if (language === 'de') {
return {
title: 'Abo und Credits',
planLabel: 'Aktueller Plan',
planFree: 'Free',
planPro: 'Pro',
creditsAvailableLabel: 'Verfügbare Credits',
manageSubscription: 'Abo verwalten',
subscriptionTitle: 'Abos',
subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.',
freePlanName: 'Free',
freePlanPrice: '0 EUR / Monat',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Monat',
proBadgeText: 'EMPFOHLEN',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Jahr',
proYearlyBadgeText: 'SPAREN',
proBenefits: [
'250 Credits jeden Monat',
'Pro-Scans mit GPT-5.4',
'Unbegrenzte Historie & Galerie',
'KI-Pflanzendoktor inklusive',
'Priorisierter Support'
],
topupTitle: 'Credits Aufladen',
topupSmall: '25 Credits 1,99 €',
topupMedium: '120 Credits 6,99 €',
topupLarge: '300 Credits 12,99 €',
topupBestValue: 'BESTES ANGEBOT',
cancelTitle: 'Schade, dass du gehst',
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
reasonTooExpensive: 'Es ist mir zu teuer',
reasonNotUsing: 'Ich nutze die App zu selten',
reasonOther: 'Ein anderer Grund',
offerTitle: 'Ein Geschenk für dich!',
offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).',
offerAccept: 'Rabatt sichern',
offerDecline: 'Nein, Kündigung fortsetzen',
confirmCancelBtn: 'Jetzt kündigen',
};
} else if (language === 'es') {
return {
title: 'Suscripción y Créditos',
planLabel: 'Plan Actual',
planFree: 'Gratis',
planPro: 'Pro',
creditsAvailableLabel: 'Créditos Disponibles',
manageSubscription: 'Administrar Suscripción',
subscriptionTitle: 'Suscripciones',
subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.',
freePlanName: 'Gratis',
freePlanPrice: '0 EUR / Mes',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Mes',
proBadgeText: 'RECOMENDADO',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Año',
proYearlyBadgeText: 'AHORRAR',
proBenefits: [
'250 créditos cada mes',
'Escaneos Pro con GPT-5.4',
'Historial y galería ilimitados',
'Doctor de plantas de IA incluido',
'Soporte prioritario'
],
topupTitle: 'Recargar Créditos',
topupSmall: '25 Créditos 1,99 €',
topupMedium: '120 Créditos 6,99 €',
topupLarge: '300 Créditos 12,99 €',
topupBestValue: 'MEJOR OFERTA',
cancelTitle: 'Lamentamos verte ir',
cancelQuestion: '¿Podemos saber por qué cancelas?',
reasonTooExpensive: 'Es muy caro',
reasonNotUsing: 'No lo uso suficiente',
reasonOther: 'Otra razón',
offerTitle: '¡Un regalo para ti!',
offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).',
offerAccept: 'Aceptar descuento',
offerDecline: 'No, continuar cancelando',
confirmCancelBtn: 'Cancelar ahora',
};
}
return {
title: 'Billing & Credits',
planLabel: 'Current Plan',
planFree: 'Free',
planPro: 'Pro',
creditsAvailableLabel: 'Available Credits',
manageSubscription: 'Manage Subscription',
subscriptionTitle: 'Subscriptions',
subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.',
freePlanName: 'Free',
freePlanPrice: '0 EUR / Month',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Month',
proBadgeText: 'RECOMMENDED',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Year',
proYearlyBadgeText: 'SAVE',
proBenefits: [
'250 credits every month',
'Pro scans with GPT-5.4',
'Unlimited history & gallery',
'AI Plant Doctor included',
'Priority support'
],
topupTitle: 'Topup Credits',
topupSmall: '25 Credits €1.99',
topupMedium: '120 Credits €6.99',
topupLarge: '300 Credits €12.99',
topupBestValue: 'BEST VALUE',
cancelTitle: 'Sorry to see you go',
cancelQuestion: 'May we ask why you are cancelling?',
reasonTooExpensive: 'It is too expensive',
reasonNotUsing: 'I don\'t use it enough',
reasonOther: 'Other reason',
offerTitle: 'A gift for you!',
offerText: 'Stay with us and get your next month for just €2.49 (50% off).',
offerAccept: 'Claim discount',
offerDecline: 'No, continue cancelling',
confirmCancelBtn: 'Cancel now',
};
};
export default function BillingScreen() {
const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, appearanceMode, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const copy = getBillingCopy(language);
const posthog = usePostHog();
const [subModalVisible, setSubModalVisible] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
// Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const [cancelReason, setCancelReason] = useState<string | null>(null);
const planId = billingSummary?.entitlement?.plan || 'free';
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
const handlePurchase = async (productId: PurchaseProductId) => {
setIsUpdating(true);
try {
const paywallResult: PAYWALL_RESULT = await RevenueCatUI.presentPaywall();
switch (paywallResult) {
case PAYWALL_RESULT.NOT_PRESENTED:
case PAYWALL_RESULT.ERROR:
case PAYWALL_RESULT.CANCELLED:
break;
case PAYWALL_RESULT.PURCHASED:
case PAYWALL_RESULT.RESTORED:
await simulatePurchase(productId);
setSubModalVisible(false);
break;
default:
break;
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error('Payment failed', e);
if (__DEV__ && (msg.toLowerCase().includes('native') || msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('undefined'))) {
// Fallback for Expo Go since RevenueCat native module is not available
console.log('Falling back to simulated purchase in Expo Go');
await simulatePurchase(productId);
setSubModalVisible(false);
} else {
Alert.alert('Unerwarteter Fehler', msg);
}
} finally {
setIsUpdating(false);
}
};
const handleSimulatePurchase = async (productId: PurchaseProductId) => {
// Fallback for free option
setIsUpdating(true);
await simulatePurchase(productId);
setIsUpdating(false);
setSubModalVisible(false);
};
const handleDowngrade = async () => {
if (planId === 'free') return; // already on free plan
setCancelStep('survey');
};
const finalizeCancel = async () => {
setIsUpdating(true);
try {
await simulateWebhookEvent('entitlement_revoked');
setCancelStep('none');
setSubModalVisible(false);
} catch (e) {
console.error('Downgrade failed', e);
} finally {
setIsUpdating(false);
}
};
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ThemeBackdrop colors={colors} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<View style={{ width: 40 }} />
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
{isLoadingBilling ? (
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
) : (
<>
<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>
<TouchableOpacity
style={[styles.manageBtn, { backgroundColor: colors.primary }]}
onPress={() => setSubModalVisible(true)}
>
<Text style={styles.manageBtnText}>{copy.manageSubscription}</Text>
</TouchableOpacity>
</View>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.creditsAvailableLabel}</Text>
<Text style={[styles.creditsValue, { color: colors.text }]}>{credits}</Text>
</View>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text>
<View style={{ gap: 10, marginTop: 8 }}>
{([
{ id: 'topup_small' as PurchaseProductId, label: copy.topupSmall },
{ id: 'topup_medium' as PurchaseProductId, label: copy.topupMedium, badge: copy.topupBestValue },
{ id: 'topup_large' as PurchaseProductId, label: copy.topupLarge },
] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => (
<TouchableOpacity
key={pack.id}
style={[
styles.topupBtn,
{
borderColor: pack.badge ? colors.primary : colors.border,
backgroundColor: pack.badge ? colors.primary + '15' : 'transparent',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
}
]}
onPress={() => handlePurchase(pack.id)}
disabled={isUpdating}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="flash" size={18} color={colors.primary} />
<Text style={[styles.topupText, { color: colors.text }]}>
{isUpdating ? '...' : pack.label}
</Text>
</View>
{pack.badge && (
<View style={{ backgroundColor: colors.primary, borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 }}>
<Text style={{ color: '#fff', fontSize: 10, fontWeight: '700' }}>{pack.badge}</Text>
</View>
)}
</TouchableOpacity>
))}
</View>
</View>
</>
)}
</ScrollView>
</SafeAreaView>
<Modal visible={subModalVisible} transparent animationType="slide" onRequestClose={() => setSubModalVisible(false)}>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>
{cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle}
</Text>
<TouchableOpacity onPress={() => {
setSubModalVisible(false);
setCancelStep('none');
}}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</View>
{cancelStep === 'none' ? (
<>
<Text style={[styles.modalHint, { color: colors.text + '80' }]}>{copy.subscriptionHint}</Text>
<View style={styles.plansContainer}>
<TouchableOpacity
style={[
styles.planOption,
{ borderColor: colors.border },
planId === 'free' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={handleDowngrade}
disabled={isUpdating}
>
<View>
<Text style={[styles.planName, { color: colors.text }]}>{copy.freePlanName}</Text>
<Text style={[styles.planPrice, { color: colors.text + '80' }]}>{copy.freePlanPrice}</Text>
</View>
{planId === 'free' && <Ionicons name="checkmark-circle" size={24} color={colors.primary} />}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.planOption,
{ borderColor: colors.border },
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating}
>
<View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}>
<Text style={[styles.planName, { color: colors.text }]}>{copy.proPlanName}</Text>
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
<Text style={styles.proBadgeText}>{copy.proBadgeText}</Text>
</View>
</View>
<Text style={[styles.planPrice, { color: colors.text + '80' }]}>{copy.proPlanPrice}</Text>
<View style={styles.proBenefits}>
{copy.proBenefits.map((b, i) => (
<View key={i} style={styles.benefitRow}>
<Ionicons name="checkmark" size={14} color={colors.primary} />
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text>
</View>
))}
</View>
</View>
{planId === 'pro' && <Ionicons name="checkmark-circle" size={24} color={colors.primary} />}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.planOption,
{ borderColor: colors.border },
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={() => handlePurchase('yearly_pro')}
disabled={isUpdating}
>
<View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}>
<Text style={[styles.planName, { color: colors.text }]}>{copy.proYearlyPlanName}</Text>
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
<Text style={styles.proBadgeText}>{copy.proYearlyBadgeText}</Text>
</View>
</View>
<Text style={[styles.planPrice, { color: colors.text + '80' }]}>{copy.proYearlyPlanPrice}</Text>
<View style={styles.proBenefits}>
{copy.proBenefits.map((b, i) => (
<View key={i} style={styles.benefitRow}>
<Ionicons name="checkmark" size={14} color={colors.primary} />
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text>
</View>
))}
</View>
</View>
{planId === 'pro' && <Ionicons name="checkmark-circle" size={24} color={colors.primary} />}
</TouchableOpacity>
</View>
</>
) : cancelStep === 'survey' ? (
<View style={styles.cancelFlowContainer}>
<Text style={[styles.cancelHint, { color: colors.textSecondary }]}>{copy.cancelQuestion}</Text>
<View style={styles.reasonList}>
{[
{ id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' },
{ id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' },
{ id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' },
].map((reason) => (
<TouchableOpacity
key={reason.id}
style={[styles.reasonOption, { borderColor: colors.border }]}
onPress={() => {
setCancelReason(reason.id);
setCancelStep('offer');
}}
>
<View style={[styles.reasonIcon, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name={reason.icon as any} size={20} color={colors.textSecondary} />
</View>
<Text style={[styles.reasonText, { color: colors.text }]}>{reason.label}</Text>
<Ionicons name="chevron-forward" size={18} color={colors.borderStrong} />
</TouchableOpacity>
))}
</View>
</View>
) : (
<View style={styles.cancelFlowContainer}>
<View style={[styles.offerCard, { backgroundColor: colors.primarySoft }]}>
<View style={[styles.offerIconWrap, { backgroundColor: colors.primary }]}>
<Ionicons name="gift" size={28} color="#fff" />
</View>
<Text style={[styles.offerText, { color: colors.text }]}>{copy.offerText}</Text>
<TouchableOpacity
style={[styles.offerAcceptBtn, { backgroundColor: colors.primary }]}
onPress={() => {
// Handle applying discount here (future implementation)
Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)');
setCancelStep('none');
setSubModalVisible(false);
}}
>
<Text style={styles.offerAcceptBtnText}>{copy.offerAccept}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.offerDeclineBtn}
onPress={finalizeCancel}
disabled={isUpdating}
>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>{copy.offerDecline}</Text>
</TouchableOpacity>
</View>
)}
{isUpdating && cancelStep === 'none' && <ActivityIndicator color={colors.primary} style={{ marginTop: 16 }} />}
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', padding: 16 },
backButton: { width: 40, height: 40, justifyContent: 'center' },
title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' },
scrollContent: { padding: 16, gap: 16 },
card: {
padding: 16,
borderRadius: 16,
borderWidth: StyleSheet.hairlineWidth,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
value: {
fontSize: 18,
fontWeight: '600',
},
manageBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
},
manageBtnText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
creditsValue: {
fontSize: 32,
fontWeight: '700',
},
topupBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 12,
borderWidth: 2,
gap: 8,
},
topupText: {
fontSize: 16,
fontWeight: '600',
},
modalOverlay: {
flex: 1,
backgroundColor: '#00000080',
justifyContent: 'flex-end',
},
modalContent: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 24,
borderTopWidth: StyleSheet.hairlineWidth,
paddingBottom: 40,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
modalTitle: {
fontSize: 20,
fontWeight: '700',
},
modalHint: {
fontSize: 14,
marginBottom: 24,
},
plansContainer: {
gap: 12,
},
planOption: {
padding: 16,
borderRadius: 12,
borderWidth: 2,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
planName: {
fontSize: 18,
fontWeight: '600',
},
planPrice: {
fontSize: 14,
},
planHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 2,
},
proBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 6,
},
proBadgeText: {
color: '#fff',
fontSize: 10,
fontWeight: '800',
},
proBenefits: {
marginTop: 12,
gap: 6,
},
benefitRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 12,
fontWeight: '500',
},
cancelFlowContainer: {
marginTop: 8,
},
cancelHint: {
fontSize: 15,
marginBottom: 16,
},
reasonList: {
gap: 12,
},
reasonOption: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderWidth: 1,
borderRadius: 12,
},
reasonIcon: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
reasonText: {
flex: 1,
fontSize: 16,
fontWeight: '500',
},
offerCard: {
borderRadius: 16,
padding: 24,
alignItems: 'center',
marginBottom: 16,
},
offerIconWrap: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
offerText: {
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
marginBottom: 24,
fontWeight: '500',
},
offerAcceptBtn: {
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 24,
width: '100%',
alignItems: 'center',
},
offerAcceptBtnText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
offerDeclineBtn: {
paddingVertical: 12,
alignItems: 'center',
},
offerDeclineBtnText: {
fontSize: 15,
fontWeight: '500',
},
});