-
Notifications
You must be signed in to change notification settings - Fork 38
feat(expo): RevenueCat paywall integration & subscription management UI #2601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
a473460
6ec7705
f3b45eb
9b367a0
66b696a
d2985d3
b6cc284
bf8ee19
f33d817
aae770b
a1117b0
5ac6dbb
837b5da
1a19c24
3ec62f2
bf16064
db9d282
429ec22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <CatalogItemsScreen />; | ||
| return ( | ||
| <ProGate> | ||
| <CatalogItemsScreen /> | ||
| </ProGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,10 @@ | ||
| import { FeedScreen } from 'expo-app/features/feed'; | ||
| import { ProGate } from 'expo-app/features/purchases'; | ||
|
|
||
| export default function FeedRoute() { | ||
| return <FeedScreen />; | ||
| return ( | ||
| <ProGate> | ||
| <FeedScreen /> | ||
| </ProGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ( | ||
| <> | ||
| <Stack.Screen | ||
| options={{ | ||
| header: () => <AiChatHeader onClear={handleClear} />, | ||
| }} | ||
| /> | ||
| <KeyboardAvoidingView | ||
| style={[ | ||
| ROOT_STYLE, | ||
| { | ||
| backgroundColor: isDarkColorScheme ? colors.background : colors.card, | ||
| }, | ||
| ]} | ||
| behavior="padding" | ||
| > | ||
| <ScrollView | ||
| ref={scrollViewRef} | ||
| onLayout={onLayout} | ||
| onScroll={onScroll} | ||
| onContentSizeChange={onContentSizeChange} | ||
| scrollIndicatorInsets={{ | ||
| bottom: HEADER_HEIGHT + 10, | ||
| top: insets.bottom + 2, | ||
| <ProGate> | ||
| <> | ||
| <Stack.Screen | ||
| options={{ | ||
| header: () => <AiChatHeader onClear={handleClear} />, | ||
| }} | ||
| /> | ||
| <KeyboardAvoidingView | ||
| style={[ | ||
| ROOT_STYLE, | ||
| { | ||
| backgroundColor: isDarkColorScheme ? colors.background : colors.card, | ||
| }, | ||
| ]} | ||
| behavior="padding" | ||
| > | ||
| <View> | ||
| <View | ||
| style={{ | ||
| height: Platform.OS === 'ios' ? insets.top + 52 : HEADER_HEIGHT + insets.top, | ||
| }} | ||
| /> | ||
| <LocationContext location={location} onSetLocation={setLocation} /> | ||
| <DateSeparator | ||
| date={new Date().toLocaleDateString('en-US', { | ||
| weekday: 'short', | ||
| day: '2-digit', | ||
| month: 'short', | ||
| year: 'numeric', | ||
| })} | ||
| /> | ||
| </View> | ||
|
|
||
| {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; | ||
| } | ||
| <ScrollView | ||
| ref={scrollViewRef} | ||
| onLayout={onLayout} | ||
| onScroll={onScroll} | ||
| onContentSizeChange={onContentSizeChange} | ||
| scrollIndicatorInsets={{ | ||
| bottom: HEADER_HEIGHT + 10, | ||
| top: insets.bottom + 2, | ||
| }} | ||
| > | ||
| <View> | ||
| <View | ||
| style={{ | ||
| height: Platform.OS === 'ios' ? insets.top + 52 : HEADER_HEIGHT + insets.top, | ||
| }} | ||
| /> | ||
| <LocationContext location={location} onSetLocation={setLocation} /> | ||
| <DateSeparator | ||
| date={new Date().toLocaleDateString('en-US', { | ||
| weekday: 'short', | ||
| day: '2-digit', | ||
| month: 'short', | ||
| year: 'numeric', | ||
| })} | ||
| /> | ||
| </View> | ||
|
|
||
| return ( | ||
| <ChatBubble | ||
| key={item.id} | ||
| item={item} | ||
| userQuery={userQuery} | ||
| isLast={index === messages.length - 1} | ||
| status={status} | ||
| testID={ | ||
| item.role === 'assistant' ? testIds.aiChat.assistantMessage(item.id) : undefined | ||
| } | ||
| {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 ( | ||
| <ChatBubble | ||
| key={item.id} | ||
| item={item} | ||
| userQuery={userQuery} | ||
| isLast={index === messages.length - 1} | ||
| status={status} | ||
| testID={ | ||
| item.role === 'assistant' ? testIds.aiChat.assistantMessage(item.id) : undefined | ||
| } | ||
| /> | ||
| ); | ||
| })} | ||
|
|
||
| {status === 'submitted' && ( | ||
| <ActivityIndicator | ||
| size="small" | ||
| color={colors.primary} | ||
| className="self-start ml-4 mb-8" | ||
| /> | ||
| ); | ||
| })} | ||
|
|
||
| {status === 'submitted' && ( | ||
| <ActivityIndicator | ||
| size="small" | ||
| color={colors.primary} | ||
| className="self-start ml-4 mb-8" | ||
| /> | ||
| )} | ||
| {status === 'error' && ( | ||
| <ErrorState error={error} onRetry={() => handleRetry()} onClear={handleClear} /> | ||
| )} | ||
| {messages.length < 2 && ( | ||
| <View className="pl-4 pr-16"> | ||
| <Text className="mb-2 text-xs text-muted-foreground mt-0">{t('ai.suggestions')}</Text> | ||
| <View className="flex-row flex-wrap gap-2"> | ||
| {getContextualSuggestions({ context, isAuthenticated }).map((suggestion) => ( | ||
| <TouchableOpacity | ||
| key={suggestion} | ||
| onPress={() => handleSubmit(suggestion)} | ||
| className="mb-2 rounded-3xl border border-border bg-card px-3 py-2" | ||
| > | ||
| <Text className="text-sm text-foreground">{suggestion}</Text> | ||
| </TouchableOpacity> | ||
| ))} | ||
| )} | ||
| {status === 'error' && ( | ||
| <ErrorState error={error} onRetry={() => handleRetry()} onClear={handleClear} /> | ||
| )} | ||
| {messages.length < 2 && ( | ||
| <View className="pl-4 pr-16"> | ||
| <Text className="mb-2 text-xs text-muted-foreground mt-0"> | ||
| {t('ai.suggestions')} | ||
| </Text> | ||
| <View className="flex-row flex-wrap gap-2"> | ||
| {getContextualSuggestions({ context, isAuthenticated }).map((suggestion) => ( | ||
| <TouchableOpacity | ||
| key={suggestion} | ||
| onPress={() => handleSubmit(suggestion)} | ||
| className="mb-2 rounded-3xl border border-border bg-card px-3 py-2" | ||
| > | ||
| <Text className="text-sm text-foreground">{suggestion}</Text> | ||
| </TouchableOpacity> | ||
| ))} | ||
| </View> | ||
| </View> | ||
| </View> | ||
| )} | ||
| <Animated.View style={[toolbarHeightStyle, { marginBottom: 20 }]} /> | ||
| </ScrollView> | ||
| </KeyboardAvoidingView> | ||
|
|
||
| <KeyboardStickyView offset={{ opened: insets.bottom }}> | ||
| <Composer | ||
| textInputHeight={textInputHeight} | ||
| input={input} | ||
| handleInputChange={setInput} | ||
| handleSubmit={() => { | ||
| handleSubmit(); | ||
| }} | ||
| stop={stop} | ||
| isLoading={isLoading} | ||
| placeholder={ | ||
| context.contextType === 'general' | ||
| ? t('ai.askAnythingOutdoors') | ||
| : context.contextType === 'item' | ||
| ? t('ai.askAboutItem') | ||
| : t('ai.askAboutPack') | ||
| } | ||
| /> | ||
| </KeyboardStickyView> | ||
| {isArrowButtonVisible && status === 'ready' && ( | ||
| <TouchableOpacity | ||
| onPress={scrollToBottom} | ||
| className="absolute bottom-20 right-4 rounded-full bg-gray-200 p-3 mb-5 shadow-lg" | ||
| > | ||
| <Icon name="arrow-down" size={20} color="black" /> | ||
| </TouchableOpacity> | ||
| )} | ||
| </> | ||
| )} | ||
| <Animated.View style={[toolbarHeightStyle, { marginBottom: 20 }]} /> | ||
| </ScrollView> | ||
| </KeyboardAvoidingView> | ||
|
|
||
| <KeyboardStickyView offset={{ opened: insets.bottom }}> | ||
| <Composer | ||
| textInputHeight={textInputHeight} | ||
| input={input} | ||
| handleInputChange={setInput} | ||
| handleSubmit={() => { | ||
| handleSubmit(); | ||
| }} | ||
| stop={stop} | ||
| isLoading={isLoading} | ||
| placeholder={ | ||
| context.contextType === 'general' | ||
| ? t('ai.askAnythingOutdoors') | ||
| : context.contextType === 'item' | ||
| ? t('ai.askAboutItem') | ||
| : t('ai.askAboutPack') | ||
| } | ||
| /> | ||
| </KeyboardStickyView> | ||
| {isArrowButtonVisible && status === 'ready' && ( | ||
| <TouchableOpacity | ||
| onPress={scrollToBottom} | ||
| className="absolute bottom-20 right-4 rounded-full bg-gray-200 p-3 mb-5 shadow-lg" | ||
| > | ||
| <Icon name="arrow-down" size={20} color="black" /> | ||
| </TouchableOpacity> | ||
| )} | ||
| </> | ||
| </ProGate> | ||
|
Comment on lines
+438
to
+562
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid mounting the full AI chat subtree behind the non-Pro invisible render path. At Line 438, the full screen is wrapped in Suggested direction+ // outside this block (near other hooks)
+ // const { isProMember } = useEntitlement();
return (
<ProGate>
<>
<Stack.Screen
options={{
header: () => <AiChatHeader onClear={handleClear} />,
}}
/>
- <KeyboardAvoidingView ...>
- ...
- </KeyboardAvoidingView>
+ {isProMember ? (
+ <KeyboardAvoidingView ...>
+ ...
+ </KeyboardAvoidingView>
+ ) : null}
</>
</ProGate>
);🤖 Prompt for AI Agents |
||
| ); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <CatalogItemDetailScreen />; | ||
| return ( | ||
| <ProGate> | ||
| <CatalogItemDetailScreen /> | ||
| </ProGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <AddCatalogItemDetailsScreen />; | ||
| return ( | ||
| <ProGate> | ||
| <AddCatalogItemDetailsScreen /> | ||
| </ProGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <PackSelectionScreen />; | ||
| return ( | ||
| <ProGate> | ||
| <PackSelectionScreen /> | ||
| </ProGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <PostDetailScreen post={post} currentUserId={currentUserId} />; | ||
| return ( | ||
| <ProGate> | ||
| <PostDetailScreen post={post} currentUserId={currentUserId} /> | ||
| </ProGate> | ||
| ); | ||
|
Comment on lines
+40
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gate data fetching, not only rendering. Wrapping only the return path at Lines 40-44 does not prevent the post query (Line 14) from running for non-Pro users. This undermines route gating and does unnecessary API work. Minimal fix sketch+ import { ProGate, useEntitlement } from 'expo-app/features/purchases';
export default function PostDetailRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
+ const { isProMember } = useEntitlement();
const currentUserId = userStore.id.peek() as string | undefined;
const { data: post, isLoading } = useQuery({
queryKey: ['feed', Number(id)],
queryFn: async () => {
const { data, error } = await apiClient.feed({ postId: id }).get();
if (error) throw new Error(`Failed to fetch post: ${error.value}`);
return data;
},
- enabled: !!id,
+ enabled: !!id && isProMember,
});
return (
<ProGate>
- <PostDetailScreen post={post} currentUserId={currentUserId} />
+ {isProMember && post ? <PostDetailScreen post={post} currentUserId={currentUserId} /> : null}
</ProGate>
);
}🤖 Prompt for AI Agents |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: PackRat-AI/PackRat
Length of output: 7959
🏁 Script executed:
Repository: PackRat-AI/PackRat
Length of output: 3618
Add a guard to prevent RevenueCat sync during auth hydration.
Line 37 calls
useRevenueCatUser()unconditionally. SinceuseAuthInit()awaits cache hydration asynchronously,useRevenueCatUser's effect may run beforeuserStoreis populated, causing it to callresetRevenueCatUser()with a null user, thenidentifyRevenueCatUser()moments later when hydration completes—creating churn during cold start.Either:
isLoadingis false, oruseRevenueCatUser()to internally skip its effect until hydration is complete.Since hooks cannot be conditionally invoked, option 2 requires passing
isLoadingstate to the hook or checkinguserSyncState.isPersistLoadedinternally.🤖 Prompt for AI Agents