Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a473460
feat(expo): integrate RevenueCat and paywall non-core features
mikib0 Jun 19, 2026
6ec7705
fix(expo): remove paywall from admin screens and fix modal double-dis…
mikib0 Jun 19, 2026
f3b45eb
fix(ProGate): remove upgrade prompt — paywall is the only non-pro UI
mikib0 Jun 19, 2026
9b367a0
fix(ProGate): drop unused fallback prop
mikib0 Jun 19, 2026
66b696a
fix(expo): present weight-analysis and pack-categories as card screen…
mikib0 Jun 19, 2026
d2985d3
fix(expo): gate catalog browse, scan, and gap analysis behind Pro pay…
mikib0 Jun 19, 2026
b6cc284
fix(expo): remove paywall from trips, weight analysis, pack categorie…
mikib0 Jun 20, 2026
bf8ee19
feat(expo): add subscription management section to settings
mikib0 Jun 20, 2026
f33d817
fix(expo): fix subscription button in settings — wrap in async handle…
mikib0 Jun 20, 2026
aae770b
fix(expo): add diagnostic toast + NOT_PRESENTED fallback to subscript…
mikib0 Jun 20, 2026
a1117b0
fix(expo): remove diagnostic toast from subscription handler
mikib0 Jun 20, 2026
5ac6dbb
fix(expo): replace imperative RC calls with dedicated paywall/custome…
mikib0 Jun 20, 2026
837b5da
fix(expo): convert settings to card screen with large title, restore …
mikib0 Jun 20, 2026
1a19c24
feat(expo): replace RC Customer Center with proper subscription manag…
mikib0 Jun 20, 2026
3ec62f2
fix(expo): add contentInsetAdjustment to settings, add RC purchases deps
mikib0 Jun 20, 2026
bf16064
chore: sort root package.json keys
mikib0 Jun 20, 2026
db9d282
revert(expo): restore app.config.ts — not for this PR
mikib0 Jun 20, 2026
429ec22
fix(purchases): move RC API key to EXPO_PUBLIC_REVENUECAT_API_KEY env…
mikib0 Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/expo/app/(app)/(tabs)/catalog/index.tsx
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>
);
}
7 changes: 6 additions & 1 deletion apps/expo/app/(app)/(tabs)/feed/index.tsx
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>
);
}
3 changes: 0 additions & 3 deletions apps/expo/app/(app)/(tabs)/trips/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ import { Redirect } from 'expo-router';
import { StatusBar } from 'expo-status-bar';

