diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 430097e..332cb56 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -30,6 +30,7 @@ "expo-status-bar": "~2.0.0", "react": "18.3.1", "react-native": "0.76.9", + "react-native-purchases": "^8.2.0", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-url-polyfill": "^2.0.0" diff --git a/apps/mobile/src/app/(onboarding)/restaurant.tsx b/apps/mobile/src/app/(onboarding)/restaurant.tsx index 98b7498..0b605e1 100644 --- a/apps/mobile/src/app/(onboarding)/restaurant.tsx +++ b/apps/mobile/src/app/(onboarding)/restaurant.tsx @@ -4,6 +4,7 @@ import { ActivityIndicator, Pressable, ScrollView, Text, TextInput, View } from import { Ionicons } from "@expo/vector-icons"; import { api } from "@/lib/api"; import { setActiveRestaurant } from "@/lib/active-restaurant"; +import { configurePurchases } from "@/lib/purchases"; interface CreateRestaurantResponse { restaurant: { id: string; slug: string }; @@ -34,6 +35,7 @@ export default function OnboardingRestaurantStep() { menuId: menu.id, slug: restaurant.slug, }); + configurePurchases(restaurant.id); router.replace("/(onboarding)/scan"); } catch (err) { diff --git a/apps/mobile/src/app/account/index.tsx b/apps/mobile/src/app/account/index.tsx index ef192d6..42913cf 100644 --- a/apps/mobile/src/app/account/index.tsx +++ b/apps/mobile/src/app/account/index.tsx @@ -434,6 +434,25 @@ export default function AccountScreen() { + {/* Subscription */} + router.push("/account/subscription")} + style={{ + backgroundColor: "#FFFFFF", + borderRadius: 20, + padding: 20, + borderWidth: 1, + borderColor: "#E7E5E4", + flexDirection: "row", + alignItems: "center", + gap: 12, + }} + > + + Abonelik + + + {/* Live Menu URL & Quick Actions */} (null); + const [customerInfo, setCustomerInfo] = useState(null); + const [purchasingPackageId, setPurchasingPackageId] = useState(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 ( + + + + ); + } + + return ( + + + router.back()} style={{ padding: 4 }}> + + + Abonelik + + + + + + {isPro ? "Menulio Pro aktif" : "Ücretsiz plan"} + {isPro && expiresAt ? ( + + Yenileme: {new Date(expiresAt).toLocaleDateString("tr-TR")} + + ) : null} + + + + {!offering || offering.availablePackages.length === 0 ? ( + + Şu anda satın alınabilir bir paket bulunamadı. + + ) : ( + + {offering.availablePackages.map((pkg) => ( + 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, + }} + > + + + {pkg.packageType === "ANNUAL" ? "Yıllık" : "Aylık"} + + + {pkg.product.priceString} / {pkg.packageType === "ANNUAL" ? "yıl" : "ay"} + + + {purchasingPackageId === pkg.identifier ? ( + + ) : ( + + )} + + ))} + + )} + + + {restoring ? ( + + ) : ( + Satın Alımları Geri Yükle + )} + + + ); +} diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index cc5e1a1..aeb22ee 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -3,6 +3,7 @@ import { router } from "expo-router"; import { ActivityIndicator, View } from "react-native"; import { api, ApiError } from "@/lib/api"; import { clearActiveRestaurant, getActiveRestaurant, setActiveRestaurant } from "@/lib/active-restaurant"; +import { configurePurchases } from "@/lib/purchases"; import { supabase } from "@/lib/supabase"; interface MeRestaurantResponse { @@ -31,6 +32,7 @@ export default function Index() { const cached = await getActiveRestaurant(); if (cached) { + configurePurchases(cached.restaurantId); if (!cancelled) { router.replace("/menu"); } @@ -48,6 +50,7 @@ export default function Index() { menuId: menu.id, slug: restaurant.slug, }); + configurePurchases(restaurant.id); if (!cancelled) { router.replace("/menu"); } diff --git a/apps/mobile/src/lib/purchases.ts b/apps/mobile/src/lib/purchases.ts new file mode 100644 index 0000000..d596a35 --- /dev/null +++ b/apps/mobile/src/lib/purchases.ts @@ -0,0 +1,37 @@ +import { Platform } from "react-native"; +import Purchases, { type CustomerInfo, type PurchasesOffering } from "react-native-purchases"; + +const API_KEY = process.env.EXPO_PUBLIC_REVENUECAT_API_KEY; +export const PRO_ENTITLEMENT_ID = "Menul.io Pro"; + +let configuredForRestaurantId: string | null = null; + +// appUserID is the restaurant id — the backend webhook (POST +// /subscription/webhook) maps RevenueCat's app_user_id straight to +// subscriptions.restaurant_id, so these must always match. +export function configurePurchases(restaurantId: string) { + if (!API_KEY) { + console.warn("EXPO_PUBLIC_REVENUECAT_API_KEY missing — purchases disabled"); + return; + } + if (configuredForRestaurantId === restaurantId) return; + + Purchases.configure({ apiKey: API_KEY, appUserID: restaurantId }); + if (Platform.OS !== "web") { + Purchases.setLogLevel(__DEV__ ? Purchases.LOG_LEVEL.DEBUG : Purchases.LOG_LEVEL.WARN); + } + configuredForRestaurantId = restaurantId; +} + +export async function getCurrentOffering(): Promise { + const offerings = await Purchases.getOfferings(); + return offerings.current; +} + +export async function getCustomerInfo(): Promise { + return Purchases.getCustomerInfo(); +} + +export function isProActive(info: CustomerInfo): boolean { + return info.entitlements.active[PRO_ENTITLEMENT_ID] !== undefined; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f67b514..bdf4d13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: react-native: specifier: 0.76.9 version: 0.76.9(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1) + react-native-purchases: + specifier: ^8.2.0 + version: 8.12.0(react-native@0.76.9(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1) react-native-safe-area-context: specifier: 4.12.0 version: 4.12.0(react-native@0.76.9(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1) @@ -1768,6 +1771,9 @@ packages: '@react-navigation/routers@7.6.4': resolution: {integrity: sha512-GI7eJm8/KsZUQaYcXvEExikKurRZRgEsSzyZ7faENfi65yqJBCXjDMwyN1pF6pNW1MoLH1ErDwDivFxY6BzD3w==} + '@revenuecat/purchases-typescript-internal@14.3.0': + resolution: {integrity: sha512-P3IhlWvH4wJAM9ypv8HamdIBMQfnLdU9PbjURw+s7NxHOL8LmPGhKuyv+gBINpka27mt1CAsDoOhNHjkexJhkg==} + '@rollup/rollup-android-arm-eabi@4.62.4': resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] @@ -4123,6 +4129,12 @@ packages: react: '*' react-native: '*' + react-native-purchases@8.12.0: + resolution: {integrity: sha512-0T6WtSDN96swsS6iLeTh7GEGLSedBr4FWP5JsGLnP6Ri7Rp754pf3UJ+S0+ZnT1cqUZ4W2z2oB6zTLyyfNfuKw==} + peerDependencies: + react: '>= 16.6.3' + react-native: '*' + react-native-safe-area-context@4.12.0: resolution: {integrity: sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==} peerDependencies: @@ -6817,6 +6829,8 @@ snapshots: dependencies: nanoid: 3.3.18 + '@revenuecat/purchases-typescript-internal@14.3.0': {} + '@rollup/rollup-android-arm-eabi@4.62.4': optional: true @@ -9316,6 +9330,12 @@ snapshots: react: 18.3.1 react-native: 0.76.9(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1) + react-native-purchases@8.12.0(react-native@0.76.9(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1): + dependencies: + '@revenuecat/purchases-typescript-internal': 14.3.0 + react: 18.3.1 + react-native: 0.76.9(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1) + react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1): dependencies: react: 18.3.1