From a4734607239a21b4fb77f5f27f0f1360daafcd7c Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Fri, 19 Jun 2026 17:57:47 +0100 Subject: [PATCH 01/18] feat(expo): integrate RevenueCat and paywall non-core features - Configure RevenueCat SDK with API key and Pro entitlement - Add purchases feature module: hooks for customer info, entitlement, offerings, purchase, restore, and paywall presentation - Add ProGate component that auto-presents the RevenueCat paywall when a non-pro user navigates to a paywalled screen - Wrap 49 non-core screens with ProGate (weather, wildlife, AI chat, guides, catalog, trips, feed, messages, pack templates, and more) - Header and search bar handled correctly behind the gate: screen's own Stack.Screen always mounts so the title is preserved; search bar is stripped from paywalled state - Use useFocusEffect + module-level lock to prevent concurrent paywall sheets and avoid triggering on background tab mounts --- apps/expo/app/(app)/(tabs)/catalog/index.tsx | 7 +- apps/expo/app/(app)/(tabs)/feed/index.tsx | 7 +- apps/expo/app/(app)/(tabs)/trips/index.tsx | 10 +- apps/expo/app/(app)/_layout.tsx | 2 + apps/expo/app/(app)/admin/ai-packs.tsx | 7 +- apps/expo/app/(app)/ai-chat.tsx | 237 +++++++-------- apps/expo/app/(app)/catalog/[id].tsx | 7 +- .../app/(app)/catalog/add-to-pack/details.tsx | 7 +- .../app/(app)/catalog/add-to-pack/index.tsx | 7 +- apps/expo/app/(app)/feed/[id].tsx | 7 +- apps/expo/app/(app)/feed/create.tsx | 7 +- apps/expo/app/(app)/gear-inventory.tsx | 119 ++++---- apps/expo/app/(app)/guides/[id].tsx | 7 +- apps/expo/app/(app)/guides/index.tsx | 7 +- apps/expo/app/(app)/messages/chat.android.tsx | 115 ++++---- apps/expo/app/(app)/messages/chat.tsx | 93 +++--- .../(app)/messages/conversations.android.tsx | 87 +++--- .../expo/app/(app)/messages/conversations.tsx | 53 ++-- apps/expo/app/(app)/pack-categories/[id].tsx | 43 +-- apps/expo/app/(app)/pack-stats/[id].tsx | 279 +++++++++--------- .../app/(app)/pack-templates/[id]/edit.tsx | 7 +- .../app/(app)/pack-templates/[id]/index.tsx | 7 +- apps/expo/app/(app)/pack-templates/index.tsx | 9 +- .../app/(app)/pack-templates/items-scan.tsx | 7 +- apps/expo/app/(app)/pack-templates/new.tsx | 7 +- apps/expo/app/(app)/reported-ai-content.tsx | 9 +- .../app/(app)/season-suggestions-results.tsx | 271 ++++++++--------- apps/expo/app/(app)/season-suggestions.tsx | 95 +++--- apps/expo/app/(app)/shared-packs.tsx | 63 ++-- apps/expo/app/(app)/shopping-list.tsx | 157 +++++----- .../expo/app/(app)/templateItem/[id]/edit.tsx | 7 +- .../app/(app)/templateItem/[id]/index.tsx | 7 +- apps/expo/app/(app)/templateItem/new.tsx | 7 +- apps/expo/app/(app)/trail-conditions.tsx | 109 +++---- apps/expo/app/(app)/trip/[id]/edit.tsx | 7 +- apps/expo/app/(app)/trip/[id]/index.tsx | 9 +- apps/expo/app/(app)/trip/location-search.tsx | 91 +++--- apps/expo/app/(app)/trip/new.tsx | 9 +- apps/expo/app/(app)/upcoming-trips.tsx | 141 ++++----- .../app/(app)/weather-alert-preferences.tsx | 175 +++++------ apps/expo/app/(app)/weather-alerts.tsx | 89 +++--- apps/expo/app/(app)/weather/[id].tsx | 7 +- apps/expo/app/(app)/weather/geo.tsx | 7 +- apps/expo/app/(app)/weather/index.tsx | 7 +- apps/expo/app/(app)/weather/preview.tsx | 7 +- apps/expo/app/(app)/weather/search.tsx | 7 +- apps/expo/app/(app)/weight-analysis/[id].tsx | 165 ++++++----- apps/expo/app/(app)/wildlife/[id].tsx | 7 +- apps/expo/app/(app)/wildlife/identify.tsx | 7 +- apps/expo/app/(app)/wildlife/index.tsx | 7 +- apps/expo/app/_layout.tsx | 3 + .../purchases/components/CustomerCenter.tsx | 27 ++ .../features/purchases/components/ProGate.tsx | 70 +++++ apps/expo/features/purchases/hooks/index.ts | 7 + .../purchases/hooks/useCustomerInfo.ts | 40 +++ .../purchases/hooks/useEntitlement.ts | 10 + .../features/purchases/hooks/useOfferings.ts | 27 ++ .../purchases/hooks/usePresentPaywall.ts | 56 ++++ .../features/purchases/hooks/usePurchase.ts | 33 +++ .../purchases/hooks/useRestorePurchases.ts | 29 ++ .../purchases/hooks/useRevenueCatUser.ts | 20 ++ apps/expo/features/purchases/index.ts | 15 + .../expo/features/purchases/lib/revenueCat.ts | 54 ++++ apps/expo/features/purchases/types.ts | 5 + apps/expo/package.json | 2 + packages/config/src/config.ts | 2 + 66 files changed, 1815 insertions(+), 1197 deletions(-) create mode 100644 apps/expo/features/purchases/components/CustomerCenter.tsx create mode 100644 apps/expo/features/purchases/components/ProGate.tsx create mode 100644 apps/expo/features/purchases/hooks/index.ts create mode 100644 apps/expo/features/purchases/hooks/useCustomerInfo.ts create mode 100644 apps/expo/features/purchases/hooks/useEntitlement.ts create mode 100644 apps/expo/features/purchases/hooks/useOfferings.ts create mode 100644 apps/expo/features/purchases/hooks/usePresentPaywall.ts create mode 100644 apps/expo/features/purchases/hooks/usePurchase.ts create mode 100644 apps/expo/features/purchases/hooks/useRestorePurchases.ts create mode 100644 apps/expo/features/purchases/hooks/useRevenueCatUser.ts create mode 100644 apps/expo/features/purchases/index.ts create mode 100644 apps/expo/features/purchases/lib/revenueCat.ts create mode 100644 apps/expo/features/purchases/types.ts 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..b11c373ae9 100644 --- a/apps/expo/app/(app)/(tabs)/trips/index.tsx +++ b/apps/expo/app/(app)/(tabs)/trips/index.tsx @@ -1,14 +1,16 @@ import { featureFlags } from 'expo-app/config'; +import { ProGate } from 'expo-app/features/purchases'; import { TripsListScreen } from 'expo-app/features/trips/screens/TripListScreen'; 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 ; + return ( + + + + ); } function TripsScreenInner() { diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index c1be74e548..19150d64d6 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); diff --git a/apps/expo/app/(app)/admin/ai-packs.tsx b/apps/expo/app/(app)/admin/ai-packs.tsx index 95c501d116..f36ce01d56 100644 --- a/apps/expo/app/(app)/admin/ai-packs.tsx +++ b/apps/expo/app/(app)/admin/ai-packs.tsx @@ -1,5 +1,10 @@ import { AIPacksScreen } from 'expo-app/features/ai-packs/screens/AIPacksScreen'; +import { ProGate } from 'expo-app/features/purchases'; export default function AIPacks() { - return ; + return ( + + + + ); } 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)/gear-inventory.tsx b/apps/expo/app/(app)/gear-inventory.tsx index 5f9e1bfafb..837dda894d 100644 --- a/apps/expo/app/(app)/gear-inventory.tsx +++ b/apps/expo/app/(app)/gear-inventory.tsx @@ -4,6 +4,7 @@ import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { PackItemCard } from 'expo-app/features/packs/components/PackItemCard'; import { useUserPackItems } from 'expo-app/features/packs/hooks/useUserPackItems'; import type { PackItem } from 'expo-app/features/packs/types'; +import { ProGate } from 'expo-app/features/purchases'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { Stack, useRouter } from 'expo-router'; @@ -65,66 +66,70 @@ export default function GearInventoryScreen() { const itemsByCategory = groupByCategory(items); return ( - - - - - - {t('packs.itemsInInventory', { count: items?.length })} - - - setViewMode('all')} - > - + + + + + + {t('packs.itemsInInventory', { count: items?.length })} + + + setViewMode('all')} > - {t('packs.all')} - - - setViewMode('category')} - > - + {t('packs.all')} + + + setViewMode('category')} > - {t('packs.byCategory')} - - + + {t('packs.byCategory')} + + + - - {viewMode === 'all' ? ( - - {items.map((item) => ( - - ))} - - ) : ( - - {Object.entries(itemsByCategory).map(([category, groupedItems]) => ( - - ))} - - )} - - + {viewMode === 'all' ? ( + + {items.map((item) => ( + + ))} + + ) : ( + + {Object.entries(itemsByCategory).map(([category, groupedItems]) => ( + + ))} + + )} + + + ); } 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-categories/[id].tsx b/apps/expo/app/(app)/pack-categories/[id].tsx index f0dc3154ee..0b32f38c12 100644 --- a/apps/expo/app/(app)/pack-categories/[id].tsx +++ b/apps/expo/app/(app)/pack-categories/[id].tsx @@ -4,6 +4,7 @@ import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; import { computeCategorySummaries } from 'expo-app/features/packs/utils'; +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, useLocalSearchParams } from 'expo-router'; @@ -66,27 +67,29 @@ export default function PackCategoriesScreen() { const categories = computeCategorySummaries({ pack, preferredUnit: weightUnit }); return ( - <> - - {categories.length ? ( - - - - {t('packs.organizeGear')} - - + + <> + + {categories.length ? ( + + + + {t('packs.organizeGear')} + + - - {categories.map((category) => ( - - ))} + + {categories.map((category) => ( + + ))} + + + ) : ( + + {t('packs.noCategorizedItems')} - - ) : ( - - {t('packs.noCategorizedItems')} - - )} - + )} + + ); } 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)/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)/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)/weight-analysis/[id].tsx b/apps/expo/app/(app)/weight-analysis/[id].tsx index d0cf934fbd..8d99ce4291 100644 --- a/apps/expo/app/(app)/weight-analysis/[id].tsx +++ b/apps/expo/app/(app)/weight-analysis/[id].tsx @@ -4,6 +4,7 @@ import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackWeightAnalysis } from 'expo-app/features/packs/hooks/usePackWeightAnalysis'; +import { ProGate } from 'expo-app/features/purchases'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { Stack, useLocalSearchParams } from 'expo-router'; @@ -47,94 +48,96 @@ export default function WeightAnalysisScreen() { const { convertWeight } = useWeightUnit(); return ( - - - - - - - - - + + + + + + + + + + - - - {t('packs.weightBreakdown')} - - - {t('packs.detailedAnalysis')} - - + + + {t('packs.weightBreakdown')} + + + {t('packs.detailedAnalysis')} + + - {data.categories.map((category, _categoryIndex) => ( - - {/* Category Header */} - - - - {category.name} - - - {category.weight} {preferredUnit} - + {data.categories.map((category, _categoryIndex) => ( + + {/* Category Header */} + + + + {category.name} + + + {category.weight} {preferredUnit} + + - - {/* Items */} - - {items - .filter((item) => item.category.trim() === category.name.trim()) - .map((item, itemIndex) => ( - 0 ? 'border-border/25 dark:border-border/80 border-t' : '', - )} - > - - {item.name} - {item.notes && ( - - {item.notes} - + {/* Items */} + + {items + .filter((item) => item.category.trim() === category.name.trim()) + .map((item, itemIndex) => ( + 0 ? 'border-border/25 dark:border-border/80 border-t' : '', )} + > + + {item.name} + {item.notes && ( + + {item.notes} + + )} + + + {convertWeight({ weight: item.weight, fromUnit: item.weightUnit || 'g' })}{' '} + {preferredUnit} + - - {convertWeight({ weight: item.weight, fromUnit: item.weightUnit || 'g' })}{' '} - {preferredUnit} - - - ))} + ))} + - - ))} + ))} - {!data.categories.length && ( - {t('packs.addItemsForBreakdown')} - )} - - + {!data.categories.length && ( + {t('packs.addItemsForBreakdown')} + )} + + + ); } 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/purchases/components/CustomerCenter.tsx b/apps/expo/features/purchases/components/CustomerCenter.tsx new file mode 100644 index 0000000000..c370d49f88 --- /dev/null +++ b/apps/expo/features/purchases/components/CustomerCenter.tsx @@ -0,0 +1,27 @@ +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' }, + }); + } +} + +/** 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..6267982bef --- /dev/null +++ b/apps/expo/features/purchases/components/ProGate.tsx @@ -0,0 +1,70 @@ +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; + fallback?: React.ReactNode; +} + +export function ProGate({ children, fallback }: ProGateProps) { + const { isProMember, isLoading } = useEntitlement(); + const { presentPaywall } = usePresentPaywall(); + const router = useRouter(); + + // useFocusEffect only fires for the currently focused screen — background tabs + // stay silent on app open. React Navigation v6 runs this after every render + // (no internal dep array), so loading-completion-while-focused is also handled. + 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}; + } + + return ( + + + {children} + + + {fallback} + + ); +} 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..63b296c681 --- /dev/null +++ b/apps/expo/features/purchases/lib/revenueCat.ts @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/react-native'; +import Purchases, { LOG_LEVEL } from 'react-native-purchases'; + +// RevenueCat public API key — not a secret, safe to embed in the app bundle. +const REVENUECAT_API_KEY = 'test_rmRjXKZMmykOaEhtvmoRYtqmVGA'; + +export function configureRevenueCat() { + try { + if (__DEV__) { + Purchases.setLogLevel(LOG_LEVEL.VERBOSE); + } + Purchases.configure({ apiKey: REVENUECAT_API_KEY }); + 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/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, From 6ec7705b8c12ae8d84fd0bc26b9b839806053ce8 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Fri, 19 Jun 2026 18:06:18 +0100 Subject: [PATCH 02/18] fix(expo): remove paywall from admin screens and fix modal double-dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove ProGate from admin/ai-packs.tsx — admin screens are unrestricted - Remove router.back() after paywall dismissal — it was closing modal- presented screens (pack-categories, weight-analysis) immediately after the paywall closed, causing the double-dismiss flicker - Show ProUpgradePrompt as fallback when paywall is dismissed without purchasing, giving users a clear path to upgrade or navigate back --- apps/expo/app/(app)/admin/ai-packs.tsx | 7 +-- .../features/purchases/components/ProGate.tsx | 53 +++++++++++-------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/apps/expo/app/(app)/admin/ai-packs.tsx b/apps/expo/app/(app)/admin/ai-packs.tsx index f36ce01d56..95c501d116 100644 --- a/apps/expo/app/(app)/admin/ai-packs.tsx +++ b/apps/expo/app/(app)/admin/ai-packs.tsx @@ -1,10 +1,5 @@ import { AIPacksScreen } from 'expo-app/features/ai-packs/screens/AIPacksScreen'; -import { ProGate } from 'expo-app/features/purchases'; export default function AIPacks() { - return ( - - - - ); + return ; } diff --git a/apps/expo/features/purchases/components/ProGate.tsx b/apps/expo/features/purchases/components/ProGate.tsx index 6267982bef..0bfd58be16 100644 --- a/apps/expo/features/purchases/components/ProGate.tsx +++ b/apps/expo/features/purchases/components/ProGate.tsx @@ -1,8 +1,7 @@ -import { ActivityIndicator } from '@packrat/ui/nativewindui'; -import { Stack, useFocusEffect, useRouter } from 'expo-router'; +import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; +import { Stack, useFocusEffect } 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'; @@ -17,29 +16,19 @@ interface ProGateProps { export function ProGate({ children, fallback }: ProGateProps) { const { isProMember, isLoading } = useEntitlement(); const { presentPaywall } = usePresentPaywall(); - const router = useRouter(); - // useFocusEffect only fires for the currently focused screen — background tabs - // stay silent on app open. React Navigation v6 runs this after every render - // (no internal dep array), so loading-completion-while-focused is also handled. + // Only fires for the currently focused screen — background tabs stay silent + // on app open. v6 useFocusEffect has no internal dep array so it re-runs on + // every render, which means loading-completion-while-focused is also handled. 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]), + presentPaywall().finally(() => { + isPaywallPresenting = false; + }); + }, [isLoading, isProMember, presentPaywall]), ); if (isLoading) { @@ -54,6 +43,10 @@ export function ProGate({ children, fallback }: ProGateProps) { return <>{children}; } + // Render children invisibly so the screen's own mounts and + // sets the correct header. The upgrade prompt sits on top as a fallback when + // the paywall sheet is dismissed. Search bar is stripped — it doesn't belong + // on a paywalled screen. return ( @@ -64,7 +57,25 @@ export function ProGate({ children, fallback }: ProGateProps) { headerSearchBarOptions: null as unknown as undefined, }} /> - {fallback} + + {fallback ?? } + + + ); +} + +function ProUpgradePrompt() { + const { presentPaywall } = usePresentPaywall(); + + return ( + + PackRat Pro + + Unlock this feature and everything Pro has to offer. + + ); } From f3b45eb0327cb08137702f446cab6aae6350e8ea Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Fri, 19 Jun 2026 18:11:12 +0100 Subject: [PATCH 03/18] =?UTF-8?q?fix(ProGate):=20remove=20upgrade=20prompt?= =?UTF-8?q?=20=E2=80=94=20paywall=20is=20the=20only=20non-pro=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/purchases/components/ProGate.tsx | 51 +++++++------------ 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/apps/expo/features/purchases/components/ProGate.tsx b/apps/expo/features/purchases/components/ProGate.tsx index 0bfd58be16..38dab6e3a4 100644 --- a/apps/expo/features/purchases/components/ProGate.tsx +++ b/apps/expo/features/purchases/components/ProGate.tsx @@ -1,7 +1,8 @@ -import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; -import { Stack, useFocusEffect } from 'expo-router'; +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'; @@ -16,19 +17,26 @@ interface ProGateProps { export function ProGate({ children, fallback }: ProGateProps) { const { isProMember, isLoading } = useEntitlement(); const { presentPaywall } = usePresentPaywall(); + const router = useRouter(); - // Only fires for the currently focused screen — background tabs stay silent - // on app open. v6 useFocusEffect has no internal dep array so it re-runs on - // every render, which means loading-completion-while-focused is also handled. useFocusEffect( useCallback(() => { if (isLoading || isProMember || isPaywallPresenting) return; isPaywallPresenting = true; - presentPaywall().finally(() => { - isPaywallPresenting = false; - }); - }, [isLoading, isProMember, presentPaywall]), + 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) { @@ -43,10 +51,8 @@ export function ProGate({ children, fallback }: ProGateProps) { return <>{children}; } - // Render children invisibly so the screen's own mounts and - // sets the correct header. The upgrade prompt sits on top as a fallback when - // the paywall sheet is dismissed. Search bar is stripped — it doesn't belong - // on a paywalled screen. + // 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 ( @@ -57,25 +63,6 @@ export function ProGate({ children, fallback }: ProGateProps) { headerSearchBarOptions: null as unknown as undefined, }} /> - - {fallback ?? } - - - ); -} - -function ProUpgradePrompt() { - const { presentPaywall } = usePresentPaywall(); - - return ( - - PackRat Pro - - Unlock this feature and everything Pro has to offer. - - ); } From 9b367a0a0a5777f5d4ac33d7d0de56bd2c82f773 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Fri, 19 Jun 2026 18:11:28 +0100 Subject: [PATCH 04/18] fix(ProGate): drop unused fallback prop --- apps/expo/features/purchases/components/ProGate.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/expo/features/purchases/components/ProGate.tsx b/apps/expo/features/purchases/components/ProGate.tsx index 38dab6e3a4..c6a84f3696 100644 --- a/apps/expo/features/purchases/components/ProGate.tsx +++ b/apps/expo/features/purchases/components/ProGate.tsx @@ -11,10 +11,9 @@ let isPaywallPresenting = false; interface ProGateProps { children: React.ReactNode; - fallback?: React.ReactNode; } -export function ProGate({ children, fallback }: ProGateProps) { +export function ProGate({ children }: ProGateProps) { const { isProMember, isLoading } = useEntitlement(); const { presentPaywall } = usePresentPaywall(); const router = useRouter(); From 66b696a80733d97947d59d267b4ab1869546e5c7 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Fri, 19 Jun 2026 18:16:44 +0100 Subject: [PATCH 05/18] fix(expo): present weight-analysis and pack-categories as card screens instead of modals --- apps/expo/app/(app)/_layout.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index 19150d64d6..abf82a12a1 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -172,15 +172,13 @@ export default function AppLayout() { Date: Fri, 19 Jun 2026 19:17:36 +0100 Subject: [PATCH 06/18] fix(expo): gate catalog browse, scan, and gap analysis behind Pro paywall - Wrap pack/items-scan route with ProGate (consistent with pack-templates/items-scan) - Gate "add from catalog" and "scan from photo" in AddPackItemActions with presentPaywallIfNeeded before opening the picker/modal - Gate gap analysis flow in PackDetailScreen with presentPaywallIfNeeded before opening the activity picker --- apps/expo/app/(app)/pack/items-scan.tsx | 7 ++++++- .../packs/components/AddPackItemActions.tsx | 19 +++++++++++++++++-- .../packs/screens/PackDetailScreen.tsx | 11 ++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) 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/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); From b6cc2842672d6c2394385377e07031f91a1ce436 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 08:46:43 +0100 Subject: [PATCH 07/18] fix(expo): remove paywall from trips, weight analysis, pack categories, and gear inventory --- apps/expo/app/(app)/(tabs)/trips/index.tsx | 7 +- apps/expo/app/(app)/gear-inventory.tsx | 119 +++++++------ apps/expo/app/(app)/pack-categories/[id].tsx | 43 +++-- apps/expo/app/(app)/weight-analysis/[id].tsx | 165 +++++++++---------- 4 files changed, 159 insertions(+), 175 deletions(-) diff --git a/apps/expo/app/(app)/(tabs)/trips/index.tsx b/apps/expo/app/(app)/(tabs)/trips/index.tsx index b11c373ae9..f1727fcbe0 100644 --- a/apps/expo/app/(app)/(tabs)/trips/index.tsx +++ b/apps/expo/app/(app)/(tabs)/trips/index.tsx @@ -1,16 +1,11 @@ import { featureFlags } from 'expo-app/config'; -import { ProGate } from 'expo-app/features/purchases'; import { TripsListScreen } from 'expo-app/features/trips/screens/TripListScreen'; import { Redirect } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; export default function TripsScreen() { if (!featureFlags.enableTrips) return ; - return ( - - - - ); + return ; } function TripsScreenInner() { diff --git a/apps/expo/app/(app)/gear-inventory.tsx b/apps/expo/app/(app)/gear-inventory.tsx index 837dda894d..5f9e1bfafb 100644 --- a/apps/expo/app/(app)/gear-inventory.tsx +++ b/apps/expo/app/(app)/gear-inventory.tsx @@ -4,7 +4,6 @@ import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { PackItemCard } from 'expo-app/features/packs/components/PackItemCard'; import { useUserPackItems } from 'expo-app/features/packs/hooks/useUserPackItems'; import type { PackItem } from 'expo-app/features/packs/types'; -import { ProGate } from 'expo-app/features/purchases'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { Stack, useRouter } from 'expo-router'; @@ -66,70 +65,66 @@ export default function GearInventoryScreen() { const itemsByCategory = groupByCategory(items); return ( - - - - - - - {t('packs.itemsInInventory', { count: items?.length })} - - - setViewMode('all')} + + + + + + {t('packs.itemsInInventory', { count: items?.length })} + + + setViewMode('all')} + > + - - {t('packs.all')} - - - setViewMode('category')} + {t('packs.all')} + + + setViewMode('category')} + > + - - {t('packs.byCategory')} - - - + {t('packs.byCategory')} + + + - {viewMode === 'all' ? ( - - {items.map((item) => ( - - ))} - - ) : ( - - {Object.entries(itemsByCategory).map(([category, groupedItems]) => ( - - ))} - - )} - - - + {viewMode === 'all' ? ( + + {items.map((item) => ( + + ))} + + ) : ( + + {Object.entries(itemsByCategory).map(([category, groupedItems]) => ( + + ))} + + )} + + ); } diff --git a/apps/expo/app/(app)/pack-categories/[id].tsx b/apps/expo/app/(app)/pack-categories/[id].tsx index 0b32f38c12..f0dc3154ee 100644 --- a/apps/expo/app/(app)/pack-categories/[id].tsx +++ b/apps/expo/app/(app)/pack-categories/[id].tsx @@ -4,7 +4,6 @@ import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; import { computeCategorySummaries } from 'expo-app/features/packs/utils'; -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, useLocalSearchParams } from 'expo-router'; @@ -67,29 +66,27 @@ export default function PackCategoriesScreen() { const categories = computeCategorySummaries({ pack, preferredUnit: weightUnit }); return ( - - <> - - {categories.length ? ( - - - - {t('packs.organizeGear')} - - + <> + + {categories.length ? ( + + + + {t('packs.organizeGear')} + + - - {categories.map((category) => ( - - ))} - - - ) : ( - - {t('packs.noCategorizedItems')} + + {categories.map((category) => ( + + ))} - )} - - + + ) : ( + + {t('packs.noCategorizedItems')} + + )} + ); } diff --git a/apps/expo/app/(app)/weight-analysis/[id].tsx b/apps/expo/app/(app)/weight-analysis/[id].tsx index 8d99ce4291..d0cf934fbd 100644 --- a/apps/expo/app/(app)/weight-analysis/[id].tsx +++ b/apps/expo/app/(app)/weight-analysis/[id].tsx @@ -4,7 +4,6 @@ import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackWeightAnalysis } from 'expo-app/features/packs/hooks/usePackWeightAnalysis'; -import { ProGate } from 'expo-app/features/purchases'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { Stack, useLocalSearchParams } from 'expo-router'; @@ -48,96 +47,94 @@ export default function WeightAnalysisScreen() { const { convertWeight } = useWeightUnit(); return ( - - - - - - - - - - + + + + + + + + + - - - {t('packs.weightBreakdown')} - - - {t('packs.detailedAnalysis')} - - + + + {t('packs.weightBreakdown')} + + + {t('packs.detailedAnalysis')} + + - {data.categories.map((category, _categoryIndex) => ( - - {/* Category Header */} - - - - {category.name} - - - {category.weight} {preferredUnit} - - + {data.categories.map((category, _categoryIndex) => ( + + {/* Category Header */} + + + + {category.name} + + + {category.weight} {preferredUnit} + + - {/* Items */} - - {items - .filter((item) => item.category.trim() === category.name.trim()) - .map((item, itemIndex) => ( - 0 ? 'border-border/25 dark:border-border/80 border-t' : '', + {/* Items */} + + {items + .filter((item) => item.category.trim() === category.name.trim()) + .map((item, itemIndex) => ( + 0 ? 'border-border/25 dark:border-border/80 border-t' : '', + )} + > + + {item.name} + {item.notes && ( + + {item.notes} + )} - > - - {item.name} - {item.notes && ( - - {item.notes} - - )} - - - {convertWeight({ weight: item.weight, fromUnit: item.weightUnit || 'g' })}{' '} - {preferredUnit} - - ))} - + + {convertWeight({ weight: item.weight, fromUnit: item.weightUnit || 'g' })}{' '} + {preferredUnit} + + + ))} - ))} + + ))} - {!data.categories.length && ( - {t('packs.addItemsForBreakdown')} - )} - - - + {!data.categories.length && ( + {t('packs.addItemsForBreakdown')} + )} + + ); } From bf8ee19063181ad4ca13fbfc358ad99fb486e46f Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 08:47:55 +0100 Subject: [PATCH 08/18] feat(expo): add subscription management section to settings Shows Pro/Free status with a crown icon. Pro users get "Manage Subscription" (RevenueCat Customer Center); free users get "Upgrade to Pro" (paywall). --- apps/expo/app/(app)/settings/index.tsx | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index d0c2daa25e..b14c7d10d6 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 { + presentCustomerCenter, + useEntitlement, + 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'; @@ -45,6 +50,9 @@ export default function SettingsScreen() { const { unit: temperatureUnit, setTemperatureUnit } = useTemperatureUnit(); const { unit: speedUnit, setSpeedUnit } = useSpeedUnit(); + const { isProMember } = useEntitlement(); + const { presentPaywall } = usePresentPaywall(); + const isApple = isAppleIntelligenceAvailable(); const isDownloading = modelStatus === 'downloading'; const isPreparing = modelStatus === 'preparing' || modelStatus === 'checking'; @@ -165,6 +173,46 @@ export default function SettingsScreen() { + + + Subscription + + + + + + + + {isProMember ? 'PackRat Pro' : 'Free Plan'} + + {isProMember + ? 'You have full access to all Pro features' + : 'Upgrade to unlock all Pro features'} + + + + + + + + {isProMember ? 'Manage Subscription' : 'Upgrade to Pro'} + + + + + + + {t('ai.modelManagement')} From f33d817f25078458771f7eea1f769a1fabae6e4f Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 08:54:37 +0100 Subject: [PATCH 09/18] =?UTF-8?q?fix(expo):=20fix=20subscription=20button?= =?UTF-8?q?=20in=20settings=20=E2=80=94=20wrap=20in=20async=20handler=20an?= =?UTF-8?q?d=20surface=20RC=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presentCustomerCenter was silently swallowing errors (no re-throw), so failures were invisible. Now it re-throws, and the settings onPress wraps both paths in a try/catch that shows a Burnt error toast on failure. --- apps/expo/app/(app)/settings/index.tsx | 14 +++++++++++++- .../purchases/components/CustomerCenter.tsx | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index b14c7d10d6..7cb4f4db9c 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -53,6 +53,18 @@ export default function SettingsScreen() { const { isProMember } = useEntitlement(); const { presentPaywall } = usePresentPaywall(); + const handleSubscriptionPress = async () => { + try { + if (isProMember) { + await presentCustomerCenter(); + } else { + await presentPaywall(); + } + } catch { + Burnt.toast({ title: 'Something went wrong. Please try again.', preset: 'error' }); + } + }; + const isApple = isAppleIntelligenceAvailable(); const isDownloading = modelStatus === 'downloading'; const isPreparing = modelStatus === 'preparing' || modelStatus === 'checking'; @@ -201,7 +213,7 @@ export default function SettingsScreen() { diff --git a/apps/expo/features/purchases/components/CustomerCenter.tsx b/apps/expo/features/purchases/components/CustomerCenter.tsx index c370d49f88..df21765ba6 100644 --- a/apps/expo/features/purchases/components/CustomerCenter.tsx +++ b/apps/expo/features/purchases/components/CustomerCenter.tsx @@ -14,6 +14,7 @@ export async function presentCustomerCenter() { Sentry.captureException(error, { tags: { feature: 'purchases', action: 'presentCustomerCenter' }, }); + throw error; } } From aae770bea10605ff709fda0b9aa7eb59edd06ef9 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 08:59:04 +0100 Subject: [PATCH 10/18] fix(expo): add diagnostic toast + NOT_PRESENTED fallback to subscription button Adds an immediate toast on press to confirm the handler fires, and falls back to the platform subscription management URL when RevenueCat returns NOT_PRESENTED (no offerings configured). --- apps/expo/app/(app)/settings/index.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 7cb4f4db9c..6a72831712 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -34,7 +34,8 @@ 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'; +import { PAYWALL_RESULT } from 'react-native-purchases-ui'; export default function SettingsScreen() { const { colorScheme, colors } = useColorScheme(); @@ -54,11 +55,19 @@ export default function SettingsScreen() { const { presentPaywall } = usePresentPaywall(); const handleSubscriptionPress = async () => { + Burnt.toast({ title: isProMember ? 'Opening subscription management…' : 'Opening upgrade…' }); try { if (isProMember) { await presentCustomerCenter(); } else { - await presentPaywall(); + const result = await presentPaywall(); + if (result === PAYWALL_RESULT.NOT_PRESENTED) { + const url = + Platform.OS === 'ios' + ? 'https://apps.apple.com/account/subscriptions' + : 'https://play.google.com/store/account/subscriptions'; + await Linking.openURL(url); + } } } catch { Burnt.toast({ title: 'Something went wrong. Please try again.', preset: 'error' }); From a1117b09056cfe1b95e21bdd5228fa24faaeac2b Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 09:12:54 +0100 Subject: [PATCH 11/18] fix(expo): remove diagnostic toast from subscription handler --- apps/expo/app/(app)/settings/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 6a72831712..c1262641b1 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -55,7 +55,6 @@ export default function SettingsScreen() { const { presentPaywall } = usePresentPaywall(); const handleSubscriptionPress = async () => { - Burnt.toast({ title: isProMember ? 'Opening subscription management…' : 'Opening upgrade…' }); try { if (isProMember) { await presentCustomerCenter(); From 5ac6dbb619a2b3fad40258c55a659c98b7165c53 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 09:17:47 +0100 Subject: [PATCH 12/18] fix(expo): replace imperative RC calls with dedicated paywall/customer-center screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit presentPaywall() and presentCustomerCenter() both silently do nothing when called from a modal screen (known RC issue #1201). The fix is to use RevenueCatUI.Paywall and RevenueCatUI.CustomerCenterView as actual screen components in dedicated routes and navigate to them instead. - Add app/(app)/paywall.tsx — renders RevenueCatUI.Paywall, invalidates customer info on purchase/restore, goes back on dismiss - Add app/(app)/customer-center.tsx — renders RevenueCatUI.CustomerCenterView with shouldShowCloseButton=false (header back handles dismiss) - Register both in the (app) stack as non-modal card screens - Settings handler now does router.push('/paywall') or '/customer-center' --- apps/expo/app/(app)/_layout.tsx | 5 +++++ apps/expo/app/(app)/customer-center.tsx | 25 +++++++++++++++++++++ apps/expo/app/(app)/paywall.tsx | 30 +++++++++++++++++++++++++ apps/expo/app/(app)/settings/index.tsx | 29 ++++-------------------- 4 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 apps/expo/app/(app)/customer-center.tsx create mode 100644 apps/expo/app/(app)/paywall.tsx diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index abf82a12a1..7904190062 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -124,6 +124,11 @@ export default function AppLayout() { options={getCatalogAddToPackItemDetailsOptions(t)} /> + + { + queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY }); + router.back(); + }; + + return ( + + + + ); +} diff --git a/apps/expo/app/(app)/paywall.tsx b/apps/expo/app/(app)/paywall.tsx new file mode 100644 index 0000000000..9b02f09ad9 --- /dev/null +++ b/apps/expo/app/(app)/paywall.tsx @@ -0,0 +1,30 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { CUSTOMER_INFO_QUERY_KEY } from 'expo-app/features/purchases/hooks/useCustomerInfo'; +import { useRouter } from 'expo-router'; +import { View } from 'react-native'; +import RevenueCatUI from 'react-native-purchases-ui'; + +export default function PaywallScreen() { + const router = useRouter(); + const queryClient = useQueryClient(); + + const handleDismiss = () => { + router.back(); + }; + + const handlePurchaseCompleted = () => { + queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY }); + router.back(); + }; + + return ( + + + + ); +} diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index c1262641b1..560fcae5fd 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -21,11 +21,7 @@ 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 { - presentCustomerCenter, - useEntitlement, - usePresentPaywall, -} from 'expo-app/features/purchases'; +import { useEntitlement } 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'; @@ -34,8 +30,7 @@ import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { useAtomValue } from 'jotai'; -import { Linking, Platform, ScrollView, TouchableOpacity, View } from 'react-native'; -import { PAYWALL_RESULT } from 'react-native-purchases-ui'; +import { Platform, ScrollView, TouchableOpacity, View } from 'react-native'; export default function SettingsScreen() { const { colorScheme, colors } = useColorScheme(); @@ -52,25 +47,9 @@ export default function SettingsScreen() { const { unit: speedUnit, setSpeedUnit } = useSpeedUnit(); const { isProMember } = useEntitlement(); - const { presentPaywall } = usePresentPaywall(); - const handleSubscriptionPress = async () => { - try { - if (isProMember) { - await presentCustomerCenter(); - } else { - const result = await presentPaywall(); - if (result === PAYWALL_RESULT.NOT_PRESENTED) { - const url = - Platform.OS === 'ios' - ? 'https://apps.apple.com/account/subscriptions' - : 'https://play.google.com/store/account/subscriptions'; - await Linking.openURL(url); - } - } - } catch { - Burnt.toast({ title: 'Something went wrong. Please try again.', preset: 'error' }); - } + const handleSubscriptionPress = () => { + router.push(isProMember ? '/customer-center' : '/paywall'); }; const isApple = isAppleIntelligenceAvailable(); From 837b5da9190fe224e1c6bd0bb587f215a226d789 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 09:24:28 +0100 Subject: [PATCH 13/18] fix(expo): convert settings to card screen with large title, restore imperative RC APIs Modal presentation was blocking presentPaywall/presentCustomerCenter (RC issue #1201). Converting settings to a normal push screen with headerLargeTitle unblocks the imperative APIs. Removes the dedicated paywall/customer-center route workaround. --- apps/expo/app/(app)/_layout.tsx | 9 +------- apps/expo/app/(app)/customer-center.tsx | 25 --------------------- apps/expo/app/(app)/paywall.tsx | 30 ------------------------- apps/expo/app/(app)/settings/index.tsx | 19 +++++++++++++--- 4 files changed, 17 insertions(+), 66 deletions(-) delete mode 100644 apps/expo/app/(app)/customer-center.tsx delete mode 100644 apps/expo/app/(app)/paywall.tsx diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index 7904190062..dfb83ff48c 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -124,11 +124,6 @@ export default function AppLayout() { options={getCatalogAddToPackItemDetailsOptions(t)} /> - - ({ - presentation: 'modal', - animation: 'fade_from_bottom', // for android title: t('profile.settings'), + headerLargeTitle: true, headerRight: () => , }) as const; diff --git a/apps/expo/app/(app)/customer-center.tsx b/apps/expo/app/(app)/customer-center.tsx deleted file mode 100644 index 3bf1108c21..0000000000 --- a/apps/expo/app/(app)/customer-center.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { CUSTOMER_INFO_QUERY_KEY } from 'expo-app/features/purchases/hooks/useCustomerInfo'; -import { useRouter } from 'expo-router'; -import { View } from 'react-native'; -import RevenueCatUI from 'react-native-purchases-ui'; - -export default function CustomerCenterScreen() { - const router = useRouter(); - const queryClient = useQueryClient(); - - const handleDismiss = () => { - queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY }); - router.back(); - }; - - return ( - - - - ); -} diff --git a/apps/expo/app/(app)/paywall.tsx b/apps/expo/app/(app)/paywall.tsx deleted file mode 100644 index 9b02f09ad9..0000000000 --- a/apps/expo/app/(app)/paywall.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { CUSTOMER_INFO_QUERY_KEY } from 'expo-app/features/purchases/hooks/useCustomerInfo'; -import { useRouter } from 'expo-router'; -import { View } from 'react-native'; -import RevenueCatUI from 'react-native-purchases-ui'; - -export default function PaywallScreen() { - const router = useRouter(); - const queryClient = useQueryClient(); - - const handleDismiss = () => { - router.back(); - }; - - const handlePurchaseCompleted = () => { - queryClient.invalidateQueries({ queryKey: CUSTOMER_INFO_QUERY_KEY }); - router.back(); - }; - - return ( - - - - ); -} diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 560fcae5fd..7cb4f4db9c 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -21,7 +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 } from 'expo-app/features/purchases'; +import { + presentCustomerCenter, + useEntitlement, + 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'; @@ -47,9 +51,18 @@ export default function SettingsScreen() { const { unit: speedUnit, setSpeedUnit } = useSpeedUnit(); const { isProMember } = useEntitlement(); + const { presentPaywall } = usePresentPaywall(); - const handleSubscriptionPress = () => { - router.push(isProMember ? '/customer-center' : '/paywall'); + const handleSubscriptionPress = async () => { + try { + if (isProMember) { + await presentCustomerCenter(); + } else { + await presentPaywall(); + } + } catch { + Burnt.toast({ title: 'Something went wrong. Please try again.', preset: 'error' }); + } }; const isApple = isAppleIntelligenceAvailable(); From 1a19c242b5a0faf1ad494282437a6a5830a93f0c Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 09:28:11 +0100 Subject: [PATCH 14/18] feat(expo): replace RC Customer Center with proper subscription management UI - Pro users: "Manage Subscription" opens App Store/Play Store subscriptions page - Free users: "Upgrade to Pro" presents the RC paywall - All users: "Restore Purchases" row with loading state and toast feedback --- apps/expo/app/(app)/settings/index.tsx | 83 +++++++++++++++++++------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 7cb4f4db9c..a61fc4ae6b 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -22,9 +22,9 @@ import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureU import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useSeasonSuggestionsPrefs } from 'expo-app/features/packs/atoms/seasonSuggestionsAtoms'; import { - presentCustomerCenter, useEntitlement, usePresentPaywall, + useRestorePurchases, } from 'expo-app/features/purchases'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -34,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(); @@ -52,17 +52,29 @@ export default function SettingsScreen() { const { isProMember } = useEntitlement(); const { presentPaywall } = usePresentPaywall(); + const { mutate: restorePurchases, isPending: isRestoring } = useRestorePurchases(); - const handleSubscriptionPress = async () => { - try { - if (isProMember) { - await presentCustomerCenter(); - } else { - await presentPaywall(); - } - } catch { - Burnt.toast({ title: 'Something went wrong. Please try again.', preset: 'error' }); - } + 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(); @@ -190,6 +202,7 @@ export default function SettingsScreen() { Subscription + {/* Plan status row */} {isProMember ? 'PackRat Pro' : 'Free Plan'} {isProMember - ? 'You have full access to all Pro features' - : 'Upgrade to unlock all Pro features'} + ? 'Full access to all Pro features' + : 'Upgrade to unlock Pro features'} + - - + + {/* Primary action */} + {isProMember ? ( + - {isProMember ? 'Manage Subscription' : 'Upgrade to Pro'} + Manage Subscription - - + + + ) : ( + + + Upgrade to Pro + + + + )} + + + + {/* Restore purchases */} + + + {isRestoring ? 'Restoring…' : 'Restore Purchases'} + From 3ec62f219adbd7e46a12c21c4577b0ac53509227 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 09:34:54 +0100 Subject: [PATCH 15/18] fix(expo): add contentInsetAdjustment to settings, add RC purchases deps - Set contentInsetAdjustmentBehavior="automatic" on settings ScrollView - Add react-native-purchases and react-native-purchases-ui to root deps - Update dev bundle identifier to use .devrc suffix --- apps/expo/app.config.ts | 6 +++--- apps/expo/app/(app)/settings/index.tsx | 2 +- bun.lock | 16 ++++++++++++++++ package.json | 6 +++++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 06a80fc839..46d4202957 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -9,19 +9,19 @@ const IS_DEV = process.env.APP_VARIANT === 'development'; const IS_PREVIEW = process.env.APP_VARIANT === 'preview'; const getAppName = () => { - if (IS_DEV) return 'PackRat (Dev)'; + if (IS_DEV) return 'PackRat (Dev) rc'; if (IS_PREVIEW) return 'PackRat (Preview)'; return 'PackRat'; }; const getBundleIdentifier = () => { - if (IS_DEV) return 'com.andrewbierman.packrat.dev'; + if (IS_DEV) return 'com.andrewbierman.packrat.devrc'; if (IS_PREVIEW) return 'com.andrewbierman.packrat.preview'; return 'com.andrewbierman.packrat'; }; const getAndroidPackage = () => { - if (IS_DEV) return 'com.packratai.mobile.dev'; + if (IS_DEV) return 'com.packratai.mobile.devrc'; if (IS_PREVIEW) return 'com.packratai.mobile.preview'; return 'com.packratai.mobile'; }; diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index a61fc4ae6b..ac54729f65 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -133,7 +133,7 @@ export default function SettingsScreen() { const iconName: MaterialIconName = isApple ? 'apple' : 'atom'; return ( - + = 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..c7546bc16f 100644 --- a/package.json +++ b/package.json @@ -206,5 +206,9 @@ "patchedDependencies": {}, "trustedDependencies": [ "@sentry/cli" - ] + ], + "dependencies": { + "react-native-purchases": "^10.4.0", + "react-native-purchases-ui": "^10.4.0" + } } From bf1606461d846a3abd523c0a0838f63b654086d7 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 09:35:21 +0100 Subject: [PATCH 16/18] chore: sort root package.json keys --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c7546bc16f..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", @@ -206,9 +210,5 @@ "patchedDependencies": {}, "trustedDependencies": [ "@sentry/cli" - ], - "dependencies": { - "react-native-purchases": "^10.4.0", - "react-native-purchases-ui": "^10.4.0" - } + ] } From db9d2829bbfc02e9d1f70a0078947918928ea8c8 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 09:41:12 +0100 Subject: [PATCH 17/18] =?UTF-8?q?revert(expo):=20restore=20app.config.ts?= =?UTF-8?q?=20=E2=80=94=20not=20for=20this=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/app.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 46d4202957..06a80fc839 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -9,19 +9,19 @@ const IS_DEV = process.env.APP_VARIANT === 'development'; const IS_PREVIEW = process.env.APP_VARIANT === 'preview'; const getAppName = () => { - if (IS_DEV) return 'PackRat (Dev) rc'; + if (IS_DEV) return 'PackRat (Dev)'; if (IS_PREVIEW) return 'PackRat (Preview)'; return 'PackRat'; }; const getBundleIdentifier = () => { - if (IS_DEV) return 'com.andrewbierman.packrat.devrc'; + if (IS_DEV) return 'com.andrewbierman.packrat.dev'; if (IS_PREVIEW) return 'com.andrewbierman.packrat.preview'; return 'com.andrewbierman.packrat'; }; const getAndroidPackage = () => { - if (IS_DEV) return 'com.packratai.mobile.devrc'; + if (IS_DEV) return 'com.packratai.mobile.dev'; if (IS_PREVIEW) return 'com.packratai.mobile.preview'; return 'com.packratai.mobile'; }; From 429ec225ff878dd5526531ce04b9c120878ab716 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Sat, 20 Jun 2026 09:45:15 +0100 Subject: [PATCH 18/18] fix(purchases): move RC API key to EXPO_PUBLIC_REVENUECAT_API_KEY env var --- apps/expo/features/purchases/lib/revenueCat.ts | 9 +++++---- packages/env/src/expo-client.ts | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/expo/features/purchases/lib/revenueCat.ts b/apps/expo/features/purchases/lib/revenueCat.ts index 63b296c681..f91dce4ae6 100644 --- a/apps/expo/features/purchases/lib/revenueCat.ts +++ b/apps/expo/features/purchases/lib/revenueCat.ts @@ -1,15 +1,16 @@ +import { clientEnvs } from '@packrat/env/expo-client'; import * as Sentry from '@sentry/react-native'; import Purchases, { LOG_LEVEL } from 'react-native-purchases'; -// RevenueCat public API key — not a secret, safe to embed in the app bundle. -const REVENUECAT_API_KEY = 'test_rmRjXKZMmykOaEhtvmoRYtqmVGA'; - 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: REVENUECAT_API_KEY }); + Purchases.configure({ apiKey }); Sentry.addBreadcrumb({ category: 'purchases', message: 'RevenueCat configured', 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, }; /**