Files
Greenlens/app/profile/billing.tsx
2026-07-30 10:03:37 +02:00

1650 lines
86 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, { 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<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
type PaywallPlanId = 'monthly' | 'yearly';
const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = {
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<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,
})),
};
};
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 wars.',
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<string | null>(null);
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
// Monthly is the default; the 7-day trial (yearly plan) must be opted into via the toggle.
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('monthly');
// Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const [cancelReason, setCancelReason] = useState<string | null>(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<string, unknown>).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 (
<View style={[styles.hardPaywallScreen, { backgroundColor: colors.background }]}>
<View style={styles.hardPaywallPlain}>
{/* 'bottom' mit drin: sonst liegen Kuendigungshinweis und
Privacy/Terms auf dem Home-Indicator. */}
<SafeAreaView style={styles.hardPaywallSafe} edges={['top', 'bottom']}>
<View style={styles.heroTopBar}>
<TouchableOpacity onPress={handleBack} style={[styles.heroIconButton, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity onPress={handleRestore} disabled={isUpdating}>
<Text style={[styles.heroRestoreText, { color: colors.textSecondary }]}>{copy.restorePurchases}</Text>
</TouchableOpacity>
</View>
<View style={[styles.paywallSheet, { backgroundColor: colors.background, marginTop: 0 }]}>
<ScrollView
style={styles.paywallScroll}
contentContainerStyle={styles.paywallBody}
showsVerticalScrollIndicator={false}
>
<Text style={[styles.paywallEyebrow, { color: colors.primary }]}>{copy.paywallEyebrow.toUpperCase()}</Text>
<Text style={[styles.paywallHeadline, { color: colors.text }]} maxFontSizeMultiplier={1.25}>{copy.paywallHeadline}</Text>
<Text style={[styles.paywallSub, { color: colors.textSecondary }]}>{copy.paywallSub}</Text>
{/* Feature-Bullets ueber der Angebotskarte: kurz, drei
Stueck, ohne Kaestchen - sie sind Kontext, nicht die
Entscheidung. */}
<View style={styles.offerBullets}>
{copy.paywallBullets.slice(0, 3).map((bullet) => (
<View key={bullet} style={styles.offerBulletRow}>
<Ionicons name="checkmark-circle-outline" size={19} color={colors.primary} />
<Text style={[styles.offerBulletText, { color: colors.textSecondary }]}>{bullet}</Text>
</View>
))}
</View>
{/* 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. */}
<View
style={[
styles.offerCardOuter,
{
backgroundColor: colors.surfaceMuted,
borderColor: colors.primary,
// Dezenter Glow statt Leuchtrahmen.
shadowColor: colors.primary,
},
]}
>
<View style={[styles.offerBadge, { backgroundColor: colors.primary }]}>
<Text style={[styles.offerBadgeText, { color: colors.onPrimary }]}>
{trialEnabled ? copy.badgeMostPopular : copy.badgeFlexible}
</Text>
</View>
<View style={styles.offerHeaderRow}>
<View style={styles.offerHeaderLeft}>
<Text style={[styles.offerPlanName, { color: colors.text }]}>
{trialEnabled ? copy.planYearlyName : copy.planMonthlyName}
</Text>
{trialEnabled ? (
<Text style={[styles.offerSaveText, { color: colors.primary }]}>{copy.saveBadge}</Text>
) : null}
</View>
<View style={styles.offerHeaderRight}>
<Text style={[styles.offerPrice, { color: colors.text }]}>
{trialEnabled ? yearlyPrice : monthlyPrice}
</Text>
<Text style={[styles.offerPricePeriod, { color: colors.textMuted }]}>
{trialEnabled ? copy.perYear : copy.perMonth}
</Text>
</View>
</View>
{trialEnabled && yearlyMonthlyEquivalent ? (
<View style={[styles.offerBreakdown, { backgroundColor: colors.surface }]}>
<Text style={[styles.offerBreakdownLabel, { color: colors.textSecondary }]}>
{copy.breaksDownTo}
</Text>
<Text
style={[styles.offerBreakdownValue, { color: colors.text }]}
numberOfLines={1}
adjustsFontSizeToFit
>
{copy.equivalentValue(yearlyMonthlyEquivalent)}
</Text>
</View>
) : null}
<TouchableOpacity
style={[
styles.offerCta,
{ backgroundColor: colors.primary },
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.86}
>
{isUpdating || !storeReady ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<>
<Text
style={[styles.offerCtaText, { color: colors.onPrimary }]}
maxFontSizeMultiplier={1.2}
>
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
<Ionicons name="arrow-forward" size={19} color={colors.onPrimary} />
</>
)}
</TouchableOpacity>
{/* Planwechsel als Textlink - kein Schalter, sondern eine
sichtbar formulierte Alternative inkl. Preis. */}
<TouchableOpacity
onPress={() => setSelectedPaywallPlan(trialEnabled ? 'monthly' : 'yearly')}
style={styles.offerAltLink}
>
<Text style={[styles.offerAltLinkText, { color: colors.textSecondary }]}>
{trialEnabled
? copy.altMonthly(monthlyPrice)
: copy.altYearly(yearlyPrice)}
</Text>
</TouchableOpacity>
</View>
{trialEnabled ? (
<View style={[styles.dueBanner, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="calendar-outline" size={16} color={colors.primary} />
<Text style={[styles.dueBannerText, { color: colors.textSecondary }]}>
{copy.dueSummary(copy.dueTodayAmount, yearlyPrice, trialEndDate)}
</Text>
</View>
) : null}
{/* Aufklappbar: eingeklappt bleibt die Kaufentscheidung
ueber der Falz, aufgeklappt stehen die Argumente
vollstaendig da. Beides ohne zweiten Screen. */}
<TouchableOpacity
style={[styles.detailsToggle, { borderColor: colors.border }]}
onPress={() => {
setDetailsOpen((open) => {
if (!open) posthog.capture('paywall_details_expanded');
return !open;
});
}}
activeOpacity={0.8}
>
<Text style={[styles.detailsToggleLabel, { color: colors.text }]}>{copy.detailsToggle}</Text>
<Ionicons
name={detailsOpen ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.textSecondary}
/>
</TouchableOpacity>
{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. */
<View style={styles.faqBlock}>
<View style={styles.faqItem}>
<Text style={[styles.faqQuestion, { color: colors.text }]}>{copy.faqQ1}</Text>
<Text style={[styles.faqAnswer, { color: colors.textSecondary }]}>{copy.paywallCompare}</Text>
</View>
<View style={styles.faqItem}>
<Text style={[styles.faqQuestion, { color: colors.text }]}>{copy.faqQ2}</Text>
<Text style={[styles.faqAnswer, { color: colors.textSecondary }]}>{copy.faqA2}</Text>
</View>
<View style={styles.faqItem}>
<Text style={[styles.faqQuestion, { color: colors.text }]}>{copy.faqQ3}</Text>
<Text style={[styles.faqAnswer, { color: colors.textSecondary }]}>{copy.faqA3}</Text>
</View>
<Text style={[styles.faqScope, { color: colors.textMuted }]}>{copy.faqScope}</Text>
</View>
) : null}
</ScrollView>
{/* 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. */}
<View style={[styles.paywallActionBar, { backgroundColor: colors.background, borderTopColor: colors.border }]}>
{storeError ? (
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.danger, marginBottom: 6 }]}>
{storeError}
</Text>
) : null}
{/* Belief 6: Kuendigung und Verlaengerung stehen sichtbar
vor der Entscheidung, nicht im Kleingedruckten. */}
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}>
{trialEnabled ? `${copy.cancelAnytime} · ${copy.trialReminder}` : copy.cancelAnytime}
</Text>
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}>
{copy.cancelPath}
</Text>
<View style={styles.paywallFooterLinks}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>Privacy</Text>
</TouchableOpacity>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}> | </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>Terms</Text>
</TouchableOpacity>
</View>
</View>
</View>
</SafeAreaView>
</View>
</View>
);
}
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ThemeBackdrop colors={colors} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} 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>
{planId === 'pro' && (
<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>
{planId !== 'pro' && (
<TouchableOpacity
style={[styles.upgradeCta, { backgroundColor: colors.primary }]}
onPress={() => router.push('/profile/billing?view=paywall')}
activeOpacity={0.86}
>
<Ionicons name="sparkles-outline" size={18} color={colors.onPrimary} />
<Text style={[styles.upgradeCtaText, { color: colors.onPrimary }]}>{copy.startTrial}</Text>
</TouchableOpacity>
)}
</View>
)}
{session && !isExpoGo ? (
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text>
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 8 }]}>{copy.topupHint}</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 || Boolean(storeError)}
>
<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>
) : null}
</>
)}
</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' ? (isPauseOffer ? copy.pauseTitle : copy.offerTitle) : copy.subscriptionTitle}
</Text>
<TouchableOpacity onPress={() => {
setSubModalVisible(false);
setCancelStep('none');
setCancelReason(null);
}}>
<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 || Boolean(storeError)}
>
<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 || Boolean(storeError)}
>
<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={() => {
// Grund merken: ein Rabatt hilft nur bei "zu teuer".
// Wer zu selten nutzt, hat kein Preisproblem.
setCancelReason(reason.id);
setCancelStep('offer');
}}
>
<View style={[styles.reasonIcon, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name={reason.icon as any} size={20} color={colors.textSecondary} />
</View>
<Text style={[styles.reasonText, { color: colors.text }]}>{reason.label}</Text>
<Ionicons name="chevron-forward" size={18} color={colors.borderStrong} />
</TouchableOpacity>
))}
</View>
</View>
) : (
<View style={styles.cancelFlowContainer}>
<View style={[styles.offerCard, { backgroundColor: colors.primarySoft }]}>
<View style={[styles.offerIconWrap, { backgroundColor: colors.primary }]}>
<Ionicons name={isPauseOffer ? 'pause' : 'gift'} size={28} color="#fff" />
</View>
<Text style={[styles.offerText, { color: colors.text }]}>
{isPauseOffer ? copy.pauseText : copy.offerText}
</Text>
<TouchableOpacity
style={[styles.offerAcceptBtn, { backgroundColor: colors.primary }]}
onPress={() => {
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);
}}
>
<Text style={styles.offerAcceptBtnText}>
{isPauseOffer ? copy.pauseAccept : copy.offerAccept}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.offerDeclineBtn}
onPress={finalizeCancel}
disabled={isUpdating}
>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>
{isPauseOffer ? copy.pauseDecline : copy.offerDecline}
</Text>
</TouchableOpacity>
</View>
)}
{(isUpdating || (!storeReady && cancelStep === 'none')) && <ActivityIndicator color={colors.primary} style={{ marginTop: 16 }} />}
</View>
</View>
</Modal>
</View>
);
}
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,
},
});