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