import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, Platform, useWindowDimensions } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { useFocusEffect } from '@react-navigation/native'; import Constants from 'expo-constants'; import Purchases, { LOG_LEVEL, PACKAGE_TYPE, PRODUCT_CATEGORY, PurchasesOffering, PurchasesPackage, PurchasesStoreProduct, } from 'react-native-purchases'; import { useApp } from '../../context/AppContext'; import { useSafeAnalytics } from '../../services/analytics'; 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; type SubscriptionPackages = Partial>; type TopupProducts = Partial>; type PaywallPlanId = 'monthly' | 'yearly'; const TOPUP_CREDITS_BY_PRODUCT: Record = { topup_small: 30, topup_medium: 100, topup_large: 250, }; const isTopupProductId = (productId: PurchaseProductId): productId is TopupProductId => ( productId === 'topup_small' || productId === 'topup_medium' || productId === 'topup_large' ); 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> }; } return { identifier: offering.identifier, packages: offering.availablePackages.map((pkg) => ({ identifier: pkg.identifier, packageType: pkg.packageType, productIdentifier: pkg.product.identifier, priceString: pkg.product.priceString, })), }; }; 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 { 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.', startTrial: '7 Tage kostenlos testen', expoGoPurchaseTitle: 'Kauf nur im Dev Build', expoGoPurchaseMessage: 'Expo Go kann keine Apple- oder RevenueCat-Kaufmaske anzeigen. Im Development Build oder TestFlight erscheint hier der echte 7-Tage-Trial. Fuer lokale Tests kannst du Pro simulieren.', expoGoSimulate: 'Pro simulieren', continueWithoutPro: 'Ohne Pro fortfahren', freePlanName: 'Free', freePlanPrice: '0 EUR / Monat', proPlanName: 'Pro', proPlanPrice: '4,99 € / Monat', // Nackter Betrag: planCardPriceTrial/-Monthly haengen die Periode selbst an. proPlanPriceBare: '4,99 €', proYearlyPlanPriceBare: '39,99 €', proBadgeText: 'EMPFOHLEN', proYearlyPlanName: 'Pro', proYearlyPlanPrice: '39,99 € / Jahr', proYearlyBadgeText: 'SPAREN', proBenefits: [ '100 Scans und Nachfragen jeden Monat', 'Ursachen-Ranking mit Angabe, wie sicher wir sind', 'Konkrete Prüfschritte statt allgemeiner Pflegetipps', '7-Tage-Plan pro Problem, plus Nachfrage danach', 'Komplette Historie & Galerie', 'Priorisierter Support' ], topupTitle: 'Credits Aufladen', topupHint: 'Für aktive Pro-Nutzer, wenn die Monatscredits nicht reichen.', topupSmall: '30 Credits - 2,99 €', topupMedium: '100 Credits - 6,99 €', topupLarge: '250 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', pauseTitle: 'Dann pausier es doch einfach.', pauseText: 'Wir setzen dein Abo für 3 Monate aus. Deine Pflanzen, deine Historie und deine Pläne bleiben erhalten - es wird nichts abgebucht.', pauseAccept: '3 Monate pausieren', pauseDecline: 'Nein, trotzdem kündigen', 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', paywallEyebrow: 'GreenLens Pro', // "Unbegrenzt" ist ersatzlos gestrichen: es stand im direkten // Widerspruch zu "100 Credits/Monat" drei Zeilen weiter unten. paywallHeadline: 'Nicht mehr raten, was deiner Pflanze fehlt.', // Kurz gehalten: die Details stehen direkt darunter in den Bullets. // Sub und Bullets doppelt zu bespielen kostet Aufmerksamkeit und // liest sich, als traute man dem eigenen Argument nicht. paywallSub: 'Ursache, Prüfschritte, Sofortmaßnahme - und ein Check nach 7 Tagen.', planCardTitle: 'GreenLens Pro', // "Gesundheitsdiagnose" -> "Ursachen-Analyse": eine Diagnose koennen // wir nicht versprechen, eine gewichtete Ursachenliste schon. planCardBody: '100 Scans & Nachfragen pro Monat', paywallBullets: [ 'Ursachen-Ranking - inklusive dem, was unsicher ist', 'Konkrete Prüfschritte statt allgemeiner Pflegetipps', '7-Tage-Plan, plus Nachfrage, ob es wirkt', 'Komplette Historie deiner Pflanzen', ], // Belief 5 explizit: der Vergleich gegen die kostenlose Alternative // gehoert an die Stelle, an der die Kaufentscheidung faellt. paywallCompare: 'Kostenlose Tools sagen dir, wie deine Pflanze heißt. Sie sagen dir nicht, was zu prüfen ist, was zu tun ist oder ob es gewirkt hat.', badgeMostPopular: 'BELIEBT', badgeFlexible: 'FLEXIBEL', planYearlyName: 'Jährlich - 7 Tage gratis', planMonthlyName: 'Monatlich', saveBadge: '33 % SPAREN', perYear: '/ Jahr', perMonth: '/ Monat', breaksDownTo: 'Macht nur', // Nur der Wert, kein ganzer Satz: das Label links sagt bereits // "Macht nur" - der Satz "entspricht X pro Monat" doppelte das und // lief aus der Zeile. equivalentValue: (price: string) => `${price} / Monat`, faqQ1: '"Kostenlose Tools gibt es doch."', faqQ2: '"Eine andere App lag selbstbewusst falsch."', faqA2: 'Wir zeigen dir zu jeder Ursache, wie sicher wir sind. Unter 65 % sagen wir das deutlich - und nennen dir die Prüfschritte, mit denen du es selbst bestätigst oder ausschließt.', faqQ3: '"Und wenn es nicht hilft?"', faqA3: 'Nach 7 Tagen fragen wir nach. Wird es nicht besser, passen wir den Plan an. Kündigen geht in 2 Taps über die iOS-Einstellungen - wir fragen kurz nach dem Grund, das war’s.', faqScope: 'Enthalten: 100 Scans und Nachfragen pro Monat, komplette Historie deiner Pflanzen.', altMonthly: (price: string) => `oder ${price} monatlich, ohne Testphase`, altYearly: (price: string) => `oder ${price} jährlich - mit 7 Tagen gratis`, dueSummary: (today: string, later: string, date: string) => `Heute fällig: ${today} - dann ${later} am ${date}`, detailsToggle: 'Fragen, die du jetzt vielleicht hast', trialReminder: 'Wir erinnern dich 2 Tage vor Ablauf der Testphase.', cancelPath: 'Kündigen in 2 Taps über die iOS-Einstellungen - Anleitung findest du in der App.', planCardPriceTrial: (price: string) => `7 Tage gratis, dann ${price}/Jahr`, planCardPriceMonthly: (price: string) => `${price}/Monat`, trialToggleLabel: '7 Tage gratis testen', dueTodayTrial: 'Fällig heute - 7 Tage gratis', dueTodayAmount: '0,00 €', dueLater: (date: string) => `Fällig am ${date}`, ctaTrial: 'Gratis testen', ctaMonthly: 'Jetzt starten', cancelAnytime: 'Jederzeit kündbar', }; } 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.', startTrial: 'Probar 7 dias gratis', expoGoPurchaseTitle: 'Compra solo en Dev Build', expoGoPurchaseMessage: 'Expo Go no puede mostrar la compra nativa de Apple o RevenueCat. En Development Build o TestFlight aparecera el trial real de 7 dias. Para pruebas locales puedes simular Pro.', expoGoSimulate: 'Simular Pro', continueWithoutPro: 'Continuar sin Pro', freePlanName: 'Gratis', freePlanPrice: '0 EUR / Mes', proPlanName: 'Pro', proPlanPrice: '4.99 EUR / Mes', proPlanPriceBare: '4,99 €', proYearlyPlanPriceBare: '39,99 €', proBadgeText: 'RECOMENDADO', proYearlyPlanName: 'Pro', proYearlyPlanPrice: '39.99 EUR / Año', proYearlyBadgeText: 'AHORRAR', proBenefits: [ '100 escaneos y consultas cada mes', 'Ranking de causas indicando qué tan seguros estamos', 'Pasos concretos de revisión, no consejos genéricos', 'Plan de 7 días por problema, más el seguimiento', 'Historial y galería completos', 'Soporte prioritario' ], topupTitle: 'Recargar Créditos', topupHint: 'Para usuarios Pro activos cuando los créditos mensuales no alcanzan.', topupSmall: '30 Créditos - 2,99 €', topupMedium: '100 Créditos - 6,99 €', topupLarge: '250 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', pauseTitle: 'Entonces mejor ponlo en pausa.', pauseText: 'Suspendemos tu suscripción 3 meses. Tus plantas, tu historial y tus planes se conservan - no se cobra nada.', pauseAccept: 'Pausar 3 meses', pauseDecline: 'No, cancelar igualmente', 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', paywallEyebrow: 'GreenLens Pro', paywallHeadline: 'Deja de adivinar qué le pasa a tu planta.', paywallSub: 'Causa, pasos de revisión, acción inmediata - y una comprobación a los 7 días.', planCardTitle: 'GreenLens Pro', planCardBody: '100 escaneos y consultas al mes', paywallBullets: [ 'Ranking de causas - incluyendo lo que es incierto', 'Pasos concretos de revisión, no consejos genéricos', 'Plan de 7 días, más la pregunta de si funciona', 'Historial completo de tus plantas', ], paywallCompare: 'Las herramientas gratuitas te dicen cómo se llama tu planta. No te dicen qué revisar, qué hacer ni si funcionó.', badgeMostPopular: 'POPULAR', badgeFlexible: 'FLEXIBLE', planYearlyName: 'Anual - 7 días gratis', planMonthlyName: 'Mensual', saveBadge: 'AHORRA 33 %', perYear: '/ año', perMonth: '/ mes', breaksDownTo: 'Sale a solo', equivalentValue: (price: string) => `${price} / mes`, faqQ1: '"Pero hay herramientas gratuitas."', faqQ2: '"Otra app se equivocó con mucha seguridad."', faqA2: 'Te mostramos qué tan seguros estamos de cada causa. Por debajo del 65 % te lo decimos claramente, y te damos los pasos de revisión para confirmarlo o descartarlo tú mismo.', faqQ3: '"¿Y si no funciona?"', faqA3: 'A los 7 días preguntamos. Si no mejora, ajustamos el plan. Cancelar son 2 toques desde los ajustes de iOS - te preguntamos el motivo, y ya está.', faqScope: 'Incluye: 100 escaneos y consultas al mes, historial completo de tus plantas.', altMonthly: (price: string) => `o ${price} al mes, sin prueba gratuita`, altYearly: (price: string) => `o ${price} al año - con 7 días gratis`, dueSummary: (today: string, later: string, date: string) => `Hoy pagas: ${today} - luego ${later} el ${date}`, detailsToggle: 'Preguntas que quizá tengas ahora', trialReminder: 'Te avisamos 2 días antes de que termine la prueba.', cancelPath: 'Cancela en 2 toques desde los Ajustes de iOS - te explicamos cómo en la app.', planCardPriceTrial: (price: string) => `7 días gratis, luego ${price}/año`, planCardPriceMonthly: (price: string) => `${price}/mes`, trialToggleLabel: 'Probar 7 días gratis', dueTodayTrial: 'Hoy - 7 días gratis', dueTodayAmount: '0,00 €', dueLater: (date: string) => `El ${date}`, ctaTrial: 'Probar gratis', ctaMonthly: 'Empezar ahora', cancelAnytime: 'Cancela cuando quieras', }; } 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.', startTrial: 'Start 7-day free trial', expoGoPurchaseTitle: 'Purchase requires a dev build', expoGoPurchaseMessage: 'Expo Go cannot show the native Apple or RevenueCat purchase sheet. In a Development Build or TestFlight this opens the real 7-day trial. For local testing you can simulate Pro.', expoGoSimulate: 'Simulate Pro', continueWithoutPro: 'Continue without Pro', freePlanName: 'Free', freePlanPrice: '0 EUR / Month', proPlanName: 'Pro', proPlanPrice: '4.99 EUR / Month', proPlanPriceBare: '€4.99', proYearlyPlanPriceBare: '€39.99', proBadgeText: 'RECOMMENDED', proYearlyPlanName: 'Pro', proYearlyPlanPrice: '39.99 EUR / Year', proYearlyBadgeText: 'SAVE', proBenefits: [ '100 scans and follow-ups every month', 'A ranking of causes, with how confident we are', 'Concrete things to check, not generic care tips', 'A 7-day plan per problem, plus the follow-up', 'Complete history & gallery', 'Priority support' ], topupTitle: 'Topup Credits', topupHint: 'For active Pro users when monthly credits are not enough.', topupSmall: '30 Credits - €2.99', topupMedium: '100 Credits - €6.99', topupLarge: '250 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', pauseTitle: 'Then just pause it.', pauseText: 'We put your subscription on hold for 3 months. Your plants, history and plans stay - nothing gets charged.', pauseAccept: 'Pause for 3 months', pauseDecline: 'No, cancel anyway', 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', paywallEyebrow: 'GreenLens Pro', paywallHeadline: 'Stop guessing what your plant needs.', paywallSub: 'Cause, what to check, what to do now - and a follow-up after 7 days.', planCardTitle: 'GreenLens Pro', planCardBody: '100 scans & follow-ups per month', paywallBullets: [ 'A ranking of causes - including what is uncertain', 'Concrete things to check, not generic care tips', 'A 7-day plan, plus a follow-up on whether it works', 'Your complete plant history', ], paywallCompare: 'Free tools tell you what your plant is called. They do not tell you what to check, what to do, or whether it worked.', badgeMostPopular: 'MOST POPULAR', badgeFlexible: 'FLEXIBLE', planYearlyName: 'Yearly - 7 days free', planMonthlyName: 'Monthly', saveBadge: 'SAVE 33%', perYear: '/ year', perMonth: '/ month', breaksDownTo: 'Breaks down to just', equivalentValue: (price: string) => `${price} / month`, faqQ1: '"But free tools exist."', faqQ2: '"Another app was confidently wrong."', faqA2: 'We show you how confident we are for every cause. Below 65% we say so plainly - and give you the checks to confirm or rule it out yourself.', faqQ3: '"What if it does not help?"', faqA3: 'After 7 days we ask. If it is not improving, we adjust the plan. Cancelling takes 2 taps in iOS settings - we ask why, that is it.', faqScope: 'Included: 100 scans and follow-ups per month, your complete plant history.', altMonthly: (price: string) => `or ${price} monthly, without trial`, altYearly: (price: string) => `or ${price} yearly - with 7 days free`, dueSummary: (today: string, later: string, date: string) => `Due today: ${today} - then ${later} on ${date}`, detailsToggle: 'Questions you might have right now', trialReminder: 'We remind you 2 days before the trial ends.', cancelPath: 'Cancel in 2 taps via iOS Settings - we show you how inside the app.', planCardPriceTrial: (price: string) => `Free for 7 days, then ${price}/year`, planCardPriceMonthly: (price: string) => `${price}/month`, trialToggleLabel: 'Try 7 days free', dueTodayTrial: 'Due today - 7 days free', dueTodayAmount: '€0.00', dueLater: (date: string) => `Due ${date}`, ctaTrial: 'Try Free', ctaMonthly: 'Start Now', cancelAnytime: 'Cancel Anytime', }; }; export default function BillingScreen() { const router = useRouter(); const { height } = useWindowDimensions(); const compact = height < 700; const params = useLocalSearchParams<{ view?: string; context?: string }>(); const paywallRequested = params.view === 'paywall'; const onboardingContext = params.context === 'onboarding'; const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session, hasCompletedOnboarding, markOnboardingCompleted } = useApp(); const colors = useColors(isDarkMode, colorPalette); const posthog = useSafeAnalytics(); 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 [storeError, setStoreError] = useState(null); const [subscriptionPackages, setSubscriptionPackages] = useState({}); const [topupProducts, setTopupProducts] = useState({}); // Monthly is the default; the 7-day trial (yearly plan) must be opted into via the toggle. const [selectedPaywallPlan, setSelectedPaywallPlan] = useState('monthly'); // Cancel Flow State const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); const [cancelReason, setCancelReason] = useState(null); // Argumente eingeklappt: die Paywall passt so ohne Scrollen auf einen Screen. // Wer mehr wissen will, klappt auf - wer kaufen will, wird nicht aufgehalten. const [detailsOpen, setDetailsOpen] = useState(false); // Wer die App zu selten nutzt, hat kein Preisproblem - ein Rabatt geht dort // ins Leere und verschenkt Marge. Pause rettet die Beziehung stattdessen. const isPauseOffer = cancelReason === 'not_using'; const planId = billingSummary?.entitlement?.plan || 'free'; const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0); const showPaywallPlans = (!session || paywallRequested) && (!isLoadingBilling || !session) && planId !== 'pro'; useEffect(() => { let cancelled = false; const loadStoreProducts = async () => { if (isExpoGo) { setStoreReady(true); return; } try { ensureRevenueCatConfigured(); 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'), }); 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); } } }; loadStoreProducts(); return () => { cancelled = true; }; }, [isExpoGo]); const trialEnabled = selectedPaywallPlan === 'yearly'; useEffect(() => { try { posthog.capture('paywall_viewed', { plan_id: planId, context: onboardingContext ? 'onboarding' : 'in_app', trial_enabled: trialEnabled, }); } catch {} if (showPaywallPlans) { try { posthog.capture('hard_paywall_viewed', { plan_id: planId, authenticated: Boolean(session), }); } catch {} } }, [posthog, planId, session?.serverUserId, showPaywallPlans, onboardingContext, trialEnabled]); const monthlyPackage = subscriptionPackages.monthly_pro; const yearlyPackage = subscriptionPackages.yearly_pro; // Fallback ohne Periode - sonst entsteht "4.99 EUR / Month/month". const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPriceBare; const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPriceBare; const trialEndDate = useMemo(() => { const date = new Date(); date.setDate(date.getDate() + 7); const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); }, [language]); const topupLabels = useMemo(() => ({ topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, topup_medium: topupProducts.topup_medium ? `${TOPUP_CREDITS_BY_PRODUCT.topup_medium} Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, topup_large: topupProducts.topup_large ? `${TOPUP_CREDITS_BY_PRODUCT.topup_large} 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 handleBack = useCallback(() => { if (showPaywallPlans) { posthog.capture('paywall_dismissed', { context: onboardingContext ? 'onboarding' : 'in_app' }); if (onboardingContext) { // Guests continue into the app; paid actions are gated at the point of use. markOnboardingCompleted(); router.replace('/(tabs)'); return; } if (session) { if (router.canGoBack()) router.back(); else router.replace('/(tabs)'); return; } router.replace(hasCompletedOnboarding ? '/(tabs)' : '/onboarding'); return; } if (router.canGoBack()) { router.back(); return; } router.replace('/(tabs)'); }, [router, showPaywallPlans, onboardingContext, session, posthog, hasCompletedOnboarding, markOnboardingCompleted]); const postPurchaseRoute = onboardingContext ? '/auth/signup' : '/(tabs)'; useFocusEffect( useCallback(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { if (!showPaywallPlans) { return false; } handleBack(); return true; }); return () => subscription.remove(); }, [showPaywallPlans, handleBack]), ); const completeExpoGoSimulation = async (productId: PurchaseProductId) => { setIsUpdating(true); try { await simulatePurchase(productId); if (productId === 'monthly_pro' || productId === 'yearly_pro') { posthog.capture('subscription_started', { product_id: productId, simulated: true }); posthog.capture('trial_started', { product_id: productId, simulated: true }); setSubModalVisible(false); router.replace(postPurchaseRoute); } else { posthog.capture('topup_purchased', { product_id: productId, simulated: true }); } } finally { setIsUpdating(false); } }; const handlePurchase = async (productId: PurchaseProductId) => { // Guests can't sync purchases to an account; signed-in free users may // buy top-ups (the server counts topupBalance for free plans too). if (isTopupProductId(productId) && !session) { return; } if (!isExpoGo && storeError) { Alert.alert('Purchases unavailable', storeError); return; } setIsUpdating(true); posthog.capture('purchase_initiated', { product_id: productId }); try { if (isExpoGo) { // ExpoGo has no native RevenueCat - use simulation for development only setIsUpdating(false); if (productId === 'monthly_pro' || productId === 'yearly_pro') { Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [ { text: copy.continueWithoutPro, style: 'cancel' }, { text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) }, ]); return; } await completeExpoGoSimulation(productId); return; } else { ensureRevenueCatConfigured(); 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.'); } const purchaseResult = await Purchases.purchasePackage(selectedPackage); // Apply RevenueCat entitlement locally and let backend sync finish in the background. const customerInfo = (purchaseResult as { customerInfo?: unknown }).customerInfo ?? await Purchases.getCustomerInfo(); void syncRevenueCatState(customerInfo as any, 'subscription_purchase'); posthog.capture('subscription_started', { product_id: productId }); posthog.capture('trial_started', { product_id: productId }); setSubModalVisible(false); setTimeout(() => router.replace(postPurchaseRoute), 0); return; } 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'); } } posthog.capture('topup_purchased', { product_id: productId }); 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) { posthog.capture('purchase_cancelled', { product_id: productId }); posthog.capture('paywall_purchase_cancelled', { product_id: productId }); 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).code : undefined; if (rcErrorCode === 7) { setSubModalVisible(false); return; } console.error('Payment failed', e); posthog.capture('purchase_failed', { product_id: productId, error: msg }); Alert.alert('Unerwarteter Fehler', msg); } finally { setIsUpdating(false); } }; const handleRestore = async () => { setIsUpdating(true); try { if (!isExpoGo) { ensureRevenueCatConfigured(); 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'); setCancelReason(null); setSubModalVisible(false); } catch (e) { console.error('Downgrade failed', e); } finally { setIsUpdating(false); } }; // Jahrespreis -> Monatsaequivalent, mit Waehrungssymbol aus dem Store-Preis. const yearlyMonthlyEquivalent = (() => { const numeric = parseFloat(yearlyPrice.replace(/[^0-9.,]/g, '').replace(',', '.')); if (!Number.isFinite(numeric) || numeric <= 0) return null; const symbol = yearlyPrice.replace(/[0-9.,\s]/g, '') || '€'; const perMonth = (numeric / 12).toFixed(2).replace('.', language === 'en' ? '.' : ','); return `${perMonth} ${symbol}`.trim(); })(); if (showPaywallPlans) { return ( {/* 'bottom' mit drin: sonst liegen Kuendigungshinweis und Privacy/Terms auf dem Home-Indicator. */} {copy.restorePurchases} {copy.paywallEyebrow.toUpperCase()} {copy.paywallHeadline} {copy.paywallSub} {/* Feature-Bullets ueber der Angebotskarte: kurz, drei Stueck, ohne Kaestchen - sie sind Kontext, nicht die Entscheidung. */} {copy.paywallBullets.slice(0, 3).map((bullet) => ( {bullet} ))} {/* EINE Angebotskarte statt Toggle + Preiskarte. Der Plan, der Preis und der Kaufbutton stehen zusammen, die Alternative darunter als Textlink. Damit entfaellt der Switch, der die Preisaenderung nie sichtbar gemacht hat. */} {trialEnabled ? copy.badgeMostPopular : copy.badgeFlexible} {trialEnabled ? copy.planYearlyName : copy.planMonthlyName} {trialEnabled ? ( {copy.saveBadge} ) : null} {trialEnabled ? yearlyPrice : monthlyPrice} {trialEnabled ? copy.perYear : copy.perMonth} {trialEnabled && yearlyMonthlyEquivalent ? ( {copy.breaksDownTo} {copy.equivalentValue(yearlyMonthlyEquivalent)} ) : null} handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')} disabled={isUpdating || !storeReady || Boolean(storeError)} activeOpacity={0.86} > {isUpdating || !storeReady ? ( ) : ( <> {trialEnabled ? copy.ctaTrial : copy.ctaMonthly} )} {/* Planwechsel als Textlink - kein Schalter, sondern eine sichtbar formulierte Alternative inkl. Preis. */} setSelectedPaywallPlan(trialEnabled ? 'monthly' : 'yearly')} style={styles.offerAltLink} > {trialEnabled ? copy.altMonthly(monthlyPrice) : copy.altYearly(yearlyPrice)} {trialEnabled ? ( {copy.dueSummary(copy.dueTodayAmount, yearlyPrice, trialEndDate)} ) : null} {/* Aufklappbar: eingeklappt bleibt die Kaufentscheidung ueber der Falz, aufgeklappt stehen die Argumente vollstaendig da. Beides ohne zweiten Screen. */} { setDetailsOpen((open) => { if (!open) posthog.capture('paywall_details_expanded'); return !open; }); }} activeOpacity={0.8} > {copy.detailsToggle} {detailsOpen ? ( /* Bewusst KEINE Feature-Wiederholung: die drei wichtigsten Punkte stehen bereits oben. Hier stehen stattdessen die drei dokumentierten Einwaende aus dem Offer Brief - Gratis-Alternativen, selbstbewusst falsche Konkurrenz-Apps, und was passiert, wenn es nicht wirkt. */ {copy.faqQ1} {copy.paywallCompare} {copy.faqQ2} {copy.faqA2} {copy.faqQ3} {copy.faqA3} {copy.faqScope} ) : null} {/* Nur noch Rechtstexte: der Kaufbutton sitzt in der Angebotskarte, direkt neben Plan und Preis. Ein zweiter CTA in einer Leiste waere eine doppelte Aufforderung zur selben Handlung. */} {storeError ? ( {storeError} ) : null} {/* Belief 6: Kuendigung und Verlaengerung stehen sichtbar vor der Entscheidung, nicht im Kleingedruckten. */} {trialEnabled ? `${copy.cancelAnytime} · ${copy.trialReminder}` : copy.cancelAnytime} {copy.cancelPath} Linking.openURL('https://greenlenspro.com/privacy')}> Privacy | Linking.openURL('https://greenlenspro.com/terms')}> Terms ); } return ( {copy.title} {isLoadingBilling && session ? ( ) : ( <> {session && ( {copy.planLabel} {planId === 'pro' ? copy.planPro : copy.planFree} {planId === 'pro' && ( setSubModalVisible(true)} > {copy.manageSubscription} )} {copy.creditsAvailableLabel} {credits} {planId !== 'pro' && ( router.push('/profile/billing?view=paywall')} activeOpacity={0.86} > {copy.startTrial} )} )} {session && !isExpoGo ? ( {copy.topupTitle} {copy.topupHint} {([ { 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) => ( handlePurchase(pack.id)} disabled={isUpdating || !storeReady || Boolean(storeError)} > {isUpdating ? '...' : pack.label} {pack.badge && ( {pack.badge} )} ))} Linking.openURL('https://greenlenspro.com/privacy')}> Privacy Policy · Linking.openURL('https://greenlenspro.com/terms')}> Terms of Use {copy.restorePurchases} ) : null} )} setSubModalVisible(false)}> {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? (isPauseOffer ? copy.pauseTitle : copy.offerTitle) : copy.subscriptionTitle} { setSubModalVisible(false); setCancelStep('none'); setCancelReason(null); }}> {cancelStep === 'none' ? ( <> {copy.subscriptionHint} {copy.freePlanName} {copy.freePlanPrice} {planId === 'free' && } handlePurchase('monthly_pro')} disabled={isUpdating || !storeReady || Boolean(storeError)} > {copy.proPlanName} {copy.proBadgeText} {monthlyPrice} {copy.autoRenewMonthly} {copy.proBenefits.map((b, i) => ( {b} ))} {planId === 'pro' && } handlePurchase('yearly_pro')} disabled={isUpdating || !storeReady || Boolean(storeError)} > {copy.proYearlyPlanName} {copy.proYearlyBadgeText} {yearlyPrice} {copy.autoRenewYearly} {copy.proBenefits.map((b, i) => ( {b} ))} {planId === 'pro' && } Linking.openURL('https://greenlenspro.com/privacy')}> Privacy Policy · Linking.openURL('https://greenlenspro.com/terms')}> Terms of Use {copy.restorePurchases} ) : cancelStep === 'survey' ? ( {copy.cancelQuestion} {[ { 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) => ( { // Grund merken: ein Rabatt hilft nur bei "zu teuer". // Wer zu selten nutzt, hat kein Preisproblem. setCancelReason(reason.id); setCancelStep('offer'); }} > {reason.label} ))} ) : ( {isPauseOffer ? copy.pauseText : copy.offerText} { posthog.capture('cancel_save_offer_accepted', { reason: cancelReason, offer: isPauseOffer ? 'pause_3_months' : 'discount_50', }); // TODO(billing): Pause bzw. Rabatt am Store-Abo umsetzen. Alert.alert('OK', isPauseOffer ? 'Abo pausiert (Mock)' : 'Rabatt angewendet (Mock)'); setCancelStep('none'); setCancelReason(null); setSubModalVisible(false); }} > {isPauseOffer ? copy.pauseAccept : copy.offerAccept} {isPauseOffer ? copy.pauseDecline : copy.offerDecline} )} {(isUpdating || (!storeReady && cancelStep === 'none')) && } ); } const styles = StyleSheet.create({ hardPaywallScreen: { flex: 1, backgroundColor: '#101411', }, hardPaywallSafe: { flex: 1, }, heroTopBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingTop: 4, }, heroIconButton: { width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center', backgroundColor: '#00000066', }, heroRestoreText: { color: '#FFFFFF', fontSize: 13, fontWeight: '700', textShadowColor: '#00000066', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 3, }, hardPaywallPlain: { flex: 1 }, paywallSheet: { flex: 1, overflow: 'hidden', zIndex: 5, }, paywallScroll: { flex: 1 }, paywallBody: { paddingHorizontal: 22, paddingTop: 10, paddingBottom: 16 }, paywallEyebrow: { fontSize: 12, fontWeight: '900', letterSpacing: 1.4, textAlign: 'center', marginBottom: 6 }, paywallHeadline: { fontSize: 32, fontWeight: '900', textAlign: 'center', marginBottom: 6 }, paywallSub: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18 }, // Bullets ueber der Karte: reiner Kontext, bewusst ohne eigenen Container. offerBullets: { gap: 12, marginBottom: 22, paddingHorizontal: 4 }, offerBulletRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 11 }, offerBulletText: { flex: 1, fontSize: 14.5, lineHeight: 20, fontWeight: '500' }, // Angebotskarte. Der Glow ist bewusst zurueckhaltend: opacity 0.18 statt // eines Leuchtrahmens, damit die Karte sich abhebt ohne zu schreien. offerCardOuter: { borderRadius: 22, borderWidth: 1.5, paddingHorizontal: 18, paddingTop: 26, paddingBottom: 16, marginBottom: 16, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.42, shadowRadius: 26, elevation: 10, }, offerBadge: { position: 'absolute', top: -12, alignSelf: 'center', left: 0, right: 0, marginHorizontal: 'auto', paddingHorizontal: 14, paddingVertical: 5, borderRadius: 999, maxWidth: 160, }, offerBadgeText: { fontSize: 11, fontWeight: '900', letterSpacing: 0.8, textAlign: 'center' }, offerHeaderRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }, offerHeaderLeft: { flex: 1, gap: 3 }, offerHeaderRight: { alignItems: 'flex-end' }, offerPlanName: { fontSize: 17, fontWeight: '800', lineHeight: 22 }, offerSaveText: { fontSize: 12.5, fontWeight: '800', letterSpacing: 0.4 }, offerPrice: { fontSize: 25, fontWeight: '900', letterSpacing: -0.4 }, offerPricePeriod: { fontSize: 12.5, fontWeight: '600' }, offerBreakdown: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 12, paddingHorizontal: 14, paddingVertical: 11, marginTop: 16, gap: 10, }, offerBreakdownLabel: { fontSize: 13.5, fontWeight: '600' }, offerBreakdownValue: { fontSize: 15, fontWeight: '800' }, offerCta: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 9, // minHeight statt height: der Kauf-Button darf mit der Systemschrift // wachsen, statt sein Label abzuschneiden. minHeight: 56, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 14, marginTop: 16, }, offerCtaText: { fontSize: 17.5, fontWeight: '800', textAlign: 'center' }, offerAltLink: { alignItems: 'center', paddingVertical: 13 }, offerAltLinkText: { fontSize: 13.5, fontWeight: '600', textDecorationLine: 'underline' }, dueBanner: { flexDirection: 'row', alignItems: 'center', gap: 9, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, marginBottom: 16, }, dueBannerText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '600' }, detailsToggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 14, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 13, marginBottom: 14 }, detailsToggleLabel: { fontSize: 14.5, fontWeight: '700' }, faqBlock: { gap: 16, marginBottom: 18, paddingHorizontal: 2 }, faqItem: { gap: 5 }, faqQuestion: { fontSize: 14.5, fontWeight: '800', fontStyle: 'italic' }, faqAnswer: { fontSize: 13.5, lineHeight: 19, fontWeight: '500' }, faqScope: { fontSize: 12.5, lineHeight: 17, fontWeight: '600', marginTop: 2 }, paywallCompare: { borderLeftWidth: 3, paddingLeft: 12, paddingVertical: 4, marginBottom: 16 }, paywallCompareText: { fontSize: 13.5, lineHeight: 19, fontWeight: '500' }, paywallBullets: { gap: 10, marginBottom: 18, paddingHorizontal: 2 }, paywallBulletRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 }, paywallBulletText: { flex: 1, fontSize: 14, lineHeight: 19, fontWeight: '600' }, // Der Preis war mit 15px kleiner als der Fliesstext. Bei einem Avatar, dessen // Kernangst Abo-Intransparenz ist, muss der Preis das Auffaelligste im Block sein. paywallActionBar: { paddingHorizontal: 22, paddingTop: 14, paddingBottom: 18, borderTopWidth: 1, gap: 6 }, paywallFooterLinks: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }, paywallFooterText: { fontSize: 12, fontWeight: '600' }, paywallFooterCentered: { textAlign: 'center' }, 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', }, upgradeCta: { marginTop: 16, height: 50, borderRadius: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, }, upgradeCtaText: { fontSize: 16, fontWeight: '800', }, 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', }, disabledPlanCard: { opacity: 0.72, }, 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, }, });