export default function TripsScreen() {
// Gate the tab route behind the trips feature flag. The tab trigger is
// already hidden in the layout, but this also blocks deep links such as
// `packrat://(tabs)/trips` from bypassing the kill switch.
if (!featureFlags.enableTrips) return <Redirect href="/" />;
return <TripsScreenInner />;
}
Expand Down
12 changes: 5 additions & 7 deletions apps/expo/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,6 +34,7 @@ export {
export default function AppLayout() {
const isLoading = useAuthInit();
const isAuthedValue = use$(isAuthed);
useRevenueCatUser();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect useRevenueCatUser implementation:"
fd -i 'useRevenueCatUser.ts' apps/expo | xargs -I{} sed -n '1,220p' {}

echo
echo "Inspect useAuthInit implementation:"
fd -i 'useAuthInit.ts' apps/expo | xargs -I{} sed -n '1,240p' {}

echo
echo "Inspect auth store defaults/hydration:"
fd -i 'store.ts' apps/expo/features/auth | xargs -I{} sed -n '1,260p' {}

Repository: PackRat-AI/PackRat

Length of output: 7959


🏁 Script executed:

cat -n apps/expo/app/\(app\)/_layout.tsx | head -60

Repository: PackRat-AI/PackRat

Length of output: 3618


Add a guard to prevent RevenueCat sync during auth hydration.

Line 37 calls useRevenueCatUser() unconditionally. Since useAuthInit() awaits cache hydration asynchronously, useRevenueCatUser's effect may run before userStore is populated, causing it to call resetRevenueCatUser() with a null user, then identifyRevenueCatUser() moments later when hydration completes—creating churn during cold start.

Either:

  1. Wrap the call with a conditional that defers to after isLoading is false, or
  2. Modify useRevenueCatUser() to internally skip its effect until hydration is complete.

Since hooks cannot be conditionally invoked, option 2 requires passing isLoading state to the hook or checking userSyncState.isPersistLoaded internally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/app/`(app)/_layout.tsx at line 37, The useRevenueCatUser() hook is
being called unconditionally before auth hydration from useAuthInit() completes,
causing its effect to run prematurely with a null user and trigger both
resetRevenueCatUser() followed immediately by identifyRevenueCatUser() on next
render. Modify the useRevenueCatUser() hook to defer its synchronization effects
until hydration is complete by either: (1) accepting an isLoading parameter from
the calling component and skipping the effect when isLoading is true, or (2)
checking userSyncState.isPersistLoaded internally before calling
resetRevenueCatUser() and identifyRevenueCatUser(). This ensures the hook only
runs its effects after the user store is populated.

const { t } = useTranslation();
const needsReauth = useAtomValue(needsReauthAtom);
const isLoadingGlobal = useAtomValue(isLoadingAtom);
Expand Down Expand Up @@ -170,15 +172,13 @@ export default function AppLayout() {
<Stack.Screen
name="weight-analysis/[id]"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
presentation: 'card',
}}
/>
<Stack.Screen
name="pack-categories/[id]"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
presentation: 'card',
}}
/>
<Stack.Screen
Expand Down Expand Up @@ -310,12 +310,10 @@ const TABS_OPTIONS = {
headerShown: false,
} as const;

// MODALS - These functions accept translation function t
const getSettingsOptions = (t: TranslationFunction) =>
({
presentation: 'modal',
animation: 'fade_from_bottom', // for android
title: t('profile.settings'),
headerLargeTitle: true,
headerRight: () => <ThemeToggle />,
}) as const;

Expand Down
237 changes: 121 additions & 116 deletions apps/expo/app/(app)/ai-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid mounting the full AI chat subtree behind the non-Pro invisible render path.

At Line 438, the full screen is wrapped in ProGate; for non-Pro users, ProGate still mounts children invisibly. That still executes the chat screen’s expensive effects/state setup before access is granted. Keep only header registration mounted for locked users and gate the heavy subtree by entitlement.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/app/`(app)/ai-chat.tsx around lines 438 - 562, The entire AI chat
screen is wrapped in ProGate, which still mounts children invisibly for non-Pro
users, causing expensive effects and state setup to execute unnecessarily. Move
the Stack.Screen component with the header outside of ProGate so it always
renders, then wrap only the heavy subtree (the KeyboardAvoidingView containing
ScrollView, messages rendering, Composer, and the arrow button) inside ProGate
to prevent expensive state initialization and effect execution for non-Pro users
who don't have access.

);
}

Expand Down
7 changes: 6 additions & 1 deletion apps/expo/app/(app)/catalog/[id].tsx
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>
);
}
7 changes: 6 additions & 1 deletion apps/expo/app/(app)/catalog/add-to-pack/details.tsx
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>
);
}
7 changes: 6 additions & 1 deletion apps/expo/app/(app)/catalog/add-to-pack/index.tsx
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>
);
}
7 changes: 6 additions & 1 deletion apps/expo/app/(app)/feed/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/app/`(app)/feed/[id].tsx around lines 40 - 44, The ProGate
component only gates the rendering of PostDetailScreen but does not prevent the
post data fetching that occurs earlier in the component. Move the post query
logic that happens before the return statement inside the ProGate component so
that non-Pro users do not trigger unnecessary API calls. This ensures the gate
controls both data fetching and rendering, not just the render output.

}
Loading
Loading