Files
Greenlens/app/profile/billing.tsx
Timo e3a28b0a1c feat(billing): add weekly_pro subscription plan
Adds a 2.99 EUR/week plan with a 3-day free trial alongside the existing
monthly and yearly subscriptions.

Backend: weekly_pro joins the supported subscription products, the
available product list and the Discord sales label. No schema change --
weekly Pro grants the same 100 credits per calendar month as monthly Pro,
so no column is needed to tell the two apart.

Paywall: weekly and yearly are the two prominent cards, monthly is a
selectable row below them. Weekly is preselected. Cards only render when
their RevenueCat package exists, and the selection falls back to a
visible plan so the CTA can never buy a product that is not loaded.

Trial eligibility: checkTrialOrIntroductoryPriceEligibility now gates the
trial copy. Apple grants one intro offer per subscription group, so with
two trial products a second free-trial promise would otherwise be shown
to users who get charged immediately. Anything but a clear ELIGIBLE is
treated as no trial, as the RevenueCat SDK recommends.

Analytics: trial_started previously fired on every subscription purchase,
including monthly which never had a trial. It now fires only for products
that actually carry one. paywall_viewed distinguishes trial_enabled from
trial_eligible and reports selected_plan.

Tests: 9 new cases covering the entitlement path, credits, renewal period
and trial allowance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:50:38 +02:00

