985 lines
47 KiB
TypeScript
985 lines
47 KiB
TypeScript
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 € / Monat',
|
||
proBadgeText: 'EMPFOHLEN',
|
||
proYearlyPlanName: 'Pro',
|
||
proYearlyPlanPrice: '39,99 € / 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,
|
||
},
|
||
});
|