Files
Greenlens/app/profile/billing.tsx

985 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Constants from 'expo-constants';
import Purchases, {
PACKAGE_TYPE,
PRODUCT_CATEGORY,
PurchasesOffering,
PurchasesPackage,
PurchasesStoreProduct,
} from 'react-native-purchases';
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';
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
const isMatchingPackage = (
pkg: PurchasesPackage,
productId: SubscriptionProductId,
expectedPackageType: PACKAGE_TYPE,
) => {
return (
pkg.product.identifier === productId
|| pkg.identifier === productId
|| pkg.packageType === expectedPackageType
);
};
const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => {
if (!offering) {
return {};
}
const availablePackages = [
offering.monthly,
offering.annual,
...offering.availablePackages,
].filter((value): value is PurchasesPackage => Boolean(value));
return {
monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)),
yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)),
};
};
const summarizeOfferingPackages = (offering: PurchasesOffering | null) => {
if (!offering) {
return { identifier: null, packages: [] as Array<Record<string, string | null>> };
}
return {
identifier: offering.identifier,
packages: offering.availablePackages.map((pkg) => ({
identifier: pkg.identifier,
packageType: pkg.packageType,
productIdentifier: pkg.product.identifier,
priceString: pkg.product.priceString,
})),
};
};
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',
restorePurchases: 'Käufe wiederherstellen',
autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.',
autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.',
manageInSettings: 'In iOS-Einstellungen verwalten',
};
} 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',
restorePurchases: 'Restaurar Compras',
autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.',
autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.',
manageInSettings: 'Administrar en Ajustes de iOS',
};
}
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',
restorePurchases: 'Restore Purchases',
autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.',
autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.',
manageInSettings: 'Manage in iOS Settings',
};
};
export default function BillingScreen() {
const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const copy = getBillingCopy(language);
const isExpoGo = Constants.appOwnership === 'expo';
const [subModalVisible, setSubModalVisible] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [storeReady, setStoreReady] = useState(isExpoGo);
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
// Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const planId = billingSummary?.entitlement?.plan || 'free';
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
useEffect(() => {
let cancelled = false;
const loadStoreProducts = async () => {
if (isExpoGo) {
setStoreReady(true);
return;
}
try {
const [offerings, topups] = await Promise.all([
Purchases.getOfferings(),
Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION),
]);
if (cancelled) return;
const currentOffering = offerings.current;
const resolvedPackages = resolveSubscriptionPackages(currentOffering);
if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) {
console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering));
}
setSubscriptionPackages(resolvedPackages);
setTopupProducts({
topup_small: topups.find((product) => product.identifier === 'topup_small'),
topup_medium: topups.find((product) => product.identifier === 'topup_medium'),
topup_large: topups.find((product) => product.identifier === 'topup_large'),
});
} catch (error) {
console.warn('Failed to load RevenueCat products', error);
} finally {
if (!cancelled) {
setStoreReady(true);
}
}
};
loadStoreProducts();
return () => {
cancelled = true;
};
}, [isExpoGo]);
const monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro;
const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice;
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
const topupLabels = useMemo(() => ({
topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
const openAppleSubscriptions = async () => {
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
};
const handlePurchase = async (productId: PurchaseProductId) => {
setIsUpdating(true);
try {
if (isExpoGo) {
// ExpoGo has no native RevenueCat — use simulation for development only
await simulatePurchase(productId);
} else {
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (planId === 'pro') {
await openAppleSubscriptions();
setSubModalVisible(false);
return;
}
const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage;
const latestOffering = !selectedPackage
? await Purchases.getOfferings().then((offerings) => offerings.current)
: null;
if (!selectedPackage) {
console.warn('[Billing] Purchase blocked because subscription package was not resolved', {
productId,
offering: summarizeOfferingPackages(latestOffering),
});
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');
} else {
const selectedProduct = topupProducts[productId];
if (!selectedProduct) {
throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.');
}
await Purchases.purchaseStoreProduct(selectedProduct);
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
}
}
setSubModalVisible(false);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled);
if (userCancelled) {
return;
}
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already
// owns this subscription on a different GreenLens account. Silently dismiss;
// the current account stays free. The user can restore via "Käufe wiederherstellen".
const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record<string, unknown>).code : undefined;
if (rcErrorCode === 7) {
setSubModalVisible(false);
return;
}
console.error('Payment failed', e);
Alert.alert('Unerwarteter Fehler', msg);
} finally {
setIsUpdating(false);
}
};
const handleRestore = async () => {
setIsUpdating(true);
try {
if (!isExpoGo) {
const customerInfo = await Purchases.restorePurchases();
await syncRevenueCatState(customerInfo as any, 'restore');
}
Alert.alert(copy.restorePurchases, '✓');
} catch (e) {
Alert.alert('Error', e instanceof Error ? e.message : String(e));
} finally {
setIsUpdating(false);
}
};
const handleDowngrade = async () => {
if (planId === 'free') return;
if (!isExpoGo) {
await openAppleSubscriptions();
return;
}
// Expo Go / dev only: simulate cancel flow
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 && session ? (
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
) : (
<>
{session && (
<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>
)}
{!session && (
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Subscription Plans</Text>
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 16 }]}>
Choose a plan to unlock AI plant scans and care features.
</Text>
{/* Monthly */}
<View style={[styles.guestPlanCard, { borderColor: colors.primary, backgroundColor: colors.primary + '10' }]}>
<View style={styles.guestPlanHeader}>
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
<Text style={styles.proBadgeText}>MONTHLY</Text>
</View>
</View>
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text>
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.autoRenewMonthly}</Text>
<View style={{ gap: 4, marginTop: 8 }}>
{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>
<TouchableOpacity
style={[styles.guestSubscribeBtn, { backgroundColor: colors.primary }]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady}
>
<Text style={styles.manageBtnText}>Subscribe Monthly</Text>
</TouchableOpacity>
</View>
{/* Yearly */}
<View style={[styles.guestPlanCard, { borderColor: colors.border, marginTop: 12 }]}>
<View style={styles.guestPlanHeader}>
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
<Text style={styles.proBadgeText}>YEARLY</Text>
</View>
</View>
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{yearlyPrice}</Text>
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.autoRenewYearly}</Text>
<View style={{ gap: 4, marginTop: 8 }}>
{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>
<TouchableOpacity
style={[styles.guestSubscribeBtn, { backgroundColor: colors.primary }]}
onPress={() => handlePurchase('yearly_pro')}
disabled={isUpdating || !storeReady}
>
<Text style={styles.manageBtnText}>Subscribe Yearly</Text>
</TouchableOpacity>
</View>
<View style={[styles.legalLinksRow, { marginTop: 16 }]}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.restoreBtn} onPress={handleRestore} disabled={isUpdating}>
<Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text>
</TouchableOpacity>
</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: topupLabels.topup_small },
{ id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue },
{ id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large },
] 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 || !storeReady}
>
<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 style={[styles.legalLinksRow, { marginTop: 12 }]}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.restoreBtn} onPress={handleRestore} disabled={isUpdating}>
<Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text>
</TouchableOpacity>
</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 || !storeReady}
>
<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' }]}>{monthlyPrice}</Text>
<Text style={[styles.autoRenewText, { color: colors.textMuted }]}>{copy.autoRenewMonthly}</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 || !storeReady}
>
<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' }]}>{yearlyPrice}</Text>
<Text style={[styles.autoRenewText, { color: colors.textMuted }]}>{copy.autoRenewYearly}</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>
<View style={styles.legalLinksRow}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.restoreBtn} onPress={handleRestore} disabled={isUpdating}>
<Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text>
</TouchableOpacity>
</>
) : 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={() => {
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 || !storeReady}
>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>{copy.offerDecline}</Text>
</TouchableOpacity>
</View>
)}
{(isUpdating || (!storeReady && 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',
},
guestPlanCard: {
borderWidth: 2,
borderRadius: 12,
padding: 16,
},
guestPlanHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 4,
},
guestPlanName: {
fontSize: 18,
fontWeight: '700',
},
guestPlanPrice: {
fontSize: 22,
fontWeight: '700',
marginBottom: 2,
},
guestPlanRenew: {
fontSize: 12,
},
guestSubscribeBtn: {
marginTop: 14,
paddingVertical: 12,
borderRadius: 10,
alignItems: 'center',
},
legalLinksRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginTop: 16,
},
legalLink: {
fontSize: 12,
fontWeight: '500',
textDecorationLine: 'underline',
},
legalSep: {
fontSize: 12,
},
restoreBtn: {
alignItems: 'center',
paddingVertical: 8,
},
autoRenewText: {
fontSize: 11,
marginTop: 2,
marginBottom: 4,
},
});