diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx
index 491cb89472..570ef25b76 100644
--- a/apps/expo/app/(app)/ai-chat.tsx
+++ b/apps/expo/app/(app)/ai-chat.tsx
@@ -34,6 +34,7 @@ import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureU
import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit';
import { getPackItems, packItemsStore } from 'expo-app/features/packs/store/packItems';
import { packsStore } from 'expo-app/features/packs/store/packs';
+import { PaywallGate } from 'expo-app/features/purchases';
import { useActiveLocation } from 'expo-app/features/weather/hooks';
import type { WeatherLocation } from 'expo-app/features/weather/types';
import { authClient, getStoredSessionToken } from 'expo-app/lib/auth-client';
@@ -78,7 +79,7 @@ const ROOT_STYLE: ViewStyle = {
minHeight: 2,
};
-export default function AIChat() {
+function AIChat() {
const { colors, isDarkColorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
const { progress } = useReanimatedKeyboardAnimation();
@@ -665,3 +666,11 @@ function Composer({
);
}
+
+export default function AIChatRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/pack-templates/index.tsx b/apps/expo/app/(app)/pack-templates/index.tsx
index 002a312723..91fe05de18 100644
--- a/apps/expo/app/(app)/pack-templates/index.tsx
+++ b/apps/expo/app/(app)/pack-templates/index.tsx
@@ -1,5 +1,10 @@
import { PackTemplateListScreen } from 'expo-app/features/pack-templates/screens/PackTemplateListScreen';
+import { PaywallGate } from 'expo-app/features/purchases';
-export default function () {
- return ;
+export default function PackTemplatesRoute() {
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/season-suggestions-results.tsx b/apps/expo/app/(app)/season-suggestions-results.tsx
index ed3c123d01..02b5f5286c 100644
--- a/apps/expo/app/(app)/season-suggestions-results.tsx
+++ b/apps/expo/app/(app)/season-suggestions-results.tsx
@@ -9,6 +9,7 @@ import {
SeasonSuggestionsError,
useSeasonSuggestions,
} from 'expo-app/features/packs/hooks/useSeasonSuggestions';
+import { PaywallGate } from 'expo-app/features/purchases';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { LinearGradient } from 'expo-linear-gradient';
@@ -330,7 +331,7 @@ function ErrorCard({ error, onRetry, onGoBack, onGoToInventory, onSignIn }: Erro
);
}
-export default function SeasonSuggestionsResultsScreen() {
+function SeasonSuggestionsResultsScreen() {
const router = useRouter();
const { t } = useTranslation();
const { location, date } = useLocalSearchParams<{ location: string; date: string }>();
@@ -520,3 +521,11 @@ export default function SeasonSuggestionsResultsScreen() {
>
);
}
+
+export default function SeasonSuggestionsResultsRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/season-suggestions.tsx b/apps/expo/app/(app)/season-suggestions.tsx
index b2cc57ce44..f20683adf4 100644
--- a/apps/expo/app/(app)/season-suggestions.tsx
+++ b/apps/expo/app/(app)/season-suggestions.tsx
@@ -6,6 +6,7 @@ import * as Sentry from '@sentry/react-native';
import { Icon } from 'expo-app/components/Icon';
import { LocationSearchSheet } from 'expo-app/features/packs/components/LocationSearchSheet';
import { LocationSourceSheet } from 'expo-app/features/packs/components/LocationSourceSheet';
+import { PaywallGate } from 'expo-app/features/purchases';
import { useBottomSheetAction } from 'expo-app/lib/hooks/useBottomSheetAction';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import * as Location from 'expo-location';
@@ -13,7 +14,7 @@ import { Stack, useRouter } from 'expo-router';
import { useRef, useState } from 'react';
import { ActivityIndicator, Alert, Linking, Platform, ScrollView, View } from 'react-native';
-export default function SeasonSuggestionsScreen() {
+function SeasonSuggestionsScreen() {
const router = useRouter();
const { t } = useTranslation();
const [isGettingLocation, setIsGettingLocation] = useState(false);
@@ -161,3 +162,11 @@ export default function SeasonSuggestionsScreen() {
>
);
}
+
+export default function SeasonSuggestionsRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/shared-packs.tsx b/apps/expo/app/(app)/shared-packs.tsx
index f90e653b1d..3075ae5165 100644
--- a/apps/expo/app/(app)/shared-packs.tsx
+++ b/apps/expo/app/(app)/shared-packs.tsx
@@ -1,5 +1,6 @@
import { Avatar, AvatarFallback, AvatarImage, Text } from '@packrat/ui/nativewindui';
import { getAppBarOptions } from '@packrat/ui/src/app-bar';
+import { PaywallGate } from 'expo-app/features/purchases';
import { cn } from 'expo-app/lib/cn';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { Stack } from 'expo-router';
@@ -173,7 +174,7 @@ function SharedPackCard({ pack }: { pack: (typeof SHARED_PACKS)[0] }) {
);
}
-export default function SharedPacksScreen() {
+function SharedPacksScreen() {
const { t } = useTranslation();
return (
@@ -210,3 +211,11 @@ export default function SharedPacksScreen() {
);
}
+
+export default function SharedPacksRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/shopping-list.tsx b/apps/expo/app/(app)/shopping-list.tsx
index 8b2fb77988..c073965768 100644
--- a/apps/expo/app/(app)/shopping-list.tsx
+++ b/apps/expo/app/(app)/shopping-list.tsx
@@ -3,6 +3,7 @@
import { Text } from '@packrat/ui/nativewindui';
import { getAppBarOptions } from '@packrat/ui/src/app-bar';
import { Icon } from 'expo-app/components/Icon';
+import { PaywallGate } from 'expo-app/features/purchases';
import { cn } from 'expo-app/lib/cn';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
@@ -159,7 +160,7 @@ function ShoppingItemCard({ item }: { item: (typeof SHOPPING_LIST)[0] }) {
);
}
-export default function ShoppingListScreen() {
+function ShoppingListScreen() {
const { t } = useTranslation();
const [filter, setFilter] = useState<'all' | 'pending' | 'purchased'>('pending');
@@ -257,3 +258,11 @@ export default function ShoppingListScreen() {
);
}
+
+export default function ShoppingListRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/trail-conditions.tsx b/apps/expo/app/(app)/trail-conditions.tsx
index 441185aca7..a26c7ab776 100644
--- a/apps/expo/app/(app)/trail-conditions.tsx
+++ b/apps/expo/app/(app)/trail-conditions.tsx
@@ -1,6 +1,7 @@
import { ActivityIndicator, Text } from '@packrat/ui/nativewindui';
import { getAppBarOptions } from '@packrat/ui/src/app-bar';
import { featureFlags } from 'expo-app/config';
+import { PaywallGate } from 'expo-app/features/purchases';
import { SubmitConditionReportForm } from 'expo-app/features/trail-conditions/components/SubmitConditionReportForm';
import { TrailConditionReportCard } from 'expo-app/features/trail-conditions/components/TrailConditionReportCard';
import { useTrailConditionReports } from 'expo-app/features/trail-conditions/hooks/useTrailConditionReports';
@@ -37,7 +38,7 @@ function getSurfaceBadgeColor(surface: TrailSurface): string {
}
}
-export default function TrailConditionsScreen() {
+function TrailConditionsScreen() {
const [showSubmitForm, setShowSubmitForm] = useState(false);
const [selectedSurface, setSelectedSurface] = useState('all');
const { data: reports, isLoading, error } = useTrailConditionReports();
@@ -219,3 +220,11 @@ export default function TrailConditionsScreen() {
);
}
+
+export default function TrailConditionsRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/weather-alerts.tsx b/apps/expo/app/(app)/weather-alerts.tsx
index 96ef2c07e7..26320aceb8 100644
--- a/apps/expo/app/(app)/weather-alerts.tsx
+++ b/apps/expo/app/(app)/weather-alerts.tsx
@@ -1,6 +1,7 @@
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Text } from '@packrat/ui/nativewindui';
import { getAppBarOptions } from '@packrat/ui/src/app-bar';
+import { PaywallGate } from 'expo-app/features/purchases';
import { useWeatherAlerts } from 'expo-app/features/weather/hooks/useWeatherAlert';
import { cn } from 'expo-app/lib/cn';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
@@ -128,7 +129,7 @@ function WeatherAlertCard({ alert }: { alert: WeatherAlert }) {
);
}
-export default function WeatherAlertsScreen() {
+function WeatherAlertsScreen() {
const { t } = useTranslation();
const router = useRouter();
const { alerts, loading, error, activeLocation } = useWeatherAlerts();
@@ -185,3 +186,11 @@ export default function WeatherAlertsScreen() {
);
}
+
+export default function WeatherAlertsRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/weight-analysis/[id].tsx b/apps/expo/app/(app)/weight-analysis/[id].tsx
index d0cf934fbd..38e9cd0e88 100644
--- a/apps/expo/app/(app)/weight-analysis/[id].tsx
+++ b/apps/expo/app/(app)/weight-analysis/[id].tsx
@@ -4,6 +4,7 @@ import { Text } from '@packrat/ui/nativewindui';
import { getAppBarOptions } from '@packrat/ui/src/app-bar';
import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit';
import { usePackWeightAnalysis } from 'expo-app/features/packs/hooks/usePackWeightAnalysis';
+import { PaywallGate } from 'expo-app/features/purchases';
import { cn } from 'expo-app/lib/cn';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { Stack, useLocalSearchParams } from 'expo-router';
@@ -38,7 +39,7 @@ function WeightCard({
);
}
-export default function WeightAnalysisScreen() {
+function WeightAnalysisScreen() {
const params = useLocalSearchParams();
const packId = params.id;
const { t } = useTranslation();
@@ -138,3 +139,11 @@ export default function WeightAnalysisScreen() {
);
}
+
+export default function WeightAnalysisRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/wildlife/index.tsx b/apps/expo/app/(app)/wildlife/index.tsx
index 47b5afb522..b88f107e42 100644
--- a/apps/expo/app/(app)/wildlife/index.tsx
+++ b/apps/expo/app/(app)/wildlife/index.tsx
@@ -1,4 +1,5 @@
import { featureFlags } from 'expo-app/config';
+import { PaywallGate } from 'expo-app/features/purchases';
import { WildlifeScreen } from 'expo-app/features/wildlife/screens/WildlifeScreen';
import { Redirect } from 'expo-router';
@@ -6,5 +7,9 @@ export default function WildlifeRoute() {
if (!featureFlags.enableWildlifeIdentification) {
return ;
}
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/features/purchases/components/PaywallGate.tsx b/apps/expo/features/purchases/components/PaywallGate.tsx
new file mode 100644
index 0000000000..003c6a8193
--- /dev/null
+++ b/apps/expo/features/purchases/components/PaywallGate.tsx
@@ -0,0 +1,26 @@
+import { ActivityIndicator } from '@packrat/ui/nativewindui';
+import { View } from 'react-native';
+import { useEntitlement } from '../hooks/useEntitlement';
+import { UpgradePrompt } from './UpgradePrompt';
+
+interface PaywallGateProps {
+ children: React.ReactNode;
+}
+
+export function PaywallGate({ children }: PaywallGateProps) {
+ const { isPro, isLoading } = useEntitlement();
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!isPro) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/expo/features/purchases/components/UpgradePrompt.tsx b/apps/expo/features/purchases/components/UpgradePrompt.tsx
new file mode 100644
index 0000000000..78b2e64391
--- /dev/null
+++ b/apps/expo/features/purchases/components/UpgradePrompt.tsx
@@ -0,0 +1,110 @@
+import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui';
+import * as Sentry from '@sentry/react-native';
+import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
+import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
+import { useState } from 'react';
+import { ScrollView, View } from 'react-native';
+import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
+import { useEntitlement } from '../hooks/useEntitlement';
+import { ENTITLEMENT_PRO, PRO_FEATURES } from '../lib/config';
+
+export function UpgradePrompt() {
+ const { t } = useTranslation();
+ const { colors } = useColorScheme();
+ const { invalidate } = useEntitlement();
+ const [isPresentingPaywall, setIsPresentingPaywall] = useState(false);
+
+ async function handleViewPlans() {
+ setIsPresentingPaywall(true);
+ try {
+ const result = await RevenueCatUI.presentPaywallIfNeeded({
+ requiredEntitlementIdentifier: ENTITLEMENT_PRO,
+ });
+
+ if (result === PAYWALL_RESULT.PURCHASED || result === PAYWALL_RESULT.RESTORED) {
+ invalidate();
+ }
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'presentPaywall' },
+ });
+ } finally {
+ setIsPresentingPaywall(false);
+ }
+ }
+
+ async function handleRestore() {
+ setIsPresentingPaywall(true);
+ try {
+ await RevenueCatUI.presentPaywallIfNeeded({
+ requiredEntitlementIdentifier: ENTITLEMENT_PRO,
+ });
+ invalidate();
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'restore' },
+ });
+ } finally {
+ setIsPresentingPaywall(false);
+ }
+ }
+
+ return (
+
+
+
+
+ 🎒
+
+
+
+ {t('purchases.upgradeTitle')}
+
+
+
+ {t('purchases.upgradeSubtitle')}
+
+
+
+
+ {PRO_FEATURES.map((feature) => (
+
+
+ ✓
+
+ {feature.label}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/expo/features/purchases/hooks/useEntitlement.ts b/apps/expo/features/purchases/hooks/useEntitlement.ts
new file mode 100644
index 0000000000..7e661daabd
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/useEntitlement.ts
@@ -0,0 +1,32 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import Purchases from 'react-native-purchases';
+import { ENTITLEMENT_PRO } from '../lib/config';
+import { isRevenueCatInitialized } from '../lib/init';
+
+export const CUSTOMER_INFO_QUERY_KEY = ['customerInfo'] as const;
+
+export function useEntitlement() {
+ const queryClient = useQueryClient();
+
+ const {
+ data: customerInfo,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: CUSTOMER_INFO_QUERY_KEY,
+ queryFn: () => Purchases.getCustomerInfo(),
+ enabled: isRevenueCatInitialized(),
+ staleTime: 1000 * 60 * 5,
+ retry: 1,
+ });
+
+ const isPro =
+ isRevenueCatInitialized() &&
+ typeof customerInfo?.entitlements.active[ENTITLEMENT_PRO] !== 'undefined';
+
+ function invalidate() {
+ queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY });
+ }
+
+ return { isPro, customerInfo, isLoading, error, invalidate };
+}
diff --git a/apps/expo/features/purchases/hooks/useIdentifyUser.ts b/apps/expo/features/purchases/hooks/useIdentifyUser.ts
new file mode 100644
index 0000000000..502e18ee72
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/useIdentifyUser.ts
@@ -0,0 +1,36 @@
+import * as Sentry from '@sentry/react-native';
+import { useQueryClient } from '@tanstack/react-query';
+import { authClient } from 'expo-app/lib/auth-client';
+import { useEffect } from 'react';
+import Purchases from 'react-native-purchases';
+import { isRevenueCatInitialized } from '../lib/init';
+import { CUSTOMER_INFO_QUERY_KEY } from './useEntitlement';
+
+export function useIdentifyUser() {
+ const queryClient = useQueryClient();
+ const { data: session } = authClient.useSession();
+ const userId = session?.user?.id;
+
+ useEffect(() => {
+ if (!isRevenueCatInitialized()) return;
+
+ if (userId) {
+ Purchases.logIn(userId)
+ .then(() => queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY }))
+ .catch((error: unknown) => {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'logIn' },
+ extra: { userId },
+ });
+ });
+ } else {
+ Purchases.logOut()
+ .then(() => queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY }))
+ .catch((error: unknown) => {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'logOut' },
+ });
+ });
+ }
+ }, [userId, queryClient]);
+}
diff --git a/apps/expo/features/purchases/index.ts b/apps/expo/features/purchases/index.ts
new file mode 100644
index 0000000000..8a173e2d7e
--- /dev/null
+++ b/apps/expo/features/purchases/index.ts
@@ -0,0 +1,6 @@
+export { PaywallGate } from './components/PaywallGate';
+export { UpgradePrompt } from './components/UpgradePrompt';
+export { useEntitlement } from './hooks/useEntitlement';
+export { useIdentifyUser } from './hooks/useIdentifyUser';
+export { ENTITLEMENT_PRO, PRO_FEATURES } from './lib/config';
+export { initRevenueCat, isRevenueCatInitialized } from './lib/init';
diff --git a/apps/expo/features/purchases/lib/config.ts b/apps/expo/features/purchases/lib/config.ts
new file mode 100644
index 0000000000..6ab1a71368
--- /dev/null
+++ b/apps/expo/features/purchases/lib/config.ts
@@ -0,0 +1,12 @@
+export const ENTITLEMENT_PRO = 'pro';
+
+export const PRO_FEATURES = [
+ { id: 'ai', label: 'AI Chat & Smart Suggestions' },
+ { id: 'weather', label: 'Weather Alerts & Forecasts' },
+ { id: 'trail', label: 'Trail Conditions' },
+ { id: 'weight', label: 'Pack Weight Analysis' },
+ { id: 'wildlife', label: 'Wildlife Identification' },
+ { id: 'shared', label: 'Shared Packs' },
+ { id: 'templates', label: 'Pack Templates' },
+ { id: 'shopping', label: 'Shopping List' },
+] as const;
diff --git a/apps/expo/features/purchases/lib/init.ts b/apps/expo/features/purchases/lib/init.ts
new file mode 100644
index 0000000000..91c7a64023
--- /dev/null
+++ b/apps/expo/features/purchases/lib/init.ts
@@ -0,0 +1,33 @@
+import { clientEnvs } from '@packrat/env/expo-client';
+import { Platform } from 'react-native';
+import Purchases, { LOG_LEVEL } from 'react-native-purchases';
+
+let initialized = false;
+
+export function initRevenueCat(): void {
+ const apiKey = Platform.select({
+ ios: clientEnvs.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY,
+ android: clientEnvs.EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY,
+ default: clientEnvs.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY,
+ });
+
+ if (!apiKey) {
+ if (__DEV__) {
+ console.warn('[RevenueCat] No API key configured — purchases disabled.');
+ }
+ return;
+ }
+
+ if (initialized) return;
+
+ if (__DEV__) {
+ Purchases.setLogLevel(LOG_LEVEL.DEBUG);
+ }
+
+ Purchases.configure({ apiKey });
+ initialized = true;
+}
+
+export function isRevenueCatInitialized(): boolean {
+ return initialized;
+}
diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json
index 18c9aaab6f..6f7963fa26 100644
--- a/apps/expo/lib/i18n/locales/en.json
+++ b/apps/expo/lib/i18n/locales/en.json
@@ -1218,5 +1218,11 @@
"fish": "fish",
"other": "other"
}
+ },
+ "purchases": {
+ "upgradeTitle": "PackRat Pro",
+ "upgradeSubtitle": "Unlock the full outdoor adventure experience",
+ "viewPlans": "View Plans",
+ "restorePurchases": "Restore Purchases"
}
}
diff --git a/apps/expo/package.json b/apps/expo/package.json
index cd2c886971..e383d1a2a2 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -148,6 +148,8 @@
"react-native-keyboard-controller": "1.21.6",
"react-native-maps": "1.27.2",
"react-native-pager-view": "8.0.1",
+ "react-native-purchases": "^9.0.0",
+ "react-native-purchases-ui": "^9.0.0",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
diff --git a/apps/expo/providers/PurchasesProvider.tsx b/apps/expo/providers/PurchasesProvider.tsx
new file mode 100644
index 0000000000..7c8a63b047
--- /dev/null
+++ b/apps/expo/providers/PurchasesProvider.tsx
@@ -0,0 +1,20 @@
+import { initRevenueCat, useIdentifyUser } from 'expo-app/features/purchases';
+import { useEffect } from 'react';
+
+function PurchasesEffects() {
+ useIdentifyUser();
+ return null;
+}
+
+export function PurchasesProvider({ children }: { children: React.ReactNode }) {
+ useEffect(() => {
+ initRevenueCat();
+ }, []);
+
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/apps/expo/providers/index.tsx b/apps/expo/providers/index.tsx
index 8bb092b9a4..8e029c071e 100644
--- a/apps/expo/providers/index.tsx
+++ b/apps/expo/providers/index.tsx
@@ -7,6 +7,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { JotaiProvider } from './JotaiProvider';
+import { PurchasesProvider } from './PurchasesProvider';
import { TanstackProvider } from './TanstackProvider';
export function Providers({ children }: { children: React.ReactNode }) {
@@ -14,16 +15,18 @@ export function Providers({ children }: { children: React.ReactNode }) {
-
-
-
-
- {children}
-
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
+
diff --git a/apps/expo/types/react-native-purchases-ui.d.ts b/apps/expo/types/react-native-purchases-ui.d.ts
new file mode 100644
index 0000000000..1efff27704
--- /dev/null
+++ b/apps/expo/types/react-native-purchases-ui.d.ts
@@ -0,0 +1,35 @@
+// Minimal type shim for react-native-purchases-ui.
+// Full types are provided by the installed package after `bun install`.
+declare module 'react-native-purchases-ui' {
+ import type { ReactNode } from 'react';
+
+ export enum PAYWALL_RESULT {
+ NOT_PRESENTED = 'NOT_PRESENTED',
+ ERROR = 'ERROR',
+ CANCELLED = 'CANCELLED',
+ PURCHASED = 'PURCHASED',
+ RESTORED = 'RESTORED',
+ }
+
+ export interface PaywallProps {
+ onPurchaseCompleted?: (event: {
+ customerInfo: import('react-native-purchases').CustomerInfo;
+ }) => void;
+ onRestoreCompleted?: (event: {
+ customerInfo: import('react-native-purchases').CustomerInfo;
+ }) => void;
+ onDismiss?: () => void;
+ children?: ReactNode;
+ }
+
+ export interface PresentPaywallIfNeededOptions {
+ requiredEntitlementIdentifier: string;
+ }
+
+ const RevenueCatUI: {
+ Paywall: (props: PaywallProps) => ReactNode;
+ presentPaywallIfNeeded(options: PresentPaywallIfNeededOptions): Promise;
+ };
+
+ export default RevenueCatUI;
+}
diff --git a/apps/expo/types/react-native-purchases.d.ts b/apps/expo/types/react-native-purchases.d.ts
new file mode 100644
index 0000000000..044717557a
--- /dev/null
+++ b/apps/expo/types/react-native-purchases.d.ts
@@ -0,0 +1,37 @@
+// Minimal type shim for react-native-purchases.
+// Full types are provided by the installed package after `bun install`.
+declare module 'react-native-purchases' {
+ export enum LOG_LEVEL {
+ VERBOSE = 'VERBOSE',
+ DEBUG = 'DEBUG',
+ INFO = 'INFO',
+ WARN = 'WARN',
+ ERROR = 'ERROR',
+ }
+
+ export interface CustomerInfo {
+ entitlements: {
+ active: Record;
+ };
+ }
+
+ export interface EntitlementInfo {
+ identifier: string;
+ isActive: boolean;
+ }
+
+ export interface LogInResult {
+ customerInfo: CustomerInfo;
+ created: boolean;
+ }
+
+ const Purchases: {
+ setLogLevel(level: LOG_LEVEL): void;
+ configure(options: { apiKey: string; useAmazon?: boolean }): void;
+ getCustomerInfo(): Promise;
+ logIn(appUserID: string): Promise;
+ logOut(): Promise;
+ };
+
+ export default Purchases;
+}
diff --git a/packages/env/src/expo-client.ts b/packages/env/src/expo-client.ts
index b56c2fc4cf..67bce48e1d 100644
--- a/packages/env/src/expo-client.ts
+++ b/packages/env/src/expo-client.ts
@@ -22,6 +22,8 @@ export const clientEnvSchema = z.object({
EXPO_PUBLIC_SENTRY_DSN: z.string().optional(),
EXPO_PUBLIC_GOOGLE_MAPS_API_KEY: z.string().optional(),
EXPO_PUBLIC_DISABLE_LOGBOX: z.enum(['true', 'false']).optional().default('false'),
+ EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY: z.string().optional(),
+ EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY: z.string().optional(),
});
export type ClientEnv = z.infer;
@@ -36,6 +38,8 @@ const processEnv = {
EXPO_PUBLIC_SENTRY_DSN: process.env.EXPO_PUBLIC_SENTRY_DSN,
EXPO_PUBLIC_GOOGLE_MAPS_API_KEY: process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY,
EXPO_PUBLIC_DISABLE_LOGBOX: process.env.EXPO_PUBLIC_DISABLE_LOGBOX,
+ EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY: process.env.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY,
+ EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY: process.env.EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY,
};
/**