diff --git a/apps/expo/app/(app)/(tabs)/catalog/index.tsx b/apps/expo/app/(app)/(tabs)/catalog/index.tsx
index deae5bbc28..e9654399ec 100644
--- a/apps/expo/app/(app)/(tabs)/catalog/index.tsx
+++ b/apps/expo/app/(app)/(tabs)/catalog/index.tsx
@@ -1,5 +1,10 @@
import CatalogItemsScreen from 'expo-app/features/catalog/screens/CatalogItemsScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function CatalogItemsPage() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/(tabs)/feed/index.tsx b/apps/expo/app/(app)/(tabs)/feed/index.tsx
index b85975166d..4095885089 100644
--- a/apps/expo/app/(app)/(tabs)/feed/index.tsx
+++ b/apps/expo/app/(app)/(tabs)/feed/index.tsx
@@ -1,5 +1,10 @@
import { FeedScreen } from 'expo-app/features/feed';
+import { ProGate } from 'expo-app/features/purchases';
export default function FeedRoute() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/(tabs)/trips/index.tsx b/apps/expo/app/(app)/(tabs)/trips/index.tsx
index 2cf2fc0227..f1727fcbe0 100644
--- a/apps/expo/app/(app)/(tabs)/trips/index.tsx
+++ b/apps/expo/app/(app)/(tabs)/trips/index.tsx
@@ -4,9 +4,6 @@ import { Redirect } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
export default function TripsScreen() {
- // Gate the tab route behind the trips feature flag. The tab trigger is
- // already hidden in the layout, but this also blocks deep links such as
- // `packrat://(tabs)/trips` from bypassing the kill switch.
if (!featureFlags.enableTrips) return ;
return ;
}
diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx
index c1be74e548..dfb83ff48c 100644
--- a/apps/expo/app/(app)/_layout.tsx
+++ b/apps/expo/app/(app)/_layout.tsx
@@ -14,6 +14,7 @@ import { getPackTemplateItemDetailOptions } from 'expo-app/features/pack-templat
import SyncBanner from 'expo-app/features/packs/components/SyncBanner';
import { getPackDetailOptions } from 'expo-app/features/packs/utils/getPackDetailOptions';
import { getPackItemDetailOptions } from 'expo-app/features/packs/utils/getPackItemDetailOptions';
+import { useRevenueCatUser } from 'expo-app/features/purchases';
import { getTripDetailOptions } from 'expo-app/features/trips/utils/getTripDetailOptions';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import type { TranslationFunction } from 'expo-app/lib/i18n/types';
@@ -33,6 +34,7 @@ export {
export default function AppLayout() {
const isLoading = useAuthInit();
const isAuthedValue = use$(isAuthed);
+ useRevenueCatUser();
const { t } = useTranslation();
const needsReauth = useAtomValue(needsReauthAtom);
const isLoadingGlobal = useAtomValue(isLoadingAtom);
@@ -170,15 +172,13 @@ export default function AppLayout() {
({
- presentation: 'modal',
- animation: 'fade_from_bottom', // for android
title: t('profile.settings'),
+ headerLargeTitle: true,
headerRight: () => ,
}) as const;
diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx
index 491cb89472..6072a31712 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 { ProGate } 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';
@@ -434,127 +435,131 @@ export default function AIChat() {
};
return (
- <>
- ,
- }}
- />
-
-
+ <>
+ ,
}}
+ />
+
-
-
-
-
-
-
- {messages.map((item, index) => {
- let userQuery: TextUIPart['text'] | undefined;
- if (item.role === 'assistant' && index > 1) {
- const userMessage = messages[index - 1];
- userQuery = userMessage?.parts.find((p) => p.type === 'text')?.text;
- }
+
+
+
+
+
+
- return (
- {
+ let userQuery: TextUIPart['text'] | undefined;
+ if (item.role === 'assistant' && index > 1) {
+ const userMessage = messages[index - 1];
+ userQuery = userMessage?.parts.find((p) => p.type === 'text')?.text;
+ }
+
+ return (
+
+ );
+ })}
+
+ {status === 'submitted' && (
+
- );
- })}
-
- {status === 'submitted' && (
-
- )}
- {status === 'error' && (
- handleRetry()} onClear={handleClear} />
- )}
- {messages.length < 2 && (
-
- {t('ai.suggestions')}
-
- {getContextualSuggestions({ context, isAuthenticated }).map((suggestion) => (
- handleSubmit(suggestion)}
- className="mb-2 rounded-3xl border border-border bg-card px-3 py-2"
- >
- {suggestion}
-
- ))}
+ )}
+ {status === 'error' && (
+ handleRetry()} onClear={handleClear} />
+ )}
+ {messages.length < 2 && (
+
+
+ {t('ai.suggestions')}
+
+
+ {getContextualSuggestions({ context, isAuthenticated }).map((suggestion) => (
+ handleSubmit(suggestion)}
+ className="mb-2 rounded-3xl border border-border bg-card px-3 py-2"
+ >
+ {suggestion}
+
+ ))}
+
-
- )}
-
-
-
-
-
- {
- handleSubmit();
- }}
- stop={stop}
- isLoading={isLoading}
- placeholder={
- context.contextType === 'general'
- ? t('ai.askAnythingOutdoors')
- : context.contextType === 'item'
- ? t('ai.askAboutItem')
- : t('ai.askAboutPack')
- }
- />
-
- {isArrowButtonVisible && status === 'ready' && (
-
-
-
- )}
- >
+ )}
+
+
+
+
+
+ {
+ handleSubmit();
+ }}
+ stop={stop}
+ isLoading={isLoading}
+ placeholder={
+ context.contextType === 'general'
+ ? t('ai.askAnythingOutdoors')
+ : context.contextType === 'item'
+ ? t('ai.askAboutItem')
+ : t('ai.askAboutPack')
+ }
+ />
+
+ {isArrowButtonVisible && status === 'ready' && (
+
+
+
+ )}
+ >
+
);
}
diff --git a/apps/expo/app/(app)/catalog/[id].tsx b/apps/expo/app/(app)/catalog/[id].tsx
index a9b7e076eb..38f8f441ef 100644
--- a/apps/expo/app/(app)/catalog/[id].tsx
+++ b/apps/expo/app/(app)/catalog/[id].tsx
@@ -1,5 +1,10 @@
import { CatalogItemDetailScreen } from 'expo-app/features/catalog/screens/CatalogItemDetailScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function CatalogItemDetailPage() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/catalog/add-to-pack/details.tsx b/apps/expo/app/(app)/catalog/add-to-pack/details.tsx
index bb5ca85870..1c9a1c3819 100644
--- a/apps/expo/app/(app)/catalog/add-to-pack/details.tsx
+++ b/apps/expo/app/(app)/catalog/add-to-pack/details.tsx
@@ -1,5 +1,10 @@
import { AddCatalogItemDetailsScreen } from 'expo-app/features/catalog/screens/AddCatalogItemDetailsScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function AddCatalogItemDetailsPage() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/catalog/add-to-pack/index.tsx b/apps/expo/app/(app)/catalog/add-to-pack/index.tsx
index 3328c1f4a0..233e345adb 100644
--- a/apps/expo/app/(app)/catalog/add-to-pack/index.tsx
+++ b/apps/expo/app/(app)/catalog/add-to-pack/index.tsx
@@ -1,5 +1,10 @@
import { PackSelectionScreen } from 'expo-app/features/catalog/screens/PackSelectionScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function PackSelectionPage() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/feed/[id].tsx b/apps/expo/app/(app)/feed/[id].tsx
index 59fcfdee8c..3cd8c20ee8 100644
--- a/apps/expo/app/(app)/feed/[id].tsx
+++ b/apps/expo/app/(app)/feed/[id].tsx
@@ -2,6 +2,7 @@ import { Text } from '@packrat/ui/nativewindui';
import { useQuery } from '@tanstack/react-query';
import { userStore } from 'expo-app/features/auth/store';
import { PostDetailScreen } from 'expo-app/features/feed';
+import { ProGate } from 'expo-app/features/purchases';
import { apiClient } from 'expo-app/lib/api/packrat';
import { useLocalSearchParams } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
@@ -36,5 +37,9 @@ export default function PostDetailRoute() {
);
}
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/feed/create.tsx b/apps/expo/app/(app)/feed/create.tsx
index 41603d7f8e..be8b248ad9 100644
--- a/apps/expo/app/(app)/feed/create.tsx
+++ b/apps/expo/app/(app)/feed/create.tsx
@@ -1,8 +1,13 @@
import { CreatePostScreen } from 'expo-app/features/feed';
+import { ProGate } from 'expo-app/features/purchases';
import { useRouter } from 'expo-router';
export default function CreatePostRoute() {
const router = useRouter();
- return router.back()} />;
+ return (
+
+ router.back()} />
+
+ );
}
diff --git a/apps/expo/app/(app)/guides/[id].tsx b/apps/expo/app/(app)/guides/[id].tsx
index 2e70061f1b..f519f950f4 100644
--- a/apps/expo/app/(app)/guides/[id].tsx
+++ b/apps/expo/app/(app)/guides/[id].tsx
@@ -1,5 +1,10 @@
import { GuideDetailScreen } from 'expo-app/features/guides/screens/GuideDetailScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function GuideDetailRoute() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/guides/index.tsx b/apps/expo/app/(app)/guides/index.tsx
index 389aab8e3c..17e6ba6fec 100644
--- a/apps/expo/app/(app)/guides/index.tsx
+++ b/apps/expo/app/(app)/guides/index.tsx
@@ -1,5 +1,10 @@
import { GuidesListScreen } from 'expo-app/features/guides/screens/GuidesListScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function GuidesRoute() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/messages/chat.android.tsx b/apps/expo/app/(app)/messages/chat.android.tsx
index 34ebcd27dc..812afab0dd 100644
--- a/apps/expo/app/(app)/messages/chat.android.tsx
+++ b/apps/expo/app/(app)/messages/chat.android.tsx
@@ -12,6 +12,7 @@ import { Portal } from '@rn-primitives/portal';
import { FlashList } from '@shopify/flash-list';
import { Icon } from 'expo-app/components/Icon';
import { TextInput } from 'expo-app/components/TextInput';
+import { ProGate } from 'expo-app/features/purchases';
import { cn } from 'expo-app/lib/cn';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { router, Stack } from 'expo-router';
@@ -76,67 +77,69 @@ export default function ChatAndroid() {
}
return (
- <>
-
+
+ <>
+
-
- }
- ListHeaderComponent={}
- keyboardDismissMode="on-drag"
- keyboardShouldPersistTaps="handled"
- scrollIndicatorInsets={{
- bottom: HEADER_HEIGHT + 10,
- top: insets.bottom + 2,
- }}
- data={messages}
- renderItem={({ item, index }) => {
- if (isString(item)) {
- return ;
- }
+
+ }
+ ListHeaderComponent={}
+ keyboardDismissMode="on-drag"
+ keyboardShouldPersistTaps="handled"
+ scrollIndicatorInsets={{
+ bottom: HEADER_HEIGHT + 10,
+ top: insets.bottom + 2,
+ }}
+ data={messages}
+ renderItem={({ item, index }) => {
+ if (isString(item)) {
+ return ;
+ }
- const nextMessage = messages[index - 1];
- const isSameNextSender = !isString(nextMessage)
- ? nextMessage?.sender === item.sender
- : false;
+ const nextMessage = messages[index - 1];
+ const isSameNextSender = !isString(nextMessage)
+ ? nextMessage?.sender === item.sender
+ : false;
- const previousMessage = messages[index + 1];
- const isSamePreviousSender = !isString(previousMessage)
- ? previousMessage?.sender === item.sender
- : false;
+ const previousMessage = messages[index + 1];
+ const isSamePreviousSender = !isString(previousMessage)
+ ? previousMessage?.sender === item.sender
+ : false;
- return (
-
- );
- }}
- />
-
+ return (
+
+ );
+ }}
+ />
+
-
-
-
- {selectedMessages.length > 0 && (
-
- )}
- >
+
+
+
+ {selectedMessages.length > 0 && (
+
+ )}
+ >
+
);
}
diff --git a/apps/expo/app/(app)/messages/chat.tsx b/apps/expo/app/(app)/messages/chat.tsx
index f6c94459ad..ccf506f89b 100644
--- a/apps/expo/app/(app)/messages/chat.tsx
+++ b/apps/expo/app/(app)/messages/chat.tsx
@@ -11,6 +11,7 @@ import {
import { FlashList } from '@shopify/flash-list';
import { Icon } from 'expo-app/components/Icon';
import { TextInput } from 'expo-app/components/TextInput';
+import { ProGate } from 'expo-app/features/purchases';
import { cn } from 'expo-app/lib/cn';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { BlurView } from 'expo-blur';
@@ -144,53 +145,55 @@ export default function ChatIos() {
});
return (
- <>
-
-
-
- }
- ListHeaderComponent={}
- keyboardDismissMode="on-drag"
- keyboardShouldPersistTaps="handled"
- scrollIndicatorInsets={{
- bottom: HEADER_HEIGHT + 10,
- top: insets.bottom + 2,
- }}
- data={messages}
- renderItem={({ item, index }) => {
- if (isString(item)) {
- return ;
- }
+
+ <>
+
+
+
+ }
+ ListHeaderComponent={}
+ keyboardDismissMode="on-drag"
+ keyboardShouldPersistTaps="handled"
+ scrollIndicatorInsets={{
+ bottom: HEADER_HEIGHT + 10,
+ top: insets.bottom + 2,
+ }}
+ data={messages}
+ renderItem={({ item, index }) => {
+ if (isString(item)) {
+ return ;
+ }
- const nextMessage = messages[index - 1];
- const isSameNextSender = !isString(nextMessage)
- ? nextMessage?.sender === item.sender
- : false;
+ const nextMessage = messages[index - 1];
+ const isSameNextSender = !isString(nextMessage)
+ ? nextMessage?.sender === item.sender
+ : false;
- return (
-
- );
- }}
- />
-
-
-
-
-
- >
+ return (
+
+ );
+ }}
+ />
+
+
+
+
+
+ >
+
);
}
diff --git a/apps/expo/app/(app)/messages/conversations.android.tsx b/apps/expo/app/(app)/messages/conversations.android.tsx
index 45508379e1..3e0098144d 100644
--- a/apps/expo/app/(app)/messages/conversations.android.tsx
+++ b/apps/expo/app/(app)/messages/conversations.android.tsx
@@ -16,6 +16,7 @@ import {
} from '@packrat/ui/nativewindui';
import { Portal } from '@rn-primitives/portal';
import { Icon } from 'expo-app/components/Icon';
+import { ProGate } from 'expo-app/features/purchases';
import { cn } from 'expo-app/lib/cn';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import * as Haptics from 'expo-haptics';
@@ -57,49 +58,51 @@ export default function ConversationsAndroidScreen() {
}
return (
- <>
-
-
-
} // Prevent last message from being blocked by the FAB/Toolbar
- renderItem={renderItem}
- />
- {Platform.OS === 'ios' ? (
-
- }
- rightView={}
- iosBlurIntensity={30}
- />
-
- ) : (
-
- )}
- {selectedMessages.length > 0 && (
-
+ <>
+
- )}
- >
+
+
} // Prevent last message from being blocked by the FAB/Toolbar
+ renderItem={renderItem}
+ />
+ {Platform.OS === 'ios' ? (
+
+ }
+ rightView={}
+ iosBlurIntensity={30}
+ />
+
+ ) : (
+
+ )}
+ {selectedMessages.length > 0 && (
+
+ )}
+ >
+
);
}
diff --git a/apps/expo/app/(app)/messages/conversations.tsx b/apps/expo/app/(app)/messages/conversations.tsx
index 3ba8e60484..f02e17b99d 100644
--- a/apps/expo/app/(app)/messages/conversations.tsx
+++ b/apps/expo/app/(app)/messages/conversations.tsx
@@ -16,6 +16,7 @@ import {
} from '@packrat/ui/nativewindui';
import { getAppBarOptions } from '@packrat/ui/src/app-bar';
import { Icon } from 'expo-app/components/Icon';
+import { ProGate } from 'expo-app/features/purchases';
import { cn } from 'expo-app/lib/cn';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import * as Haptics from 'expo-haptics';
@@ -78,31 +79,33 @@ export default function ConversationsIosScreen() {
}
return (
- <>
- (
-
- ),
- headerRight: rightView,
- headerSearchBarOptions: {
- hideWhenScrolling: true,
- },
- }}
- />
-
-
: undefined}
- keyExtractor={(item) => item.id}
- renderItem={renderItem}
- />
- {isSelecting && 0} />}
- >
+
+ <>
+ (
+
+ ),
+ headerRight: rightView,
+ headerSearchBarOptions: {
+ hideWhenScrolling: true,
+ },
+ }}
+ />
+
+
: undefined}
+ keyExtractor={(item) => item.id}
+ renderItem={renderItem}
+ />
+ {isSelecting && 0} />}
+ >
+
);
}
diff --git a/apps/expo/app/(app)/pack-stats/[id].tsx b/apps/expo/app/(app)/pack-stats/[id].tsx
index 6f23eab0d2..ca16422a2c 100644
--- a/apps/expo/app/(app)/pack-stats/[id].tsx
+++ b/apps/expo/app/(app)/pack-stats/[id].tsx
@@ -5,6 +5,7 @@ import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit';
import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore';
import { usePackWeightHistory } from 'expo-app/features/packs/hooks/usePackWeightHistory';
import { computeCategorySummaries } from 'expo-app/features/packs/utils';
+import { ProGate } from 'expo-app/features/purchases';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { ScrollView, View } from 'react-native';
@@ -34,153 +35,159 @@ export default function PackStatsScreen() {
}));
return (
-
-
-
- {/* Weight History Section */}
-
-
- {t('packs.weightHistory')}
-
+
+
+
+
+ {/* Weight History Section */}
+
+
+ {t('packs.weightHistory')}
+
- {WEIGHT_HISTORY && WEIGHT_HISTORY.length > 0 ? (
- <>
-
- {WEIGHT_HISTORY.map((item) => {
- const maxWeight = Math.max(...WEIGHT_HISTORY.map((w) => w.weight));
- const minWeight = Math.min(...WEIGHT_HISTORY.map((w) => w.weight));
- const range = maxWeight - minWeight || 1;
- const heightPercentage = ((item.weight - minWeight) / range) * 80 + 20;
+ {WEIGHT_HISTORY && WEIGHT_HISTORY.length > 0 ? (
+ <>
+
+ {WEIGHT_HISTORY.map((item) => {
+ const maxWeight = Math.max(...WEIGHT_HISTORY.map((w) => w.weight));
+ const minWeight = Math.min(...WEIGHT_HISTORY.map((w) => w.weight));
+ const range = maxWeight - minWeight || 1;
+ const heightPercentage = ((item.weight - minWeight) / range) * 80 + 20;
- return (
-
-
-
- {item.month}
-
-
- {convertWeight({ weight: item.weight, fromUnit: 'g' })} {weightUnit}
-
-
- );
- })}
+ return (
+
+
+
+ {item.month}
+
+
+ {convertWeight({ weight: item.weight, fromUnit: 'g' })} {weightUnit}
+
+
+ );
+ })}
+
+
+ {t('packs.packWeightOverMonths')}
+
+ >
+ ) : (
+
+
+ No weight history yet
+
+
+ Add gear to your pack — your pack weight over time will appear here.
+
+
-
- {t('packs.packWeightOverMonths')}
-
- >
- ) : (
-
-
- No weight history yet
-
-
- Add gear to your pack — your pack weight over time will appear here.
-
-
-
- )}
-
+ )}
+
- {/* Category Distribution Section */}
-
-
- {t('packs.categoryDistribution')}
-
+ {/* Category Distribution Section */}
+
+
+ {t('packs.categoryDistribution')}
+
- {CATEGORY_DISTRIBUTION.length > 0 ? (
- <>
-
- {CATEGORY_DISTRIBUTION.map((item) => (
-
-
- {item.name}
-
- {item.weight.toFixed(1)} {weightUnit}({item.percentage}%)
-
+ {CATEGORY_DISTRIBUTION.length > 0 ? (
+ <>
+
+ {CATEGORY_DISTRIBUTION.map((item) => (
+
+
+ {item.name}
+
+ {item.weight.toFixed(1)} {weightUnit}({item.percentage}%)
+
+
+
+
+
-
-
-
-
- ))}
+ ))}
+
+
+ {t('packs.weightDistribution')}
+
+ >
+ ) : (
+
+
+ No categorized items
+
+
+ Add items to your pack and assign categories to see weight distribution.
+
+
-
- {t('packs.weightDistribution')}
-
- >
- ) : (
-
-
- No categorized items
-
-
- Add items to your pack and assign categories to see weight distribution.
-
-
-
- )}
-
-
- {/* Pack Insights Section */}
- {featureFlags.enablePackInsights && (
-
-
- {t('packs.packInsights')}
-
+ )}
+
-
-
- {t('packs.lighterThanSimilar')}
-
-
- {t('packs.basedOnData')}
+ {/* Pack Insights Section */}
+ {featureFlags.enablePackInsights && (
+
+
+ {t('packs.packInsights')}
-
-
-
- {t('packs.reducedWeight')}
-
-
- {t('packs.weightReduction')}
-
-
+
+
+ {t('packs.lighterThanSimilar')}
+
+
+ {t('packs.basedOnData')}
+
+
-
-
- {t('packs.heaviestCategory')}
-
-
- {t('packs.considerUltralight')}
-
+
+
+ {t('packs.reducedWeight')}
+
+
+ {t('packs.weightReduction')}
+
+
+
+
+
+ {t('packs.heaviestCategory')}
+
+
+ {t('packs.considerUltralight')}
+
+
-
- )}
-
-
+ )}
+
+
+
);
}
diff --git a/apps/expo/app/(app)/pack-templates/[id]/edit.tsx b/apps/expo/app/(app)/pack-templates/[id]/edit.tsx
index ef63bd7d42..cb40ce2e70 100644
--- a/apps/expo/app/(app)/pack-templates/[id]/edit.tsx
+++ b/apps/expo/app/(app)/pack-templates/[id]/edit.tsx
@@ -1,5 +1,10 @@
import { EditPackTemplateScreen } from 'expo-app/features/pack-templates/screens/EditPackTemplateScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function EditPackTemplateScreenRoute() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/pack-templates/[id]/index.tsx b/apps/expo/app/(app)/pack-templates/[id]/index.tsx
index bd92b8fa3f..2588dbd2d2 100644
--- a/apps/expo/app/(app)/pack-templates/[id]/index.tsx
+++ b/apps/expo/app/(app)/pack-templates/[id]/index.tsx
@@ -1,5 +1,10 @@
import { PackTemplateDetailScreen } from 'expo-app/features/pack-templates/screens/PackTemplateDetailScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function PackTemplateDetailScreenRoute() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/pack-templates/index.tsx b/apps/expo/app/(app)/pack-templates/index.tsx
index 002a312723..c7106d6f59 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 { ProGate } from 'expo-app/features/purchases';
-export default function () {
- return ;
+export default function PackTemplatesRoute() {
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/pack-templates/items-scan.tsx b/apps/expo/app/(app)/pack-templates/items-scan.tsx
index fd170df957..a6b3002a00 100644
--- a/apps/expo/app/(app)/pack-templates/items-scan.tsx
+++ b/apps/expo/app/(app)/pack-templates/items-scan.tsx
@@ -1,5 +1,10 @@
import { ItemsScanScreen } from 'expo-app/features/pack-templates/screens/ItemsScanScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function PackNewFromImageScreen() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/pack-templates/new.tsx b/apps/expo/app/(app)/pack-templates/new.tsx
index b913fdc6d3..8d918afec0 100644
--- a/apps/expo/app/(app)/pack-templates/new.tsx
+++ b/apps/expo/app/(app)/pack-templates/new.tsx
@@ -1,5 +1,10 @@
import { CreateTemplatePackScreen } from 'expo-app/features/pack-templates/screens/CreatePackTemplateScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function PackNewScreen() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/pack/items-scan.tsx b/apps/expo/app/(app)/pack/items-scan.tsx
index e6ee9cf760..7997aaa2e5 100644
--- a/apps/expo/app/(app)/pack/items-scan.tsx
+++ b/apps/expo/app/(app)/pack/items-scan.tsx
@@ -1,5 +1,10 @@
import { ItemsScanScreen } from 'expo-app/features/packs/screens/ItemsScanScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function PackNewFromImageScreen() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/reported-ai-content.tsx b/apps/expo/app/(app)/reported-ai-content.tsx
index 4b8f114f59..5306ff5c82 100644
--- a/apps/expo/app/(app)/reported-ai-content.tsx
+++ b/apps/expo/app/(app)/reported-ai-content.tsx
@@ -1,3 +1,10 @@
import ReportedContentScreen from 'expo-app/features/ai/screens/ReportedContentScreen';
+import { ProGate } from 'expo-app/features/purchases';
-export default ReportedContentScreen;
+export default function ReportedContentRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/expo/app/(app)/season-suggestions-results.tsx b/apps/expo/app/(app)/season-suggestions-results.tsx
index ed3c123d01..8eec0e62c3 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 { ProGate } 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';
@@ -377,146 +378,148 @@ export default function SeasonSuggestionsResultsScreen() {
};
return (
- <>
-
-
-
-
- {__DEV__ && }
-
- {!displayError && !data && }
-
- {displayError && (
- router.back()}
- onGoToInventory={() => router.push('/(app)/(tabs)/(home)')}
- onSignIn={() => router.replace('/auth')}
- />
- )}
-
- {data && !displayError && (
-
-
-
-
- {data.season}
-
-
-
-
- {data.location}
+
+ <>
+
+
+
+
+ {__DEV__ && }
+
+ {!displayError && !data && }
+
+ {displayError && (
+ router.back()}
+ onGoToInventory={() => router.push('/(app)/(tabs)/(home)')}
+ onSignIn={() => router.replace('/auth')}
+ />
+ )}
+
+ {data && !displayError && (
+
+
+
+
+ {data.season}
+
+
+
+
+ {data.location}
+
-
-
- {data.suggestions.map((suggestion, index) => (
-
-
-
-
- {suggestion.name}
-
-
-
-
- createdPacks[index]
- ? router.push(`/pack/${createdPacks[index]}`)
- : handleCreatePack({ suggestion, index })
- }
- >
- {createdPacks[index] ? (
-
- {t('common.view')}
+
+ {data.suggestions.map((suggestion, index) => (
+
+
+
+
+ {suggestion.name}
- ) : (
- {t('common.create')}
- )}
-
-
-
-
- {suggestion.category}
-
-
- {suggestion.description}
-
-
-
- {suggestion.items.map((item) => (
-
-
-
- {item.name}
-
- ))}
-
-
- ))}
+
+ createdPacks[index]
+ ? router.push(`/pack/${createdPacks[index]}`)
+ : handleCreatePack({ suggestion, index })
+ }
+ >
+ {createdPacks[index] ? (
+
+ {t('common.view')}
+
+ ) : (
+ {t('common.create')}
+ )}
+
+
+
+
+ {suggestion.category}
+
+
+ {suggestion.description}
+
+
+
+ {suggestion.items.map((item) => (
+
+
+
+ {item.name}
+
+
+ ))}
+
+
+ ))}
+
-
- )}
-
-
- >
+ )}
+
+
+ >
+
);
}
diff --git a/apps/expo/app/(app)/season-suggestions.tsx b/apps/expo/app/(app)/season-suggestions.tsx
index b2cc57ce44..ca3f397adc 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 { ProGate } 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';
@@ -112,52 +113,54 @@ export default function SeasonSuggestionsScreen() {
};
return (
- <>
-
-
-
-
-
-
- {t('seasons.personalizedRecommendations')}
-
+
+ <>
+
+
+
+
+
+
+ {t('seasons.personalizedRecommendations')}
+
+
+
+
-
-
-
-
-
-
-
-
- >
+
+
+
+
+
+ >
+
);
}
diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx
index d0c2daa25e..ac54729f65 100644
--- a/apps/expo/app/(app)/settings/index.tsx
+++ b/apps/expo/app/(app)/settings/index.tsx
@@ -21,6 +21,11 @@ import { useSpeedUnit } from 'expo-app/features/auth/hooks/useSpeedUnit';
import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit';
import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit';
import { useSeasonSuggestionsPrefs } from 'expo-app/features/packs/atoms/seasonSuggestionsAtoms';
+import {
+ useEntitlement,
+ usePresentPaywall,
+ useRestorePurchases,
+} from 'expo-app/features/purchases';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { testIds } from 'expo-app/lib/testIds';
@@ -29,7 +34,7 @@ import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useAtomValue } from 'jotai';
-import { Platform, ScrollView, TouchableOpacity, View } from 'react-native';
+import { Linking, Platform, ScrollView, TouchableOpacity, View } from 'react-native';
export default function SettingsScreen() {
const { colorScheme, colors } = useColorScheme();
@@ -45,6 +50,33 @@ export default function SettingsScreen() {
const { unit: temperatureUnit, setTemperatureUnit } = useTemperatureUnit();
const { unit: speedUnit, setSpeedUnit } = useSpeedUnit();
+ const { isProMember } = useEntitlement();
+ const { presentPaywall } = usePresentPaywall();
+ const { mutate: restorePurchases, isPending: isRestoring } = useRestorePurchases();
+
+ const handleManageSubscription = () => {
+ const url =
+ Platform.OS === 'ios'
+ ? 'https://apps.apple.com/account/subscriptions'
+ : 'https://play.google.com/store/account/subscriptions';
+ Linking.openURL(url);
+ };
+
+ const handleRestore = () => {
+ restorePurchases(undefined, {
+ onSuccess: (info) => {
+ const isPro = !!info.entitlements.active['PackRat Pro'];
+ Burnt.toast({
+ title: isPro ? 'Pro access restored!' : 'No purchases found',
+ preset: isPro ? 'done' : 'error',
+ });
+ },
+ onError: () => {
+ Burnt.toast({ title: 'Restore failed. Please try again.', preset: 'error' });
+ },
+ });
+ };
+
const isApple = isAppleIntelligenceAvailable();
const isDownloading = modelStatus === 'downloading';
const isPreparing = modelStatus === 'preparing' || modelStatus === 'checking';
@@ -101,7 +133,7 @@ export default function SettingsScreen() {
const iconName: MaterialIconName = isApple ? 'apple' : 'atom';
return (
-
+
+
+
+ Subscription
+
+
+ {/* Plan status row */}
+
+
+
+
+
+ {isProMember ? 'PackRat Pro' : 'Free Plan'}
+
+ {isProMember
+ ? 'Full access to all Pro features'
+ : 'Upgrade to unlock Pro features'}
+
+
+
+
+
+
+ {/* Primary action */}
+ {isProMember ? (
+
+
+ Manage Subscription
+
+
+
+ ) : (
+
+
+ Upgrade to Pro
+
+
+
+ )}
+
+
+
+ {/* Restore purchases */}
+
+
+ {isRestoring ? 'Restoring…' : 'Restore Purchases'}
+
+
+
+
+
{t('ai.modelManagement')}
diff --git a/apps/expo/app/(app)/shared-packs.tsx b/apps/expo/app/(app)/shared-packs.tsx
index f90e653b1d..f1dcae90ce 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 { ProGate } 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';
@@ -176,37 +177,39 @@ function SharedPackCard({ pack }: { pack: (typeof SHARED_PACKS)[0] }) {
export default function SharedPacksScreen() {
const { t } = useTranslation();
return (
-
-
-
-
-
- {t('packs.collaborateOnPacks')}
-
-
+
+
+
+
+
+
+ {t('packs.collaborateOnPacks')}
+
+
-
- {SHARED_PACKS.map((pack) => (
-
- ))}
-
+
+ {SHARED_PACKS.map((pack) => (
+
+ ))}
+
-
-
- {t('packs.sharingBenefits')}
-
-
- {t('packs.distributeGroupGear')}
-
-
- {t('packs.sharingBenefit1')}
-
-
- {t('packs.sharingBenefit2')}
-
- {t('packs.sharingBenefit3')}
-
-
-
+
+
+ {t('packs.sharingBenefits')}
+
+
+ {t('packs.distributeGroupGear')}
+
+
+ {t('packs.sharingBenefit1')}
+
+
+ {t('packs.sharingBenefit2')}
+
+ {t('packs.sharingBenefit3')}
+
+
+
+
);
}
diff --git a/apps/expo/app/(app)/shopping-list.tsx b/apps/expo/app/(app)/shopping-list.tsx
index 8b2fb77988..9c4a807062 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 { ProGate } 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';
@@ -171,89 +172,93 @@ export default function ShoppingListScreen() {
});
return (
-
-
-
-
-
-
- {t('shopping.itemsToPurchase', {
- count: SHOPPING_LIST.filter((item) => !item.purchased).length,
- })}
-
-
- setFilter('pending')}
- >
-
+
+
+
+
+
+
+ {t('shopping.itemsToPurchase', {
+ count: SHOPPING_LIST.filter((item) => !item.purchased).length,
+ })}
+
+
+ setFilter('pending')}
>
- {t('shopping.toBuy')}
-
-
- setFilter('purchased')}
- >
-
+ {t('shopping.toBuy')}
+
+
+ setFilter('purchased')}
>
- {t('shopping.purchased')}
-
-
- setFilter('all')}
- >
-
+ {t('shopping.purchased')}
+
+
+ setFilter('all')}
>
- {t('shopping.all')}
-
-
+
+ {t('shopping.all')}
+
+
+
-
-
-
- Estimated Total: $225
-
+
+
+ Estimated Total: $225
+
+
-
-
- {filteredItems.map((item) => (
-
- ))}
-
+
+ {filteredItems.map((item) => (
+
+ ))}
+
-
-
- Shopping Tips
-
-
- • Check for seasonal sales at REI, Backcountry, and other outdoor retailers
-
-
- • Consider used gear from r/ULgeartrade or Gear Trade for better deals
-
- • Compare prices across multiple retailers before purchasing
-
-
-
+
+
+ Shopping Tips
+
+
+ • Check for seasonal sales at REI, Backcountry, and other outdoor retailers
+
+
+ • Consider used gear from r/ULgeartrade or Gear Trade for better deals
+
+ • Compare prices across multiple retailers before purchasing
+
+
+
+
);
}
diff --git a/apps/expo/app/(app)/templateItem/[id]/edit.tsx b/apps/expo/app/(app)/templateItem/[id]/edit.tsx
index 49c9565822..53c4005000 100644
--- a/apps/expo/app/(app)/templateItem/[id]/edit.tsx
+++ b/apps/expo/app/(app)/templateItem/[id]/edit.tsx
@@ -1,5 +1,10 @@
import { EditPackTemplateItemScreen } from 'expo-app/features/pack-templates/screens/EditPackTemplateItemScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function EditTemplateItemRoute() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/templateItem/[id]/index.tsx b/apps/expo/app/(app)/templateItem/[id]/index.tsx
index 0c07ac815c..8ba81d29bc 100644
--- a/apps/expo/app/(app)/templateItem/[id]/index.tsx
+++ b/apps/expo/app/(app)/templateItem/[id]/index.tsx
@@ -1,5 +1,10 @@
import { PackTemplateItemDetailScreen } from 'expo-app/features/pack-templates/screens/PackTemplateItemDetailScreen';
+import { ProGate } from 'expo-app/features/purchases';
export default function TemplateItemDetailRoute() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/templateItem/new.tsx b/apps/expo/app/(app)/templateItem/new.tsx
index b561f36cd6..8ba26d85cf 100644
--- a/apps/expo/app/(app)/templateItem/new.tsx
+++ b/apps/expo/app/(app)/templateItem/new.tsx
@@ -1,8 +1,13 @@
import { CreatePackTemplateItemForm } from 'expo-app/features/pack-templates/screens/CreatePackTemplateItemForm';
+import { ProGate } from 'expo-app/features/purchases';
import { useLocalSearchParams } from 'expo-router';
export default function NewTemplateItemScreen() {
const { packTemplateId } = useLocalSearchParams();
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/trail-conditions.tsx b/apps/expo/app/(app)/trail-conditions.tsx
index 441185aca7..c3988290ad 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 { ProGate } 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';
@@ -161,61 +162,63 @@ export default function TrailConditionsScreen() {
);
return (
-
- (
- setShowSubmitForm(true)}
- className="mr-2 rounded-full bg-primary px-3 py-1.5"
- accessibilityLabel={t('trailConditions.reportConditionsTitle')}
- accessibilityRole="button"
- >
-
- {t('trailConditions.reportButton')}
-
-
- ),
- }}
- />
+
+
+ (
+ setShowSubmitForm(true)}
+ className="mr-2 rounded-full bg-primary px-3 py-1.5"
+ accessibilityLabel={t('trailConditions.reportConditionsTitle')}
+ accessibilityRole="button"
+ >
+
+ {t('trailConditions.reportButton')}
+
+
+ ),
+ }}
+ />
-
- className="flex-1"
- data={filteredReports}
- keyExtractor={(item) => item.id}
- renderItem={renderItem}
- ListHeaderComponent={listHeader}
- ListFooterComponent={listFooter}
- ListEmptyComponent={listEmptyComponent}
- contentContainerClassName="pb-4"
- contentInsetAdjustmentBehavior="automatic"
- />
+
+ className="flex-1"
+ data={filteredReports}
+ keyExtractor={(item) => item.id}
+ renderItem={renderItem}
+ ListHeaderComponent={listHeader}
+ ListFooterComponent={listFooter}
+ ListEmptyComponent={listEmptyComponent}
+ contentContainerClassName="pb-4"
+ contentInsetAdjustmentBehavior="automatic"
+ />
- {/* Submit Report Modal */}
- setShowSubmitForm(false)}
- >
-
-
-
- {t('trailConditions.reportConditionsTitle')}
-
- setShowSubmitForm(false)}
- accessibilityLabel={t('common.cancel')}
- accessibilityRole="button"
- >
- {t('common.cancel')}
-
+ {/* Submit Report Modal */}
+ setShowSubmitForm(false)}
+ >
+
+
+
+ {t('trailConditions.reportConditionsTitle')}
+
+ setShowSubmitForm(false)}
+ accessibilityLabel={t('common.cancel')}
+ accessibilityRole="button"
+ >
+ {t('common.cancel')}
+
+
+ setShowSubmitForm(false)} />
- setShowSubmitForm(false)} />
-
-
-
+
+
+
);
}
diff --git a/apps/expo/app/(app)/trip/[id]/edit.tsx b/apps/expo/app/(app)/trip/[id]/edit.tsx
index dba18c615a..8c56f72d9c 100644
--- a/apps/expo/app/(app)/trip/[id]/edit.tsx
+++ b/apps/expo/app/(app)/trip/[id]/edit.tsx
@@ -1,5 +1,10 @@
+import { ProGate } from 'expo-app/features/purchases';
import { EditTripScreen } from 'expo-app/features/trips/screens/EditTripScreen';
export default function EditTripScreenRoute() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/trip/[id]/index.tsx b/apps/expo/app/(app)/trip/[id]/index.tsx
index a3b37861c7..f697a00de1 100644
--- a/apps/expo/app/(app)/trip/[id]/index.tsx
+++ b/apps/expo/app/(app)/trip/[id]/index.tsx
@@ -1,10 +1,13 @@
import { featureFlags } from 'expo-app/config';
+import { ProGate } from 'expo-app/features/purchases';
import { TripDetailScreen } from 'expo-app/features/trips/screens/TripDetailScreen';
import { Redirect } from 'expo-router';
export default function TripDetailScreenRoute() {
- // Gate deep links behind the trips feature flag so e.g. `packrat://trip/:id`
- // cannot bypass the kill switch.
if (!featureFlags.enableTrips) return ;
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/trip/location-search.tsx b/apps/expo/app/(app)/trip/location-search.tsx
index bd725f4ba5..488a69d3dc 100644
--- a/apps/expo/app/(app)/trip/location-search.tsx
+++ b/apps/expo/app/(app)/trip/location-search.tsx
@@ -1,6 +1,7 @@
import { clientEnvs } from '@packrat/env/expo-client';
import { ActivityIndicator, Button } from '@packrat/ui/nativewindui';
import { SearchInput } from 'expo-app/components/SearchInput';
+import { ProGate } from 'expo-app/features/purchases';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
@@ -89,54 +90,56 @@ export default function LocationSearchScreen() {
};
return (
-
-
+
+
+
+
+
+
+
+
+
-
+
+ {selectedLocation && (
+
+ )}
+
-
-
-
-
+
+ {isLoading && }
{selectedLocation && (
-
+
)}
-
-
-
-
- {isLoading && }
- {selectedLocation && (
-
- )}
-
-
+
+
+
);
}
diff --git a/apps/expo/app/(app)/trip/new.tsx b/apps/expo/app/(app)/trip/new.tsx
index 3c917107d2..486bc43108 100644
--- a/apps/expo/app/(app)/trip/new.tsx
+++ b/apps/expo/app/(app)/trip/new.tsx
@@ -1,10 +1,13 @@
import { featureFlags } from 'expo-app/config';
+import { ProGate } from 'expo-app/features/purchases';
import { CreateTripScreen } from 'expo-app/features/trips/screens/CreateTripScreen';
import { Redirect } from 'expo-router';
export default function TripNewScreen() {
- // Gate deep links behind the trips feature flag so e.g. `packrat://trip/new`
- // cannot bypass the kill switch.
if (!featureFlags.enableTrips) return ;
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/upcoming-trips.tsx b/apps/expo/app/(app)/upcoming-trips.tsx
index 14678c7d8b..d13326aa5b 100644
--- a/apps/expo/app/(app)/upcoming-trips.tsx
+++ b/apps/expo/app/(app)/upcoming-trips.tsx
@@ -1,6 +1,7 @@
import { List, ListItem, Text } from '@packrat/ui/nativewindui';
import { format } from 'date-fns';
import { featureFlags } from 'expo-app/config';
+import { ProGate } from 'expo-app/features/purchases';
import { useTrips } from 'expo-app/features/trips/hooks';
import { cn } from 'expo-app/lib/cn';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
@@ -158,79 +159,83 @@ function UpcomingTripsScreenInner() {
const selectedPack = selectedTrip ? packs.find((p) => p.id === selectedTrip.packId) : undefined;
return (
-
-
-
- {t('trips.plannedAdventures')}
-
-
-
- {/* Trip List */}
- ({
- id: trip.id,
- trip,
- title: trip.name,
- subTitle: `${trip.location?.name ?? t('trips.unknown')} • ${formatDate(
- trip.startDate,
- )} to ${formatDate(trip.endDate)}`,
- }))}
- extraData={selectedTripId}
- keyExtractor={(item) => item.id}
- renderItem={(info) => {
- const { trip } = info.item;
- const { status, completion } = getTripStatus({ trip, t });
-
- return (
- }
- rightView={
-
-
-
- }
- onPress={() => setSelectedTripId(trip.id)}
- className={
- selectedTripId === trip.id ? 'bg-muted/50 dark:bg-slate-950' : 'dark:bg-transparent'
- }
- />
- );
- }}
- />
-
- {/* Trip Summary */}
- {selectedTrip && (
-
-
-
- {selectedTrip.name}
-
-
- {selectedTrip.location?.name ?? 'No location'}
-
-
+
+
+
+
+ {t('trips.plannedAdventures')}
+
+
-
-
-
- DATES
+ {/* Trip List */}
+ ({
+ id: trip.id,
+ trip,
+ title: trip.name,
+ subTitle: `${trip.location?.name ?? t('trips.unknown')} • ${formatDate(
+ trip.startDate,
+ )} to ${formatDate(trip.endDate)}`,
+ }))}
+ extraData={selectedTripId}
+ keyExtractor={(item) => item.id}
+ renderItem={(info) => {
+ const { trip } = info.item;
+ const { status, completion } = getTripStatus({ trip, t });
+
+ return (
+ }
+ rightView={
+
+
+
+ }
+ onPress={() => setSelectedTripId(trip.id)}
+ className={
+ selectedTripId === trip.id
+ ? 'bg-muted/50 dark:bg-slate-950'
+ : 'dark:bg-transparent'
+ }
+ />
+ );
+ }}
+ />
+
+ {/* Trip Summary */}
+ {selectedTrip && (
+
+
+
+ {selectedTrip.name}
-
- {formatDate(selectedTrip.startDate)} - {formatDate(selectedTrip.endDate)}
+
+ {selectedTrip.location?.name ?? 'No location'}
-
-
- PACK
-
-
- {selectedPack ? `${selectedPack.items.length} items` : 'No pack assigned'}
-
+
+
+
+
+ DATES
+
+
+ {formatDate(selectedTrip.startDate)} - {formatDate(selectedTrip.endDate)}
+
+
+
+
+ PACK
+
+
+ {selectedPack ? `${selectedPack.items.length} items` : 'No pack assigned'}
+
+
-
- )}
-
+ )}
+
+
);
}
diff --git a/apps/expo/app/(app)/weather-alert-preferences.tsx b/apps/expo/app/(app)/weather-alert-preferences.tsx
index fd06fcaeb0..ea2106b7d1 100644
--- a/apps/expo/app/(app)/weather-alert-preferences.tsx
+++ b/apps/expo/app/(app)/weather-alert-preferences.tsx
@@ -1,6 +1,7 @@
import { Form, FormItem, FormSection, Text, Toggle } from '@packrat/ui/nativewindui';
import { getAppBarOptions } from '@packrat/ui/src/app-bar';
import { Icon } from 'expo-app/components/Icon';
+import { ProGate } from 'expo-app/features/purchases';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { Stack } from 'expo-router';
@@ -65,96 +66,98 @@ export default function WeatherAlertPreferencesScreen() {
const alertTypesDisabled = !preferences.weatherNotifications;
return (
- <>
-
-
-
-
- >
+
+
+
+ ))}
+
+
+
+ >
+
);
}
diff --git a/apps/expo/app/(app)/weather-alerts.tsx b/apps/expo/app/(app)/weather-alerts.tsx
index 96ef2c07e7..ca8ba5eefb 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 { ProGate } 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';
@@ -134,54 +135,56 @@ export default function WeatherAlertsScreen() {
const { alerts, loading, error, activeLocation } = useWeatherAlerts();
return (
-
-
-
-
-
- {t('weather.currentWeatherAlerts')}
-
-
- router.push('/weather-alert-preferences')}
- className="flex-row items-center gap-1 ml-2"
- >
-
-
- {t('weather.manageAlerts')}
+
+
+
+
+
+
+ {t('weather.currentWeatherAlerts')}
-
-
-
- {loading && Loading alerts...}
+ router.push('/weather-alert-preferences')}
+ className="flex-row items-center gap-1 ml-2"
+ >
+
+
+ {t('weather.manageAlerts')}
+
+
+
- {error && {error}}
+
+ {loading && Loading alerts...}
- {!loading && alerts.length === 0 && (
-
- No active alerts for {activeLocation?.name ?? 'this location'}
-
- )}
+ {error && {error}}
- {alerts.map((alert) => (
-
- ))}
-
+ {!loading && alerts.length === 0 && (
+
+ No active alerts for {activeLocation?.name ?? 'this location'}
+
+ )}
-
-
- {t('weather.weatherDataLastUpdated', {
- date: new Date().toLocaleTimeString(),
- })}
-
-
-
-
+ {alerts.map((alert) => (
+
+ ))}
+
+
+
+
+ {t('weather.weatherDataLastUpdated', {
+ date: new Date().toLocaleTimeString(),
+ })}
+
+
+
+
+
);
}
diff --git a/apps/expo/app/(app)/weather/[id].tsx b/apps/expo/app/(app)/weather/[id].tsx
index 20eb723864..1d27e4f032 100644
--- a/apps/expo/app/(app)/weather/[id].tsx
+++ b/apps/expo/app/(app)/weather/[id].tsx
@@ -1,5 +1,10 @@
+import { ProGate } from 'expo-app/features/purchases';
import { LocationDetailScreen } from 'expo-app/features/weather/screens';
export default function LocationDetailIndexScreen() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/weather/geo.tsx b/apps/expo/app/(app)/weather/geo.tsx
index 5c1ade3e60..344a9f49f0 100644
--- a/apps/expo/app/(app)/weather/geo.tsx
+++ b/apps/expo/app/(app)/weather/geo.tsx
@@ -1,5 +1,10 @@
+import { ProGate } from 'expo-app/features/purchases';
import TripWeatherDetailsScreen from 'expo-app/features/trips/screens/TripWeatherDetailsScreen';
export default function GeoWeatherDetailsScreen() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/weather/index.tsx b/apps/expo/app/(app)/weather/index.tsx
index ee8c67f846..e343f36a5f 100644
--- a/apps/expo/app/(app)/weather/index.tsx
+++ b/apps/expo/app/(app)/weather/index.tsx
@@ -1,5 +1,10 @@
+import { ProGate } from 'expo-app/features/purchases';
import { LocationsScreen } from 'expo-app/features/weather/screens';
export default function LocationsIndexScreen() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/weather/preview.tsx b/apps/expo/app/(app)/weather/preview.tsx
index 2db5fdfb4a..ebe52cded6 100644
--- a/apps/expo/app/(app)/weather/preview.tsx
+++ b/apps/expo/app/(app)/weather/preview.tsx
@@ -1,5 +1,10 @@
+import { ProGate } from 'expo-app/features/purchases';
import { LocationPreviewScreen } from 'expo-app/features/weather/screens';
export default function LocationPreviewIndexScreen() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/weather/search.tsx b/apps/expo/app/(app)/weather/search.tsx
index be625321fc..2ad8c88b54 100644
--- a/apps/expo/app/(app)/weather/search.tsx
+++ b/apps/expo/app/(app)/weather/search.tsx
@@ -1,5 +1,10 @@
+import { ProGate } from 'expo-app/features/purchases';
import { LocationSearchScreen } from 'expo-app/features/weather/screens';
export default function LocationSearchIndexScreen() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/wildlife/[id].tsx b/apps/expo/app/(app)/wildlife/[id].tsx
index 2c90425f68..925b07c1ec 100644
--- a/apps/expo/app/(app)/wildlife/[id].tsx
+++ b/apps/expo/app/(app)/wildlife/[id].tsx
@@ -1,4 +1,5 @@
import { featureFlags } from 'expo-app/config';
+import { ProGate } from 'expo-app/features/purchases';
import { SpeciesDetailScreen } from 'expo-app/features/wildlife/screens/SpeciesDetailScreen';
import { Redirect } from 'expo-router';
@@ -6,5 +7,9 @@ export default function SpeciesDetailRoute() {
if (!featureFlags.enableWildlifeIdentification) {
return ;
}
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/wildlife/identify.tsx b/apps/expo/app/(app)/wildlife/identify.tsx
index 9de85cbf13..e610229cff 100644
--- a/apps/expo/app/(app)/wildlife/identify.tsx
+++ b/apps/expo/app/(app)/wildlife/identify.tsx
@@ -1,4 +1,5 @@
import { featureFlags } from 'expo-app/config';
+import { ProGate } from 'expo-app/features/purchases';
import { IdentificationScreen } from 'expo-app/features/wildlife/screens/IdentificationScreen';
import { Redirect } from 'expo-router';
@@ -6,5 +7,9 @@ export default function IdentifyRoute() {
if (!featureFlags.enableWildlifeIdentification) {
return ;
}
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/expo/app/(app)/wildlife/index.tsx b/apps/expo/app/(app)/wildlife/index.tsx
index 47b5afb522..181d9c02a4 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 { ProGate } 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/app/_layout.tsx b/apps/expo/app/_layout.tsx
index 64ee1888b6..5e83c1729a 100644
--- a/apps/expo/app/_layout.tsx
+++ b/apps/expo/app/_layout.tsx
@@ -10,6 +10,7 @@ import '../global.css';
import { clientEnvs } from '@packrat/env/expo-client';
import { Alert, type AlertMethods } from '@packrat/ui/nativewindui';
import * as Sentry from '@sentry/react-native';
+import { configureRevenueCat } from 'expo-app/features/purchases';
import { useColorScheme, useInitialAndroidBarSync } from 'expo-app/lib/hooks/useColorScheme';
import { Providers } from 'expo-app/providers';
import { NAV_THEME } from 'expo-app/theme';
@@ -20,6 +21,8 @@ if (__DEV__ && clientEnvs.EXPO_PUBLIC_DISABLE_LOGBOX === 'true') {
LogBox.ignoreAllLogs(true);
}
+configureRevenueCat();
+
Sentry.init({
dsn: clientEnvs.EXPO_PUBLIC_SENTRY_DSN,
enabled: clientEnvs.NODE_ENV !== 'development' && !!clientEnvs.EXPO_PUBLIC_SENTRY_DSN,
diff --git a/apps/expo/features/packs/components/AddPackItemActions.tsx b/apps/expo/features/packs/components/AddPackItemActions.tsx
index 15be921f78..baaca86358 100644
--- a/apps/expo/features/packs/components/AddPackItemActions.tsx
+++ b/apps/expo/features/packs/components/AddPackItemActions.tsx
@@ -8,12 +8,14 @@ import { isAuthed } from 'expo-app/features/auth/store';
import { CatalogBrowserModal } from 'expo-app/features/catalog/components';
import { useRecentlyUsedCatalogItems } from 'expo-app/features/catalog/hooks/useRecentlyUsedCatalogItems';
import type { CatalogItem } from 'expo-app/features/catalog/types';
+import { usePresentPaywall } from 'expo-app/features/purchases';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { testIds } from 'expo-app/lib/testIds';
import { router } from 'expo-router';
import React from 'react';
import { Alert, TouchableOpacity, View } from 'react-native';
+import { PAYWALL_RESULT } from 'react-native-purchases-ui';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useBulkAddCatalogItems, useImagePicker } from '../hooks';
@@ -32,8 +34,9 @@ export default React.forwardRef(
const { addItemsToPack } = useBulkAddCatalogItems();
const { trackRecentlyUsed } = useRecentlyUsedCatalogItems();
+ const { presentPaywallIfNeeded } = usePresentPaywall();
- const handleAddFromPhoto = () => {
+ const handleAddFromPhoto = async () => {
ref && !isFunction(ref) && ref.current?.close();
if (!isAuthed.peek()) {
@@ -45,6 +48,12 @@ export default React.forwardRef(
},
});
}
+
+ const paywallResult = await presentPaywallIfNeeded();
+ if (paywallResult === PAYWALL_RESULT.CANCELLED || paywallResult === PAYWALL_RESULT.ERROR) {
+ return;
+ }
+
const options = ['Take Photo', 'Choose from Library', 'Cancel'];
const cancelButtonIndex = 2;
@@ -91,7 +100,7 @@ export default React.forwardRef(
);
};
- const handleAddFromCatalog = () => {
+ const handleAddFromCatalog = async () => {
ref && !isFunction(ref) && ref.current?.close();
if (!isAuthed.peek()) {
@@ -103,6 +112,12 @@ export default React.forwardRef(
},
});
}
+
+ const paywallResult = await presentPaywallIfNeeded();
+ if (paywallResult === PAYWALL_RESULT.CANCELLED || paywallResult === PAYWALL_RESULT.ERROR) {
+ return;
+ }
+
setIsCatalogModalVisible(true);
};
diff --git a/apps/expo/features/packs/screens/PackDetailScreen.tsx b/apps/expo/features/packs/screens/PackDetailScreen.tsx
index cbb1c71049..00775429dc 100644
--- a/apps/expo/features/packs/screens/PackDetailScreen.tsx
+++ b/apps/expo/features/packs/screens/PackDetailScreen.tsx
@@ -11,6 +11,7 @@ import { isAuthed } from 'expo-app/features/auth/store';
import { ActivityPicker } from 'expo-app/features/packs/components/ActivityPicker';
import { GapAnalysisModal } from 'expo-app/features/packs/components/GapAnalysisModal';
import { PackItemCard } from 'expo-app/features/packs/components/PackItemCard';
+import { usePresentPaywall } from 'expo-app/features/purchases';
import { LocationPicker } from 'expo-app/features/weather/components';
import type { WeatherLocation } from 'expo-app/features/weather/types';
import { cn } from 'expo-app/lib/cn';
@@ -23,6 +24,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { useAtomValue } from 'jotai';
import { useMemo, useState } from 'react';
import { Image, Platform, ScrollView, Share, TouchableOpacity, View } from 'react-native';
+import { PAYWALL_RESULT } from 'react-native-purchases-ui';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import AddPackItemActions from '../components/AddPackItemActions';
import { usePackDetailsFromApi, usePackDetailsFromStore, usePackGapAnalysis } from '../hooks';
@@ -78,6 +80,8 @@ export function PackDetailScreen() {
// TypeScript cannot track narrowing across the closure boundary.
const pack = (isOwnedByUser ? packFromStore : packFromApi) as Pack;
+ const { presentPaywallIfNeeded } = usePresentPaywall();
+
const { colors } = useColorScheme();
const insets = useSafeAreaInsets();
@@ -182,7 +186,7 @@ export function PackDetailScreen() {
return 'text-green-500';
};
- const handleAnalyzeGapsPress = () => {
+ const handleAnalyzeGapsPress = async () => {
if (!isAuthed.peek()) {
return router.push({
pathname: '/auth',
@@ -193,6 +197,11 @@ export function PackDetailScreen() {
});
}
+ const paywallResult = await presentPaywallIfNeeded();
+ if (paywallResult === PAYWALL_RESULT.CANCELLED || paywallResult === PAYWALL_RESULT.ERROR) {
+ return;
+ }
+
// Start with activity selection
setSelectedActivity(undefined);
setLocation(undefined);
diff --git a/apps/expo/features/purchases/components/CustomerCenter.tsx b/apps/expo/features/purchases/components/CustomerCenter.tsx
new file mode 100644
index 0000000000..df21765ba6
--- /dev/null
+++ b/apps/expo/features/purchases/components/CustomerCenter.tsx
@@ -0,0 +1,28 @@
+import { Button, Text } from '@packrat/ui/nativewindui';
+import * as Sentry from '@sentry/react-native';
+import RevenueCatUI from 'react-native-purchases-ui';
+
+export async function presentCustomerCenter() {
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'Presenting customer center',
+ level: 'info',
+ });
+ try {
+ await RevenueCatUI.presentCustomerCenter();
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'presentCustomerCenter' },
+ });
+ throw error;
+ }
+}
+
+/** Button that opens the RevenueCat Customer Center. */
+export function CustomerCenterButton({ label = 'Manage Subscription' }: { label?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/expo/features/purchases/components/ProGate.tsx b/apps/expo/features/purchases/components/ProGate.tsx
new file mode 100644
index 0000000000..c6a84f3696
--- /dev/null
+++ b/apps/expo/features/purchases/components/ProGate.tsx
@@ -0,0 +1,67 @@
+import { ActivityIndicator } from '@packrat/ui/nativewindui';
+import { Stack, useFocusEffect, useRouter } from 'expo-router';
+import { useCallback } from 'react';
+import { View } from 'react-native';
+import { PAYWALL_RESULT } from 'react-native-purchases-ui';
+import { useEntitlement } from '../hooks/useEntitlement';
+import { usePresentPaywall } from '../hooks/usePresentPaywall';
+
+// Prevents concurrent paywall sheets from stacking (e.g. multiple tabs mounting simultaneously).
+let isPaywallPresenting = false;
+
+interface ProGateProps {
+ children: React.ReactNode;
+}
+
+export function ProGate({ children }: ProGateProps) {
+ const { isProMember, isLoading } = useEntitlement();
+ const { presentPaywall } = usePresentPaywall();
+ const router = useRouter();
+
+ useFocusEffect(
+ useCallback(() => {
+ if (isLoading || isProMember || isPaywallPresenting) return;
+
+ isPaywallPresenting = true;
+ presentPaywall()
+ .then((result) => {
+ if (
+ (result === PAYWALL_RESULT.CANCELLED || result === PAYWALL_RESULT.ERROR) &&
+ router.canGoBack()
+ ) {
+ router.back();
+ }
+ })
+ .finally(() => {
+ isPaywallPresenting = false;
+ });
+ }, [isLoading, isProMember, presentPaywall, router]),
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isProMember) {
+ return <>{children}>;
+ }
+
+ // Render children invisibly so Stack.Screen mounts and sets the correct header.
+ // No visible fallback — the paywall sheet is the only UI shown to non-pro users.
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/expo/features/purchases/hooks/index.ts b/apps/expo/features/purchases/hooks/index.ts
new file mode 100644
index 0000000000..5221dc1af5
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/index.ts
@@ -0,0 +1,7 @@
+export { CUSTOMER_INFO_QUERY_KEY, useCustomerInfo } from './useCustomerInfo';
+export { useEntitlement } from './useEntitlement';
+export { OFFERINGS_QUERY_KEY, useOfferings } from './useOfferings';
+export { usePresentPaywall } from './usePresentPaywall';
+export { usePurchase } from './usePurchase';
+export { useRestorePurchases } from './useRestorePurchases';
+export { useRevenueCatUser } from './useRevenueCatUser';
diff --git a/apps/expo/features/purchases/hooks/useCustomerInfo.ts b/apps/expo/features/purchases/hooks/useCustomerInfo.ts
new file mode 100644
index 0000000000..857b01d4d3
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/useCustomerInfo.ts
@@ -0,0 +1,40 @@
+import * as Sentry from '@sentry/react-native';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect } from 'react';
+import Purchases, { type CustomerInfo } from 'react-native-purchases';
+
+export const CUSTOMER_INFO_QUERY_KEY = ['purchases', 'customerInfo'] as const;
+
+export function useCustomerInfo() {
+ const queryClient = useQueryClient();
+
+ // Keep React Query cache in sync when RevenueCat emits updates (e.g. after
+ // a successful purchase, subscription renewal, or restore from another device).
+ useEffect(() => {
+ const handler = (info: CustomerInfo) => {
+ queryClient.setQueryData(CUSTOMER_INFO_QUERY_KEY, info);
+ };
+ Purchases.addCustomerInfoUpdateListener(handler);
+ return () => Purchases.removeCustomerInfoUpdateListener(handler);
+ }, [queryClient]);
+
+ return useQuery({
+ queryKey: CUSTOMER_INFO_QUERY_KEY,
+ queryFn: async () => {
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'Fetching customer info',
+ level: 'info',
+ });
+ try {
+ return await Purchases.getCustomerInfo();
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'getCustomerInfo' },
+ });
+ throw error;
+ }
+ },
+ staleTime: 1000 * 60 * 5,
+ });
+}
diff --git a/apps/expo/features/purchases/hooks/useEntitlement.ts b/apps/expo/features/purchases/hooks/useEntitlement.ts
new file mode 100644
index 0000000000..76986a2c68
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/useEntitlement.ts
@@ -0,0 +1,10 @@
+import { PACKRAT_PRO_ENTITLEMENT } from '../types';
+import { useCustomerInfo } from './useCustomerInfo';
+
+export function useEntitlement() {
+ const { data: customerInfo, isLoading, error, refetch } = useCustomerInfo();
+
+ const isProMember = !!customerInfo?.entitlements.active[PACKRAT_PRO_ENTITLEMENT];
+
+ return { isProMember, isLoading, error, refetch };
+}
diff --git a/apps/expo/features/purchases/hooks/useOfferings.ts b/apps/expo/features/purchases/hooks/useOfferings.ts
new file mode 100644
index 0000000000..23fffaec28
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/useOfferings.ts
@@ -0,0 +1,27 @@
+import * as Sentry from '@sentry/react-native';
+import { useQuery } from '@tanstack/react-query';
+import Purchases from 'react-native-purchases';
+
+export const OFFERINGS_QUERY_KEY = ['purchases', 'offerings'] as const;
+
+export function useOfferings() {
+ return useQuery({
+ queryKey: OFFERINGS_QUERY_KEY,
+ queryFn: async () => {
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'Fetching offerings',
+ level: 'info',
+ });
+ try {
+ return await Purchases.getOfferings();
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'getOfferings' },
+ });
+ throw error;
+ }
+ },
+ staleTime: 1000 * 60 * 30,
+ });
+}
diff --git a/apps/expo/features/purchases/hooks/usePresentPaywall.ts b/apps/expo/features/purchases/hooks/usePresentPaywall.ts
new file mode 100644
index 0000000000..9c39615c80
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/usePresentPaywall.ts
@@ -0,0 +1,56 @@
+import * as Sentry from '@sentry/react-native';
+import { useQueryClient } from '@tanstack/react-query';
+import { useCallback } from 'react';
+import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
+import { PACKRAT_PRO_ENTITLEMENT } from '../types';
+import { CUSTOMER_INFO_QUERY_KEY } from './useCustomerInfo';
+
+export function usePresentPaywall() {
+ const queryClient = useQueryClient();
+
+ const presentPaywall = useCallback(async () => {
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'Presenting paywall',
+ level: 'info',
+ });
+ try {
+ const result = await RevenueCatUI.presentPaywall();
+ if (result !== PAYWALL_RESULT.NOT_PRESENTED && result !== PAYWALL_RESULT.ERROR) {
+ // Invalidate customer info so entitlement state refreshes
+ await queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY });
+ }
+ return result;
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'presentPaywall' },
+ });
+ throw error;
+ }
+ }, [queryClient]);
+
+ // Presents the paywall only if the user lacks the Pro entitlement.
+ const presentPaywallIfNeeded = useCallback(async () => {
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'Presenting paywall if needed',
+ level: 'info',
+ });
+ try {
+ const result = await RevenueCatUI.presentPaywallIfNeeded({
+ requiredEntitlementIdentifier: PACKRAT_PRO_ENTITLEMENT,
+ });
+ if (result !== PAYWALL_RESULT.NOT_PRESENTED && result !== PAYWALL_RESULT.ERROR) {
+ await queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY });
+ }
+ return result;
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'presentPaywallIfNeeded' },
+ });
+ throw error;
+ }
+ }, [queryClient]);
+
+ return { presentPaywall, presentPaywallIfNeeded };
+}
diff --git a/apps/expo/features/purchases/hooks/usePurchase.ts b/apps/expo/features/purchases/hooks/usePurchase.ts
new file mode 100644
index 0000000000..e0fb132a1f
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/usePurchase.ts
@@ -0,0 +1,33 @@
+import * as Sentry from '@sentry/react-native';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import type { Package } from 'react-native-purchases';
+import Purchases from 'react-native-purchases';
+import { CUSTOMER_INFO_QUERY_KEY } from './useCustomerInfo';
+
+export function usePurchase() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (pkg: Package) => {
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'Initiating purchase',
+ level: 'info',
+ data: { productId: pkg.product.identifier },
+ });
+ try {
+ const { customerInfo } = await Purchases.purchasePackage(pkg);
+ return customerInfo;
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'purchasePackage' },
+ extra: { productId: pkg.product.identifier },
+ });
+ throw error;
+ }
+ },
+ onSuccess: (customerInfo) => {
+ queryClient.setQueryData(CUSTOMER_INFO_QUERY_KEY, customerInfo);
+ },
+ });
+}
diff --git a/apps/expo/features/purchases/hooks/useRestorePurchases.ts b/apps/expo/features/purchases/hooks/useRestorePurchases.ts
new file mode 100644
index 0000000000..1a5a90d38c
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/useRestorePurchases.ts
@@ -0,0 +1,29 @@
+import * as Sentry from '@sentry/react-native';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import Purchases from 'react-native-purchases';
+import { CUSTOMER_INFO_QUERY_KEY } from './useCustomerInfo';
+
+export function useRestorePurchases() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'Restoring purchases',
+ level: 'info',
+ });
+ try {
+ return await Purchases.restorePurchases();
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'restorePurchases' },
+ });
+ throw error;
+ }
+ },
+ onSuccess: (customerInfo) => {
+ queryClient.setQueryData(CUSTOMER_INFO_QUERY_KEY, customerInfo);
+ },
+ });
+}
diff --git a/apps/expo/features/purchases/hooks/useRevenueCatUser.ts b/apps/expo/features/purchases/hooks/useRevenueCatUser.ts
new file mode 100644
index 0000000000..71cdbcaac9
--- /dev/null
+++ b/apps/expo/features/purchases/hooks/useRevenueCatUser.ts
@@ -0,0 +1,20 @@
+import { use$ } from '@legendapp/state/react';
+import { userStore } from 'expo-app/features/auth/store';
+import { useEffect } from 'react';
+import { identifyRevenueCatUser, resetRevenueCatUser } from '../lib/revenueCat';
+
+/**
+ * Keeps the RevenueCat user identity in sync with the app's auth state.
+ * Call this once at the app root after RevenueCat is configured.
+ */
+export function useRevenueCatUser() {
+ const user = use$(userStore);
+
+ useEffect(() => {
+ if (user?.id) {
+ identifyRevenueCatUser(user.id);
+ } else {
+ resetRevenueCatUser();
+ }
+ }, [user?.id]);
+}
diff --git a/apps/expo/features/purchases/index.ts b/apps/expo/features/purchases/index.ts
new file mode 100644
index 0000000000..424e0b4f78
--- /dev/null
+++ b/apps/expo/features/purchases/index.ts
@@ -0,0 +1,15 @@
+export { CustomerCenterButton, presentCustomerCenter } from './components/CustomerCenter';
+export { ProGate } from './components/ProGate';
+export {
+ CUSTOMER_INFO_QUERY_KEY,
+ OFFERINGS_QUERY_KEY,
+ useCustomerInfo,
+ useEntitlement,
+ useOfferings,
+ usePresentPaywall,
+ usePurchase,
+ useRestorePurchases,
+ useRevenueCatUser,
+} from './hooks';
+export { configureRevenueCat, identifyRevenueCatUser, resetRevenueCatUser } from './lib/revenueCat';
+export { PACKRAT_PRO_ENTITLEMENT, type ProductId, type PurchaseResult } from './types';
diff --git a/apps/expo/features/purchases/lib/revenueCat.ts b/apps/expo/features/purchases/lib/revenueCat.ts
new file mode 100644
index 0000000000..f91dce4ae6
--- /dev/null
+++ b/apps/expo/features/purchases/lib/revenueCat.ts
@@ -0,0 +1,55 @@
+import { clientEnvs } from '@packrat/env/expo-client';
+import * as Sentry from '@sentry/react-native';
+import Purchases, { LOG_LEVEL } from 'react-native-purchases';
+
+export function configureRevenueCat() {
+ const apiKey = clientEnvs.EXPO_PUBLIC_REVENUECAT_API_KEY;
+ if (!apiKey) return;
+
+ try {
+ if (__DEV__) {
+ Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
+ }
+ Purchases.configure({ apiKey });
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'RevenueCat configured',
+ level: 'info',
+ });
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'configure' },
+ });
+ }
+}
+
+export async function identifyRevenueCatUser(userId: string) {
+ try {
+ await Purchases.logIn(userId);
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'RevenueCat user identified',
+ level: 'info',
+ });
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'logIn' },
+ extra: { userId },
+ });
+ }
+}
+
+export async function resetRevenueCatUser() {
+ try {
+ await Purchases.logOut();
+ Sentry.addBreadcrumb({
+ category: 'purchases',
+ message: 'RevenueCat user reset',
+ level: 'info',
+ });
+ } catch (error) {
+ Sentry.captureException(error, {
+ tags: { feature: 'purchases', action: 'logOut' },
+ });
+ }
+}
diff --git a/apps/expo/features/purchases/types.ts b/apps/expo/features/purchases/types.ts
new file mode 100644
index 0000000000..36bbe334a5
--- /dev/null
+++ b/apps/expo/features/purchases/types.ts
@@ -0,0 +1,5 @@
+export const PACKRAT_PRO_ENTITLEMENT = 'PackRat Pro';
+
+export type ProductId = 'lifetime' | 'yearly' | 'monthly';
+
+export type PurchaseResult = 'purchased' | 'cancelled' | 'error';
diff --git a/apps/expo/package.json b/apps/expo/package.json
index 8ecbd1ca54..d2feb8f5b2 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -149,6 +149,8 @@
"react-native-keyboard-controller": "1.21.6",
"react-native-maps": "1.27.2",
"react-native-pager-view": "8.0.1",
+ "react-native-purchases": "*",
+ "react-native-purchases-ui": "*",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
diff --git a/bun.lock b/bun.lock
index b0aa0ed4b6..50cd4750af 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "packrat-monorepo",
+ "dependencies": {
+ "react-native-purchases": "^10.4.0",
+ "react-native-purchases-ui": "^10.4.0",
+ },
"devDependencies": {
"@biomejs/biome": "2.4.6",
"@manypkg/cli": "^0.24.0",
@@ -175,6 +179,8 @@
"react-native-keyboard-controller": "1.21.6",
"react-native-maps": "1.27.2",
"react-native-pager-view": "8.0.1",
+ "react-native-purchases": "*",
+ "react-native-purchases-ui": "*",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
@@ -1833,6 +1839,12 @@
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="],
+ "@revenuecat/purchases-js": ["@revenuecat/purchases-js@1.42.4", "", {}, "sha512-Zayh/4VqjUa/iDtXpgDmur3Vp5r9dO3axGs1zNp9RIKv8iH/fufJOH64jCRcZ31Ft7O7c5UNjYWIThXDjCn1Iw=="],
+
+ "@revenuecat/purchases-js-hybrid-mappings": ["@revenuecat/purchases-js-hybrid-mappings@18.15.1", "", { "dependencies": { "@revenuecat/purchases-js": "1.42.4" } }, "sha512-wJqvPiDE2ra9byoeOerBqNCJFzZQL8HNVbkhGRjAZZ3Us7kXzWvIT4LSgTO70bKre7iPaCYif10fd4Lh/eQi8w=="],
+
+ "@revenuecat/purchases-typescript-internal": ["@revenuecat/purchases-typescript-internal@18.15.1", "", {}, "sha512-OaaBxOpmO/Jp33DVCUfnqqOC+hFlXQyNXlZTgFrRCFWW3jVRo+8MQYOV8pVPo20cIrjlUQhnBB3qx+ECUkaJ+Q=="],
+
"@rn-primitives/alert-dialog": ["@rn-primitives/alert-dialog@1.4.0", "", { "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", "@rn-primitives/hooks": "1.4.0", "@rn-primitives/slot": "1.4.0", "@rn-primitives/types": "1.4.0" }, "peerDependencies": { "@rn-primitives/portal": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-TLnFbdOR1gqofJliMgLbm8A3liHAX0gTsLQyqG/aSVgSXSHNSGlO5H7WMcmaWcBe6vJgbR1UYIV3ADMHbzu+mA=="],
"@rn-primitives/avatar": ["@rn-primitives/avatar@1.4.0", "", { "dependencies": { "@rn-primitives/hooks": "1.4.0", "@rn-primitives/slot": "1.4.0", "@rn-primitives/types": "1.4.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-OOS5QSET4XEVcv4q20PAwkzZPC6LlE0mMFAMjbJ4hLA6stzQJxNaK9PaUl1AXt+x4Prh4MPOC7ktuERIKkNeYg=="],
@@ -4219,6 +4231,10 @@
"react-native-pager-view": ["react-native-pager-view@8.0.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-pGOne2o0y0HOQLrlTLcGgOE48uJlqSZHRRwdW8nL6JJozMkPGJYi/G9e0EsJoWFpXYONjiDgr8IwxC4F6/r7Lg=="],
+ "react-native-purchases": ["react-native-purchases@10.4.0", "", { "dependencies": { "@revenuecat/purchases-js-hybrid-mappings": "18.15.1", "@revenuecat/purchases-typescript-internal": "18.15.1" }, "peerDependencies": { "react": ">= 16.6.3", "react-native": ">= 0.73.0", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-jM1FWdLKchMlBONoqIM+Fw9iGYloWPBx097iMVYk28V/BHY61tUS5YflGgmSHkL3rpJQQxBfsYWhAPpBSABvpg=="],
+
+ "react-native-purchases-ui": ["react-native-purchases-ui@10.4.0", "", { "dependencies": { "@revenuecat/purchases-typescript-internal": "18.15.1" }, "peerDependencies": { "react": "*", "react-native": ">= 0.73.0", "react-native-purchases": "10.4.0", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-IjiC4WgiVBTlenIxFq02QPPPTFI3GuZabBGS5nCsFqQtN2uLUBmHL3l8RSpHyFKTy5yKfPRLvWt4TdXpHAXTBA=="],
+
"react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="],
diff --git a/package.json b/package.json
index d9286b862a..4602372105 100644
--- a/package.json
+++ b/package.json
@@ -86,6 +86,10 @@
"expo-sqlite": "~56.0.4",
"react": "19.2.3"
},
+ "dependencies": {
+ "react-native-purchases": "^10.4.0",
+ "react-native-purchases-ui": "^10.4.0"
+ },
"devDependencies": {
"@biomejs/biome": "2.4.6",
"@manypkg/cli": "^0.24.0",
diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts
index 4960388f0b..24f46b68c2 100644
--- a/packages/config/src/config.ts
+++ b/packages/config/src/config.ts
@@ -14,6 +14,7 @@ const FeatureFlag = Object.freeze({
EnableWildlifeIdentification: 'enableWildlifeIdentification',
EnableLocalAI: 'enableLocalAI',
EnableTrails: 'enableTrails',
+ EnableRevenueCat: 'enableRevenueCat',
});
const DashboardTileId = Object.freeze({
@@ -73,6 +74,7 @@ const APP_CONFIG_SOURCE = {
[FeatureFlag.EnableWildlifeIdentification]: false,
[FeatureFlag.EnableLocalAI]: true,
[FeatureFlag.EnableTrails]: false,
+ [FeatureFlag.EnableRevenueCat]: true,
},
dashboard: {
gapPrefix: GAP_PREFIX,
diff --git a/packages/env/src/expo-client.ts b/packages/env/src/expo-client.ts
index b56c2fc4cf..fce295662b 100644
--- a/packages/env/src/expo-client.ts
+++ b/packages/env/src/expo-client.ts
@@ -22,6 +22,7 @@ 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_API_KEY: z.string().optional(),
});
export type ClientEnv = z.infer;
@@ -36,6 +37,7 @@ 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_API_KEY: process.env.EXPO_PUBLIC_REVENUECAT_API_KEY,
};
/**