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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user