1845 lines
95 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, {
INTRO_ELIGIBILITY_STATUS,
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 = 'weekly_pro' | '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 = 'weekly' | 'monthly' | 'yearly';
const PRODUCT_BY_PAYWALL_PLAN: Record<PaywallPlanId, SubscriptionProductId> = {
weekly: 'weekly_pro',
monthly: 'monthly_pro',
yearly: 'yearly_pro',
};
// Trial-Laufzeit je Plan. Monthly hat bewusst keinen - der Trial ist das
// Argument fuer die beiden prominenten Optionen.
const TRIAL_DAYS_BY_PAYWALL_PLAN: Record<PaywallPlanId, number> = {
weekly: 3,
monthly: 0,
yearly: 7,
};
// Produkte, fuer die ueberhaupt eine Trial-Berechtigung geprueft werden muss.
const TRIAL_PRODUCTS: SubscriptionProductId[] = ['weekly_pro', 'yearly_pro'];
type TrialEligibility = Partial<Record<SubscriptionProductId, boolean>>;
// Apples Trial-Berechtigung gilt pro Subscription Group, nicht pro Produkt: wer den
// 3-Tage-Trial ueber Weekly verbraucht hat, bekommt auch ueber Yearly keinen zweiten.
// Alles ausser einem klaren ELIGIBLE wird deshalb als "kein Trial" behandelt - inklusive
// UNKNOWN, wozu auch das RevenueCat-SDK selbst raet, um keine falsche Zusage zu machen.
const readTrialEligibility = (
result: Record<string, { status: INTRO_ELIGIBILITY_STATUS }>,
): TrialEligibility => {
return TRIAL_PRODUCTS.reduce<TrialEligibility>((acc, productId) => {
acc[productId] = result?.[productId]?.status === INTRO_ELIGIBILITY_STATUS.INTRO_ELIGIBILITY_STATUS_ELIGIBLE;
return acc;
}, {});
};
const isSubscriptionProductId = (productId: PurchaseProductId): productId is SubscriptionProductId => (
productId === 'weekly_pro' || productId === 'monthly_pro' || productId === 'yearly_pro'
);
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.weekly,
offering.monthly,
offering.annual,
...offering.availablePackages,
].filter((value): value is PurchasesPackage => Boolean(value));
return {
weekly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'weekly_pro', PACKAGE_TYPE.WEEKLY)),
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 €',
proWeeklyPlanPriceBare: '2,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',
planWeeklyName: 'Wöchentlich - 3 Tage gratis',
// Ohne Trial-Berechtigung: nur der Planname, keine Gratis-Zusage.
planYearlyNamePlain: 'Jährlich',
planWeeklyNamePlain: 'Wöchentlich',
saveBadge: '33 % SPAREN',
perYear: '/ Jahr',
perMonth: '/ Monat',
perWeek: '/ Woche',
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.',
// 3-Tage-Trial: eine 2-Tage-Vorwarnung waere fast deckungsgleich mit dem Start.
trialReminderWeekly: 'Wir erinnern dich 1 Tag 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 €',
proWeeklyPlanPriceBare: '2,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',
planWeeklyName: 'Semanal - 3 días gratis',
planYearlyNamePlain: 'Anual',
planWeeklyNamePlain: 'Semanal',
saveBadge: 'AHORRA 33 %',
perYear: '/ año',
perMonth: '/ mes',
perWeek: '/ semana',
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.',
trialReminderWeekly: 'Te avisamos 1 día 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',
proWeeklyPlanPriceBare: '€2.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',
planWeeklyName: 'Weekly - 3 days free',
planYearlyNamePlain: 'Yearly',
planWeeklyNamePlain: 'Weekly',
saveBadge: 'SAVE 33%',
perYear: '/ year',
perMonth: '/ month',
perWeek: '/ week',
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.',
trialReminderWeekly: 'We remind you 1 day 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>({});
// Leeres Objekt = noch nichts geprueft, also auch keine Trial-Zusage zeigen.
const [trialEligibility, setTrialEligibility] = useState<TrialEligibility>({});
// Weekly ist vorausgewaehlt: niedrigste Einstiegshuerde. Yearly steht
// gleichwertig darunter, Monthly als dritte Zeile.
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('weekly');
// 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, eligibility] = await Promise.all([
Purchases.getOfferings(),
Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION),
// Eigener catch: faellt die Pruefung aus, laufen Offerings und Top-ups
// trotzdem durch - die Paywall verspricht dann nur eben keinen Trial.
Purchases.checkTrialOrIntroductoryPriceEligibility(TRIAL_PRODUCTS).catch((error) => {
console.warn('[Billing] Trial eligibility check failed, falling back to no-trial pricing', error);
return {} as Record<string, { status: INTRO_ELIGIBILITY_STATUS }>;
}),
]);
if (cancelled) return;
setTrialEligibility(readTrialEligibility(eligibility));
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 selectedProductId = PRODUCT_BY_PAYWALL_PLAN[selectedPaywallPlan];
const selectedTrialDays = TRIAL_DAYS_BY_PAYWALL_PLAN[selectedPaywallPlan];
// Ein Plan zeigt seine Testphase nur, wenn Apple sie diesem Nutzer auch gewaehrt.
// In Expo Go gibt es keinen Store - dort bleibt der Simulationspfad wie gehabt.
const planHasTrial = (plan: PaywallPlanId) => {
if (TRIAL_DAYS_BY_PAYWALL_PLAN[plan] <= 0) return false;
if (isExpoGo) return true;
return trialEligibility[PRODUCT_BY_PAYWALL_PLAN[plan]] === true;
};
const trialEnabled = planHasTrial(selectedPaywallPlan);
// Fuer die Kaufpfade: dort steht die Produkt-ID fest, nicht der Paywall-Plan.
const productHasTrial = (productId: PurchaseProductId) => {
if (!isSubscriptionProductId(productId)) return false;
if (!TRIAL_PRODUCTS.includes(productId)) return false;
if (isExpoGo) return true;
return trialEligibility[productId] === true;
};
useEffect(() => {
try {
posthog.capture('paywall_viewed', {
plan_id: planId,
context: onboardingContext ? 'onboarding' : 'in_app',
// trial_enabled = der Plan sieht eine Testphase vor,
// trial_eligible = Apple gewaehrt sie diesem Nutzer auch. Getrennt, weil
// sonst nicht unterscheidbar ist, ob eine schlechte Conversion am Preis
// oder an einer bereits verbrauchten Testphase liegt.
trial_enabled: selectedTrialDays > 0,
trial_eligible: trialEnabled,
// Mit drei Plaenen reicht das Trial-Flag nicht mehr, um Weekly von
// Yearly zu unterscheiden.
selected_plan: selectedPaywallPlan,
});
} catch {}
if (showPaywallPlans) {
try {
posthog.capture('hard_paywall_viewed', {
plan_id: planId,
authenticated: Boolean(session),
});
} catch {}
}
}, [posthog, planId, session?.serverUserId, showPaywallPlans, onboardingContext, trialEnabled, selectedPaywallPlan]);
const weeklyPackage = subscriptionPackages.weekly_pro;
const monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro;
// Fallback ohne Periode - sonst entsteht "4.99 EUR / Month/month".
const weeklyPrice = weeklyPackage?.product.priceString ?? copy.proWeeklyPlanPriceBare;
const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPriceBare;
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPriceBare;
const selectedPrice = selectedPaywallPlan === 'weekly'
? weeklyPrice
: selectedPaywallPlan === 'yearly' ? yearlyPrice : monthlyPrice;
// Die Karten rendern nur, wenn ihr RevenueCat-Package existiert. Ohne diesen
// Fallback koennte die Vorauswahl auf einen unsichtbaren Plan zeigen: die Paywall
// haette dann keine markierte Option und der CTA kaufte ins Leere. Monthly ist der
// letzte Ausweg - dessen Zeile wird immer gerendert.
useEffect(() => {
if (isExpoGo || !storeReady) return;
if (selectedPaywallPlan === 'weekly' && !weeklyPackage) {
setSelectedPaywallPlan(yearlyPackage ? 'yearly' : 'monthly');
return;
}
if (selectedPaywallPlan === 'yearly' && !yearlyPackage) {
setSelectedPaywallPlan(weeklyPackage ? 'weekly' : 'monthly');
}
}, [isExpoGo, storeReady, selectedPaywallPlan, weeklyPackage, yearlyPackage]);
const trialEndDate = useMemo(() => {
const date = new Date();
date.setDate(date.getDate() + selectedTrialDays);
const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US';
return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
}, [language, selectedTrialDays]);
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 (isSubscriptionProductId(productId)) {
posthog.capture('subscription_started', { product_id: productId, simulated: true });
if (productHasTrial(productId)) {
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 (isSubscriptionProductId(productId)) {
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 (isSubscriptionProductId(productId)) {
if (planId === 'pro') {
await openAppleSubscriptions();
setSubModalVisible(false);
return;
}
const selectedPackage = subscriptionPackages[productId];
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 });
if (productHasTrial(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);
}
};
// Store-Preis -> Monatsaequivalent, mit Waehrungssymbol aus dem Store-Preis.
// periodsPerYear: 1 fuer das Jahresabo, 52 fuer das Wochenabo.
const toMonthlyEquivalent = (price: string, periodsPerYear: number) => {
const numeric = parseFloat(price.replace(/[^0-9.,]/g, '').replace(',', '.'));
if (!Number.isFinite(numeric) || numeric <= 0) return null;
const symbol = price.replace(/[0-9.,\s]/g, '') || '€';
const perMonth = ((numeric * periodsPerYear) / 12).toFixed(2).replace('.', language === 'en' ? '.' : ',');
return `${perMonth} ${symbol}`.trim();
};
// Nur fuers Jahresabo: dort macht die Umrechnung den grossen Betrag greifbar.
// Fuer Weekly und Monthly steht der Periodenpreis unveraendert an der Karte.
const yearlyMonthlyEquivalent = toMonthlyEquivalent(yearlyPrice, 1);
const selectedMonthlyEquivalent = selectedPaywallPlan === 'yearly' ? yearlyMonthlyEquivalent : null;
// Rendert eine der beiden prominenten Planoptionen. Fehlt das RevenueCat-Package
// (z. B. weil das Wochenabo im Offering noch nicht freigeschaltet ist), faellt die
// Karte ersatzlos weg - die Paywall bleibt dann bei den restlichen Plaenen, statt
// einen Kauf anzubieten, der nur fehlschlagen kann.
const renderPlanCard = (plan: Exclude<PaywallPlanId, 'monthly'>) => {
const isYearly = plan === 'yearly';
const hasPackage = Boolean(isYearly ? yearlyPackage : weeklyPackage);
if (!isExpoGo && !hasPackage) {
return null;
}
const isSelected = selectedPaywallPlan === plan;
const showsTrial = planHasTrial(plan);
const planName = isYearly
? (showsTrial ? copy.planYearlyName : copy.planYearlyNamePlain)
: (showsTrial ? copy.planWeeklyName : copy.planWeeklyNamePlain);
return (
<TouchableOpacity
key={plan}
onPress={() => setSelectedPaywallPlan(plan)}
activeOpacity={0.86}
accessibilityRole="radio"
accessibilityState={{ selected: isSelected }}
style={[
styles.planCard,
{
borderColor: isSelected ? colors.primary : colors.border,
backgroundColor: isSelected ? colors.surfaceMuted : 'transparent',
shadowColor: colors.primary,
},
isSelected && styles.planCardSelected,
]}
>
<View style={styles.planCardRow}>
<Ionicons
name={isSelected ? 'radio-button-on' : 'radio-button-off'}
size={22}
color={isSelected ? colors.primary : colors.textMuted}
/>
<View style={styles.offerHeaderLeft}>
<Text style={[styles.offerPlanName, { color: colors.text }]}>
{planName}
</Text>
<Text style={[styles.offerSaveText, { color: colors.primary }]}>
{isYearly ? copy.saveBadge : copy.badgeFlexible}
</Text>
</View>
<View style={styles.offerHeaderRight}>
<Text style={[styles.offerPrice, { color: colors.text }]}>
{isYearly ? yearlyPrice : weeklyPrice}
</Text>
<Text style={[styles.offerPricePeriod, { color: colors.textMuted }]}>
{isYearly ? copy.perYear : copy.perWeek}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
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>
{/* Zwei gleichwertige Karten: Woechentlich und Jaehrlich - die
beiden Plaene mit Testphase. Monatlich steht darunter als
eigene, ebenfalls anklickbare Zeile: sichtbar vorhanden,
aber ohne die Entscheidung zu dritteln. */}
<View style={styles.planPicker}>
{renderPlanCard('weekly')}
{renderPlanCard('yearly')}
</View>
<TouchableOpacity
onPress={() => setSelectedPaywallPlan('monthly')}
activeOpacity={0.86}
accessibilityRole="radio"
accessibilityState={{ selected: selectedPaywallPlan === 'monthly' }}
style={[
styles.planRowMonthly,
{
borderColor: selectedPaywallPlan === 'monthly' ? colors.primary : colors.border,
backgroundColor: selectedPaywallPlan === 'monthly' ? colors.surfaceMuted : 'transparent',
},
]}
>
<Ionicons
name={selectedPaywallPlan === 'monthly' ? 'radio-button-on' : 'radio-button-off'}
size={20}
color={selectedPaywallPlan === 'monthly' ? colors.primary : colors.textMuted}
/>
<Text style={[styles.planRowMonthlyText, { color: colors.textSecondary }]}>
{`${copy.planMonthlyName} · ${monthlyPrice} ${copy.perMonth}`}
</Text>
</TouchableOpacity>
{/* Monatsaequivalent - nur beim Jahresabo. */}
{selectedMonthlyEquivalent ? (
<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(selectedMonthlyEquivalent)}
</Text>
</View>
) : null}
<TouchableOpacity
style={[
styles.offerCta,
{ backgroundColor: colors.primary },
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(selectedProductId)}
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>
{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, selectedPrice, 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} · ${selectedPaywallPlan === 'weekly' ? copy.trialReminderWeekly : 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' },
planPicker: { gap: 10, marginBottom: 10 },
planCard: {
borderRadius: 18,
borderWidth: 1.5,
paddingHorizontal: 16,
paddingVertical: 14,
},
planCardSelected: {
// Dezenter Glow auf der gewaehlten Karte, gleiche Sprache wie zuvor die
// einzelne Angebotskarte.
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.34,
shadowRadius: 20,
elevation: 8,
},
planCardRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
planRowMonthly: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderRadius: 14,
borderWidth: 1,
paddingHorizontal: 16,
paddingVertical: 12,
marginBottom: 4,
},
planRowMonthlyText: { flex: 1, fontSize: 14, fontWeight: '700' },
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,
},
});