Files
menulio/apps/mobile/src/app/account/subscription.tsx
T
AyrisAI b25c742eea feat(mobile): integrate react-native-purchases (RevenueCat)
- Purchases.configure() runs with appUserID = restaurantId at every
  point the id becomes known (login resolve, cold-start rehydration,
  onboarding restaurant creation) — this must match what the backend
  webhook expects (see apps/api/src/routes/subscription.ts).
- New account/subscription.tsx paywall: current entitlement status,
  offering packages with real store prices, purchase, restore.
- Linked from the account screen.

Native module — requires an EAS/dev-client build, not plain Expo Go.
2026-08-20 13:20:54 +03:00

168 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from "react";
import { router } from "expo-router";
import { ActivityIndicator, Alert, Pressable, ScrollView, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { CustomerInfo, PurchasesOffering, PurchasesPackage } from "react-native-purchases";
import Purchases from "react-native-purchases";
import { getActiveRestaurant } from "@/lib/active-restaurant";
import { configurePurchases, getCurrentOffering, getCustomerInfo, isProActive, PRO_ENTITLEMENT_ID } from "@/lib/purchases";
export default function SubscriptionScreen() {
const [loading, setLoading] = useState(true);
const [offering, setOffering] = useState<PurchasesOffering | null>(null);
const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
const [purchasingPackageId, setPurchasingPackageId] = useState<string | null>(null);
const [restoring, setRestoring] = useState(false);
const load = useCallback(async () => {
const active = await getActiveRestaurant();
if (!active) {
router.back();
return;
}
configurePurchases(active.restaurantId);
try {
const [offer, info] = await Promise.all([getCurrentOffering(), getCustomerInfo()]);
setOffering(offer);
setCustomerInfo(info);
} catch (err) {
Alert.alert("Hata", err instanceof Error ? err.message : "Abonelik bilgisi yüklenemedi.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function onPurchase(pkg: PurchasesPackage) {
setPurchasingPackageId(pkg.identifier);
try {
const { customerInfo: updated } = await Purchases.purchasePackage(pkg);
setCustomerInfo(updated);
if (isProActive(updated)) {
Alert.alert("Teşekkürler!", "Menulio Pro aktif edildi.");
}
} catch (err) {
const rcError = err as { userCancelled?: boolean; message?: string };
if (!rcError.userCancelled) {
Alert.alert("Satın alma başarısız", rcError.message ?? "Bir hata oluştu.");
}
} finally {
setPurchasingPackageId(null);
}
}
async function onRestore() {
setRestoring(true);
try {
const info = await Purchases.restorePurchases();
setCustomerInfo(info);
Alert.alert(
isProActive(info) ? "Abonelik bulundu" : "Aktif abonelik yok",
isProActive(info) ? "Menulio Pro aboneliğiniz geri yüklendi." : "Bu hesaba bağlı aktif bir abonelik bulunamadı.",
);
} catch (err) {
Alert.alert("Hata", err instanceof Error ? err.message : "Abonelik geri yüklenemedi.");
} finally {
setRestoring(false);
}
}
const isPro = customerInfo ? isProActive(customerInfo) : false;
const expiresAt = customerInfo?.entitlements.active[PRO_ENTITLEMENT_ID]?.expirationDate;
if (loading) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#FAF8F5" }}>
<ActivityIndicator size="large" color="#C8A96B" />
</View>
);
}
return (
<ScrollView style={{ flex: 1, backgroundColor: "#FAF8F5" }} contentContainerStyle={{ padding: 20, gap: 16 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Pressable onPress={() => router.back()} style={{ padding: 4 }}>
<Ionicons name="arrow-back" size={22} color="#1C1917" />
</Pressable>
<Text style={{ fontSize: 22, fontWeight: "800", color: "#1C1917" }}>Abonelik</Text>
</View>
<View
style={{
backgroundColor: isPro ? "#F0FDF4" : "#FFFFFF",
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: isPro ? "#BBF7D0" : "#E7E5E4",
flexDirection: "row",
alignItems: "center",
gap: 10,
}}
>
<Ionicons name={isPro ? "checkmark-circle" : "information-circle-outline"} size={22} color={isPro ? "#059669" : "#78716C"} />
<View style={{ flex: 1 }}>
<Text style={{ fontWeight: "700", color: "#1C1917" }}>{isPro ? "Menulio Pro aktif" : "Ücretsiz plan"}</Text>
{isPro && expiresAt ? (
<Text style={{ fontSize: 12, color: "#78716C", marginTop: 2 }}>
Yenileme: {new Date(expiresAt).toLocaleDateString("tr-TR")}
</Text>
) : null}
</View>
</View>
{!offering || offering.availablePackages.length === 0 ? (
<Text style={{ color: "#78716C", textAlign: "center", marginTop: 24 }}>
Şu anda satın alınabilir bir paket bulunamadı.
</Text>
) : (
<View style={{ gap: 12 }}>
{offering.availablePackages.map((pkg) => (
<Pressable
key={pkg.identifier}
onPress={() => onPurchase(pkg)}
disabled={purchasingPackageId !== null}
style={{
backgroundColor: "#FFFFFF",
borderRadius: 16,
padding: 16,
borderWidth: 1.5,
borderColor: "#C8A96B",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
opacity: purchasingPackageId && purchasingPackageId !== pkg.identifier ? 0.5 : 1,
}}
>
<View>
<Text style={{ fontSize: 16, fontWeight: "800", color: "#1C1917" }}>
{pkg.packageType === "ANNUAL" ? "Yıllık" : "Aylık"}
</Text>
<Text style={{ fontSize: 13, color: "#78716C", marginTop: 2 }}>
{pkg.product.priceString} / {pkg.packageType === "ANNUAL" ? "yıl" : "ay"}
</Text>
</View>
{purchasingPackageId === pkg.identifier ? (
<ActivityIndicator color="#C8A96B" />
) : (
<Ionicons name="chevron-forward" size={20} color="#C8A96B" />
)}
</Pressable>
))}
</View>
)}
<Pressable onPress={onRestore} disabled={restoring} style={{ padding: 12, alignItems: "center" }}>
{restoring ? (
<ActivityIndicator color="#78716C" />
) : (
<Text style={{ color: "#78716C", fontSize: 14, fontWeight: "600" }}>Satın Alımları Geri Yükle</Text>
)}
</Pressable>
</ScrollView>
);
}