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 ( - <> - - -
- - - - - - - - {t('weather.weatherNotifications')} - - {t('weather.weatherNotificationsDesc')} - - - - - - - - - + + <> + + + + + + + + + + + {t('weather.weatherNotifications')} + + {t('weather.weatherNotificationsDesc')} + + - - {t('weather.locationMonitoring')} - - {t('weather.locationMonitoringDesc')} - + + + + + + + + + {t('weather.locationMonitoring')} + + {t('weather.locationMonitoringDesc')} + + - - - - + + + - - {ALERT_TYPE_CONFIGS.map(({ key, iconName, iconColor }) => ( - - - - - - - - + {ALERT_TYPE_CONFIGS.map(({ key, iconName, iconColor }) => ( + + + + - {t(`weather.${key}` as never)} - - - {t(`weather.${key}Desc` as never)} - + + + + + {t(`weather.${key}` as never)} + + + {t(`weather.${key}Desc` as never)} + + - - - - - ))} - - -
- + + + + ))} + + + + + ); } 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, }; /**