Slefhostet und postgres

This commit is contained in:
2026-04-02 11:39:57 +02:00
parent b1c99893a6
commit 08483c7075
215 changed files with 4584 additions and 5190 deletions

View File

@@ -94,7 +94,7 @@ function RootLayoutInner() {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any);
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();

View File

@@ -4,12 +4,68 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import Constants from 'expo-constants';
import Purchases, { PRODUCT_CATEGORY } from 'react-native-purchases';
import Purchases, {
PACKAGE_TYPE,
PRODUCT_CATEGORY,
PurchasesOffering,
PurchasesPackage,
PurchasesStoreProduct,
} from 'react-native-purchases';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts';
import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts';
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
const isMatchingPackage = (
pkg: PurchasesPackage,
productId: SubscriptionProductId,
expectedPackageType: PACKAGE_TYPE,
) => {
return (
pkg.product.identifier === productId
|| pkg.identifier === productId
|| pkg.packageType === expectedPackageType
);
};
const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => {
if (!offering) {
return {};
}
const availablePackages = [
offering.monthly,
offering.annual,
...offering.availablePackages,
].filter((value): value is PurchasesPackage => Boolean(value));
return {
monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)),
yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)),
};
};
const summarizeOfferingPackages = (offering: PurchasesOffering | null) => {
if (!offering) {
return { identifier: null, packages: [] as Array<Record<string, string | null>> };
}
return {
identifier: offering.identifier,
packages: offering.availablePackages.map((pkg) => ({
identifier: pkg.identifier,
packageType: pkg.packageType,
productIdentifier: pkg.product.identifier,
priceString: pkg.product.priceString,
})),
};
};
const getBillingCopy = (language: Language) => {
if (language === 'de') {
@@ -161,8 +217,8 @@ export default function BillingScreen() {
const [subModalVisible, setSubModalVisible] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [storeReady, setStoreReady] = useState(isExpoGo);
const [subscriptionPackages, setSubscriptionPackages] = useState<Partial<Record<'monthly_pro' | 'yearly_pro', any>>>({});
const [topupProducts, setTopupProducts] = useState<Partial<Record<'topup_small' | 'topup_medium' | 'topup_large', any>>>({});
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
// Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
@@ -187,11 +243,13 @@ export default function BillingScreen() {
if (cancelled) return;
const currentOffering = offerings.current;
setSubscriptionPackages({
monthly_pro: currentOffering?.monthly ?? undefined,
yearly_pro: currentOffering?.annual ?? undefined,
});
const currentOffering = offerings.current;
const resolvedPackages = resolveSubscriptionPackages(currentOffering);
if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) {
console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering));
}
setSubscriptionPackages(resolvedPackages);
setTopupProducts({
topup_small: topups.find((product) => product.identifier === 'topup_small'),
@@ -244,22 +302,29 @@ export default function BillingScreen() {
return;
}
const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage;
if (!selectedPackage) {
const latestOffering = !selectedPackage
? await Purchases.getOfferings().then((offerings) => offerings.current)
: null;
if (!selectedPackage) {
console.warn('[Billing] Purchase blocked because subscription package was not resolved', {
productId,
offering: summarizeOfferingPackages(latestOffering),
});
throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.');
}
await Purchases.purchasePackage(selectedPackage);
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any);
} else {
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
} else {
const selectedProduct = topupProducts[productId];
if (!selectedProduct) {
throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.');
}
await Purchases.purchaseStoreProduct(selectedProduct);
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any);
}
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
}
}
setSubModalVisible(false);
} catch (e) {
@@ -282,7 +347,7 @@ export default function BillingScreen() {
try {
if (!isExpoGo) {
const customerInfo = await Purchases.restorePurchases();
await syncRevenueCatState(customerInfo as any);
await syncRevenueCatState(customerInfo as any, 'restore');
}
Alert.alert(copy.restorePurchases, '✓');
} catch (e) {
@@ -413,11 +478,11 @@ export default function BillingScreen() {
</View>
<View style={[styles.legalLinksRow, { marginTop: 16 }]}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlns-landing.vercel.app/privacy')}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlns-landing.vercel.app/terms')}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity>
</View>
@@ -467,11 +532,11 @@ export default function BillingScreen() {
))}
</View>
<View style={[styles.legalLinksRow, { marginTop: 12 }]}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlns-landing.vercel.app/privacy')}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlns-landing.vercel.app/terms')}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity>
</View>
@@ -524,10 +589,10 @@ export default function BillingScreen() {
styles.planOption,
{ borderColor: colors.border },
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating}
>
]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady}
>
<View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}>
<Text style={[styles.planName, { color: colors.text }]}>{copy.proPlanName}</Text>
@@ -582,11 +647,11 @@ export default function BillingScreen() {
</TouchableOpacity>
</View>
<View style={styles.legalLinksRow}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlns-landing.vercel.app/privacy')}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlns-landing.vercel.app/terms')}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity>
</View>