This commit is contained in:
2026-07-27 16:42:04 +02:00
parent 45a7704ea6
commit 018b751723
23 changed files with 2327 additions and 635 deletions

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, Switch, useWindowDimensions } from 'react-native';
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';
@@ -26,7 +26,6 @@ type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPacka
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
type PaywallPlanId = 'monthly' | 'yearly';
const PAYWALL_BACKGROUND = require('../../assets/paywall_scan_background.png');
const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = {
topup_small: 30,
@@ -121,27 +120,35 @@ const getBillingCopy = (language: Language) => {
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 Credits für AI-Scans und Follow-ups jeden Monat',
'Pro-Scans mit GPT-5.4',
'Unbegrenzte Historie & Galerie',
'KI-Pflanzendoktor inklusive',
'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 €',
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).',
@@ -153,14 +160,55 @@ const getBillingCopy = (language: Language) => {
autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.',
manageInSettings: 'In iOS-Einstellungen verwalten',
paywallEyebrow: 'GreenLens Pro',
paywallHeadline: 'Unbegrenzter Zugriff',
paywallSub: 'Unbegrenzte Scans, Health-Checks und dein persönlicher Pflegeplan.',
// "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',
planCardBody: 'Unbegrenzte KI-Scans, Gesundheitsdiagnose, 7-Tage-Rettungspläne, 100 Credits/Monat',
// "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',
dueTodayTrial: 'Fällig heute - 7 Tage gratis',
dueTodayAmount: '0,00 €',
dueLater: (date: string) => `Fällig am ${date}`,
ctaTrial: 'Gratis testen',
@@ -186,27 +234,34 @@ const getBillingCopy = (language: Language) => {
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 créditos para escaneos IA y seguimientos cada mes',
'Escaneos Pro con GPT-5.4',
'Historial y galería ilimitados',
'Doctor de plantas de IA incluido',
'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 €',
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).',
@@ -218,14 +273,43 @@ const getBillingCopy = (language: Language) => {
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: 'Acceso ilimitado',
paywallSub: 'Escaneos ilimitados, chequeos de salud y tu plan de cuidados personal.',
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: 'Escaneos IA ilimitados, diagnóstico de salud, planes de rescate de 7 días, 100 créditos/mes',
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',
dueTodayTrial: 'Hoy - 7 días gratis',
dueTodayAmount: '0,00 €',
dueLater: (date: string) => `El ${date}`,
ctaTrial: 'Probar gratis',
@@ -251,27 +335,34 @@ const getBillingCopy = (language: Language) => {
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 credits for AI scans and follow-ups every month',
'Pro scans with GPT-5.4',
'Unlimited history & gallery',
'AI Plant Doctor included',
'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',
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).',
@@ -283,14 +374,43 @@ const getBillingCopy = (language: Language) => {
autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.',
manageInSettings: 'Manage in iOS Settings',
paywallEyebrow: 'GreenLens Pro',
paywallHeadline: 'Get Unlimited Access',
paywallSub: 'Unlimited scans, health checks and your personal care plan.',
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: 'Unlimited AI scans, health diagnosis, 7-day rescue plans, 100 credits/month',
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',
dueTodayTrial: 'Due today - 7 days free',
dueTodayAmount: '€0.00',
dueLater: (date: string) => `Due ${date}`,
ctaTrial: 'Try Free',
@@ -325,6 +445,13 @@ export default function BillingScreen() {
// 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);
@@ -404,8 +531,9 @@ export default function BillingScreen() {
const monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro;
const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice;
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
// 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);
@@ -496,7 +624,7 @@ export default function BillingScreen() {
posthog.capture('purchase_initiated', { product_id: productId });
try {
if (isExpoGo) {
// ExpoGo has no native RevenueCat use simulation for development only
// 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, [
@@ -558,7 +686,7 @@ export default function BillingScreen() {
return;
}
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED the Apple ID already
// 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;
@@ -606,6 +734,7 @@ export default function BillingScreen() {
try {
await simulateWebhookEvent('entitlement_revoked');
setCancelStep('none');
setCancelReason(null);
setSubModalVisible(false);
} catch (e) {
console.error('Downgrade failed', e);
@@ -614,109 +743,230 @@ export default function BillingScreen() {
}
};
// 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}>
<ImageBackground
source={PAYWALL_BACKGROUND}
style={styles.hardPaywallHero}
imageStyle={styles.hardPaywallHeroImage}
resizeMode="cover"
>
<View style={[styles.hardPaywallScreen, { backgroundColor: colors.background }]}>
<View style={styles.hardPaywallPlain}>
<SafeAreaView style={styles.hardPaywallSafe} edges={['top']}>
<View style={styles.heroTopBar}>
<TouchableOpacity onPress={handleBack} style={styles.heroIconButton}>
<Ionicons name="close" size={24} color="#FFFFFF" />
<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}>{copy.restorePurchases}</Text>
<Text style={[styles.heroRestoreText, { color: colors.textSecondary }]}>{copy.restorePurchases}</Text>
</TouchableOpacity>
</View>
<View style={[styles.paywallSheet, { backgroundColor: colors.background, marginTop: compact ? 110 : 190 }]}>
<View style={[styles.paywallSheet, { backgroundColor: colors.background, marginTop: 0 }]}>
<ScrollView
style={styles.paywallScroll}
contentContainerStyle={styles.paywallBody}
showsVerticalScrollIndicator={false}
>
<View style={[styles.sheetHandle, { backgroundColor: colors.border }]} />
<Text style={[styles.paywallEyebrow, { color: colors.primary }]}>{copy.paywallEyebrow.toUpperCase()}</Text>
<Text style={[styles.paywallHeadline, { color: colors.text }]}>{copy.paywallHeadline}</Text>
<Text style={[styles.paywallSub, { color: colors.textSecondary }]}>{copy.paywallSub}</Text>
<View style={[styles.planCard, { backgroundColor: colors.surfaceMuted }]}>
<Text style={[styles.planCardTitle, { color: colors.text }]}>{copy.planCardTitle}</Text>
<Text style={[styles.planCardBody, { color: colors.textSecondary }]}>{copy.planCardBody}</Text>
<View style={[styles.planCardDivider, { backgroundColor: colors.border }]} />
<Text style={[styles.planCardPrice, { color: colors.text }]}>
{trialEnabled ? copy.planCardPriceTrial(yearlyPrice) : copy.planCardPriceMonthly(monthlyPrice)}
</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>
<View style={[styles.trialToggleRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.trialToggleLabel, { color: colors.text }]}>{copy.trialToggleLabel}</Text>
<Switch
value={trialEnabled}
onValueChange={(next) => setSelectedPaywallPlan(next ? 'yearly' : 'monthly')}
trackColor={{ true: colors.primary, false: colors.border }}
thumbColor="#FFFFFF"
/>
{/* 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 }]}>
{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.dueTimeline}>
<View style={styles.dueRow}>
<View style={[styles.dueDot, { backgroundColor: colors.primary }]} />
<Text style={[styles.dueLabel, { color: colors.primary }]}>{copy.dueTodayTrial}</Text>
<Text style={[styles.dueAmount, { color: colors.text }]}>{copy.dueTodayAmount}</Text>
</View>
<View style={[styles.dueLine, { backgroundColor: colors.border }]} />
<View style={styles.dueRow}>
<View style={[styles.dueDot, { backgroundColor: colors.border }]} />
<Text style={[styles.dueLabel, { color: colors.textSecondary }]}>{copy.dueLater(trialEndDate)}</Text>
<Text style={[styles.dueAmount, { color: colors.text }]}>{yearlyPrice}</Text>
</View>
<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}
{storeError ? (
<Text style={[styles.paywallFooterText, { color: colors.danger, marginBottom: 8 }]}>{storeError}</Text>
) : null}
{/* Aufklappbar: eingeklappt bleibt die Kaufentscheidung
ueber der Falz, aufgeklappt stehen die Argumente
vollstaendig da. Beides ohne zweiten Screen. */}
<TouchableOpacity
style={[
styles.paywallCta,
{ backgroundColor: colors.primary },
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.86}
style={[styles.detailsToggle, { borderColor: colors.border }]}
onPress={() => {
setDetailsOpen((open) => {
if (!open) posthog.capture('paywall_details_expanded');
return !open;
});
}}
activeOpacity={0.8}
>
{isUpdating || !storeReady ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<Text style={[styles.paywallCtaText, { color: colors.onPrimary }]}>
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
)}
<Text style={[styles.detailsToggleLabel, { color: colors.text }]}>{copy.detailsToggle}</Text>
<Ionicons
name={detailsOpen ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.textSecondary}
/>
</TouchableOpacity>
<View style={styles.paywallFooter}>
<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>
{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>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>{copy.cancelAnytime}</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>
</ImageBackground>
</View>
</View>
);
}
@@ -835,11 +1085,12 @@ export default function BillingScreen() {
<View style={[styles.modalContent, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>
{cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle}
{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>
@@ -953,6 +1204,9 @@ export default function BillingScreen() {
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');
}}
>
@@ -969,20 +1223,29 @@ export default function BillingScreen() {
<View style={styles.cancelFlowContainer}>
<View style={[styles.offerCard, { backgroundColor: colors.primarySoft }]}>
<View style={[styles.offerIconWrap, { backgroundColor: colors.primary }]}>
<Ionicons name="gift" size={28} color="#fff" />
<Ionicons name={isPauseOffer ? 'pause' : 'gift'} size={28} color="#fff" />
</View>
<Text style={[styles.offerText, { color: colors.text }]}>{copy.offerText}</Text>
<Text style={[styles.offerText, { color: colors.text }]}>
{isPauseOffer ? copy.pauseText : copy.offerText}
</Text>
<TouchableOpacity
style={[styles.offerAcceptBtn, { backgroundColor: colors.primary }]}
onPress={() => {
// Handle applying discount here (future implementation)
Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)');
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}>{copy.offerAccept}</Text>
<Text style={styles.offerAcceptBtnText}>
{isPauseOffer ? copy.pauseAccept : copy.offerAccept}
</Text>
</TouchableOpacity>
</View>
@@ -991,7 +1254,9 @@ export default function BillingScreen() {
onPress={finalizeCancel}
disabled={isUpdating}
>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>{copy.offerDecline}</Text>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>
{isPauseOffer ? copy.pauseDecline : copy.offerDecline}
</Text>
</TouchableOpacity>
</View>
)}
@@ -1008,12 +1273,6 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#101411',
},
hardPaywallHero: {
flex: 1,
},
hardPaywallHeroImage: {
transform: [{ translateY: -38 }, { scale: 1.06 }],
},
hardPaywallSafe: {
flex: 1,
},
@@ -1040,43 +1299,108 @@ const styles = StyleSheet.create({
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
hardPaywallPlain: { flex: 1 },
paywallSheet: {
flex: 1,
borderTopLeftRadius: 30,
borderTopRightRadius: 30,
overflow: 'hidden',
zIndex: 5,
},
sheetHandle: {
alignSelf: 'center',
width: 42,
height: 5,
borderRadius: 999,
marginTop: 10,
marginBottom: 8,
},
paywallBody: { paddingHorizontal: 22, paddingTop: 10, paddingBottom: 24 },
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 },
planCard: { borderRadius: 16, padding: 18, marginBottom: 14 },
planCardTitle: { fontSize: 19, fontWeight: '800', marginBottom: 6 },
planCardBody: { fontSize: 14, lineHeight: 20 },
planCardDivider: { height: StyleSheet.hairlineWidth, marginVertical: 12 },
planCardPrice: { fontSize: 15, fontWeight: '800' },
trialToggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 14, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 12, marginBottom: 16 },
trialToggleLabel: { fontSize: 15, fontWeight: '800' },
dueTimeline: { marginBottom: 18, paddingHorizontal: 4 },
dueRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
dueDot: { width: 10, height: 10, borderRadius: 5 },
dueLine: { width: 2, height: 18, marginLeft: 4, marginVertical: 2 },
dueLabel: { flex: 1, fontSize: 14, fontWeight: '700' },
dueAmount: { fontSize: 14, fontWeight: '800' },
paywallCta: { height: 58, borderRadius: 14, alignItems: 'center', justifyContent: 'center', marginBottom: 12 },
paywallCtaText: { fontSize: 18, fontWeight: '800' },
paywallFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
paywallFooterLinks: { flexDirection: 'row', alignItems: 'center' },
// 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,
height: 56,
borderRadius: 14,
marginTop: 16,
},
offerCtaText: { fontSize: 17.5, fontWeight: '800' },
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' },