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>
This commit is contained in:
Timo
2026-08-10 12:50:12 +02:00
parent 5da8027b68
commit e3a28b0a1c
9 changed files with 800 additions and 161 deletions

View File

@@ -6,6 +6,7 @@ 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,
@@ -20,11 +21,47 @@ import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts';
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
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 = 'monthly' | 'yearly';
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> = {
@@ -55,12 +92,14 @@ const resolveSubscriptionPackages = (offering: PurchasesOffering | null): Subscr
}
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)),
};
@@ -123,6 +162,7 @@ const getBillingCopy = (language: Language) => {
// 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',
@@ -184,9 +224,14 @@ const getBillingCopy = (language: Language) => {
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
@@ -204,6 +249,8 @@ const getBillingCopy = (language: Language) => {
`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`,
@@ -236,6 +283,7 @@ const getBillingCopy = (language: Language) => {
proPlanPrice: '4.99 EUR / Mes',
proPlanPriceBare: '4,99 €',
proYearlyPlanPriceBare: '39,99 €',
proWeeklyPlanPriceBare: '2,99 €',
proBadgeText: 'RECOMENDADO',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Año',
@@ -288,9 +336,13 @@ const getBillingCopy = (language: Language) => {
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."',
@@ -305,6 +357,7 @@ const getBillingCopy = (language: Language) => {
`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`,
@@ -337,6 +390,7 @@ const getBillingCopy = (language: Language) => {
proPlanPrice: '4.99 EUR / Month',
proPlanPriceBare: '€4.99',
proYearlyPlanPriceBare: '€39.99',
proWeeklyPlanPriceBare: '€2.99',
proBadgeText: 'RECOMMENDED',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Year',
@@ -389,9 +443,13 @@ const getBillingCopy = (language: Language) => {
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."',
@@ -406,6 +464,7 @@ const getBillingCopy = (language: Language) => {
`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`,
@@ -440,8 +499,11 @@ export default function BillingScreen() {
const [storeError, setStoreError] = useState<string | null>(null);
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
// Monthly is the default; the 7-day trial (yearly plan) must be opted into via the toggle.
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('monthly');
// 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');
@@ -468,13 +530,21 @@ export default function BillingScreen() {
try {
ensureRevenueCatConfigured();
const [offerings, topups] = await Promise.all([
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) {
@@ -508,14 +578,41 @@ export default function BillingScreen() {
};
}, [isExpoGo]);
const trialEnabled = selectedPaywallPlan === 'yearly';
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: trialEnabled,
// 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) {
@@ -526,20 +623,40 @@ export default function BillingScreen() {
});
} catch {}
}
}, [posthog, planId, session?.serverUserId, showPaywallPlans, onboardingContext, trialEnabled]);
}, [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() + 7);
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]);
}, [language, selectedTrialDays]);
const topupLabels = useMemo(() => ({
topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
@@ -595,9 +712,11 @@ export default function BillingScreen() {
setIsUpdating(true);
try {
await simulatePurchase(productId);
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (isSubscriptionProductId(productId)) {
posthog.capture('subscription_started', { product_id: productId, simulated: true });
posthog.capture('trial_started', { product_id: productId, simulated: true });
if (productHasTrial(productId)) {
posthog.capture('trial_started', { product_id: productId, simulated: true });
}
setSubModalVisible(false);
router.replace(postPurchaseRoute);
} else {
@@ -626,7 +745,7 @@ export default function BillingScreen() {
if (isExpoGo) {
// ExpoGo has no native RevenueCat - use simulation for development only
setIsUpdating(false);
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (isSubscriptionProductId(productId)) {
Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
{ text: copy.continueWithoutPro, style: 'cancel' },
{ text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) },
@@ -637,13 +756,13 @@ export default function BillingScreen() {
return;
} else {
ensureRevenueCatConfigured();
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (isSubscriptionProductId(productId)) {
if (planId === 'pro') {
await openAppleSubscriptions();
setSubModalVisible(false);
return;
}
const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage;
const selectedPackage = subscriptionPackages[productId];
const latestOffering = !selectedPackage
? await Purchases.getOfferings().then((offerings) => offerings.current)
: null;
@@ -660,7 +779,9 @@ export default function BillingScreen() {
?? await Purchases.getCustomerInfo();
void syncRevenueCatState(customerInfo as any, 'subscription_purchase');
posthog.capture('subscription_started', { product_id: productId });
posthog.capture('trial_started', { product_id: productId });
if (productHasTrial(productId)) {
posthog.capture('trial_started', { product_id: productId });
}
setSubModalVisible(false);
setTimeout(() => router.replace(postPurchaseRoute), 0);
return;
@@ -743,14 +864,80 @@ export default function BillingScreen() {
}
};
// Jahrespreis -> Monatsaequivalent, mit Waehrungssymbol aus dem Store-Preis.
const yearlyMonthlyEquivalent = (() => {
const numeric = parseFloat(yearlyPrice.replace(/[^0-9.,]/g, '').replace(',', '.'));
// 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 = yearlyPrice.replace(/[0-9.,\s]/g, '') || '€';
const perMonth = (numeric / 12).toFixed(2).replace('.', language === 'en' ? '.' : ',');
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 (
@@ -790,105 +977,84 @@ export default function BillingScreen() {
))}
</View>
{/* EINE Angebotskarte statt Toggle + Preiskarte. Der Plan,
der Preis und der Kaufbutton stehen zusammen, die
Alternative darunter als Textlink. Damit entfaellt der
Switch, der die Preisaenderung nie sichtbar gemacht hat. */}
<View
{/* 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.offerCardOuter,
styles.planRowMonthly,
{
backgroundColor: colors.surfaceMuted,
borderColor: colors.primary,
// Dezenter Glow statt Leuchtrahmen.
shadowColor: colors.primary,
borderColor: selectedPaywallPlan === 'monthly' ? colors.primary : colors.border,
backgroundColor: selectedPaywallPlan === 'monthly' ? colors.surfaceMuted : 'transparent',
},
]}
>
<View style={[styles.offerBadge, { backgroundColor: colors.primary }]}>
<Text style={[styles.offerBadgeText, { color: colors.onPrimary }]}>
{trialEnabled ? copy.badgeMostPopular : copy.badgeFlexible}
<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}
<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>
<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.offerBreakdownValue, { color: colors.text }]}
numberOfLines={1}
adjustsFontSizeToFit
style={[styles.offerCtaText, { color: colors.onPrimary }]}
maxFontSizeMultiplier={1.2}
>
{copy.equivalentValue(yearlyMonthlyEquivalent)}
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
</View>
) : null}
<TouchableOpacity
style={[
styles.offerCta,
{ backgroundColor: colors.primary },
(!storeReady || isUpdating || Boolean(storeError)) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')}
disabled={isUpdating || !storeReady || Boolean(storeError)}
activeOpacity={0.86}
>
{isUpdating || !storeReady ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<>
<Text
style={[styles.offerCtaText, { color: colors.onPrimary }]}
maxFontSizeMultiplier={1.2}
>
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
<Ionicons name="arrow-forward" size={19} color={colors.onPrimary} />
</>
)}
</TouchableOpacity>
{/* Planwechsel als Textlink - kein Schalter, sondern eine
sichtbar formulierte Alternative inkl. Preis. */}
<TouchableOpacity
onPress={() => setSelectedPaywallPlan(trialEnabled ? 'monthly' : 'yearly')}
style={styles.offerAltLink}
>
<Text style={[styles.offerAltLinkText, { color: colors.textSecondary }]}>
{trialEnabled
? copy.altMonthly(monthlyPrice)
: copy.altYearly(yearlyPrice)}
</Text>
</TouchableOpacity>
</View>
<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, yearlyPrice, trialEndDate)}
{copy.dueSummary(copy.dueTodayAmount, selectedPrice, trialEndDate)}
</Text>
</View>
) : null}
@@ -954,7 +1120,9 @@ export default function BillingScreen() {
{/* 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}
{trialEnabled
? `${copy.cancelAnytime} · ${selectedPaywallPlan === 'weekly' ? copy.trialReminderWeekly : copy.trialReminder}`
: copy.cancelAnytime}
</Text>
<Text style={[styles.paywallFooterText, styles.paywallFooterCentered, { color: colors.textMuted }]}>
{copy.cancelPath}
@@ -1354,6 +1522,33 @@ const styles = StyleSheet.create({
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',