From 5d2489cfb2a3e3e3e27909f33be7550319580290 Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Wed, 15 Jul 2026 22:50:14 +0530 Subject: [PATCH 1/2] Changes : Added Pro paywall and AI usage gates - Added RevenueCat subscription provider, paywall screens, plan cards, and feature access hooks - Gated AI chat, entry reflections, and insight reports behind free/Pro monthly limits - Added Supabase subscription status and AI usage ledger migration plus Edge Function enforcement - Added RevenueCat environment keys, billing permission, app version bump, and setup docs - Added Maestro smoke flows, test IDs, and E2E selector documentation - Improved App Lock controls and fixed preview logout crash during auth navigation --- .env.example | 3 + .env.preview.example | 4 + .env.production.example | 4 + .gitignore | 1 + .maestro/00-launch.yaml | 28 ++ .maestro/00-smoke-launch.yaml | 36 ++ app.json | 7 +- app/(onboarding)/onboarding-screen-1.tsx | 6 + app/(onboarding)/onboarding-screen-2.tsx | 10 +- app/(onboarding)/onboarding-screen-3.tsx | 2 + app/(onboarding)/onboarding-screen-4.tsx | 2 + app/(onboarding)/onboarding-screen-5.tsx | 2 + app/(tabs)/_layout.tsx | 12 +- app/_layout.tsx | 6 +- app/insights/report/[periodType].tsx | 97 +++- app/paywall.tsx | 16 + app/settings/app-lock/setup.tsx | 16 +- app/settings/privacy.tsx | 6 + app/sso.tsx | 5 +- assets/images/landing-page.png | Bin 0 -> 132277 bytes components/ai-chat/ai-chat-screen.tsx | 146 +++++- components/app-lock/AppLockScreen.tsx | 9 + .../app-lock/AppLockSettingsComponents.tsx | 4 + components/app-lock/BiometricLockSwitch.tsx | 181 +++++++ components/app-lock/PinInput.tsx | 50 +- components/auth/auth-screen.tsx | 56 ++- components/auth/auth-text-field.tsx | 12 + components/home/home-screen.tsx | 28 +- components/home/mood/HomeMoodCheckInCard.tsx | 8 + components/home/mood/MoodCheckInAction.tsx | 4 + components/home/mood/MoodOption.tsx | 3 + components/home/mood/MoodSpectrumSelector.tsx | 10 + components/insights/insights-screen.tsx | 130 ++++- .../insights/report/ReportScreenStates.tsx | 15 + .../entry-ai-reflection-card.tsx | 62 ++- .../journal-editor/journal-editor-screen.tsx | 82 +++- .../journal-history/journal-calendar-view.tsx | 5 + .../journal-history-screen.tsx | 12 +- components/navigation/bottom-tab-bar.tsx | 2 + .../onboarding/onboarding-progress-header.tsx | 3 + components/paywall/PaywallScreen.tsx | 449 ++++++++++++++++++ components/paywall/PlanCard.tsx | 64 +++ components/paywall/PremiumFeatureList.tsx | 28 ++ .../profile/notification-settings-screen.tsx | 18 +- components/profile/profile-screen.tsx | 114 ++++- components/states/ScreenEmptyState.tsx | 9 + components/tags/tag-input-modal.tsx | 15 + components/ui/animated-icon-button.tsx | 6 + constants/app-lock-theme.ts | 1 + content/legal/privacyPolicy.ts | 9 + content/legal/termsAndConditions.ts | 10 + data/profile.ts | 8 + docs/e2e-testid-audit.md | 31 ++ docs/e2e-testid-map.md | 84 ++++ docs/environment-matrix.md | 5 + docs/privacy.md | 3 + docs/subscription-feature-gating-audit.md | 40 ++ docs/subscription-paywall-test.md | 56 +++ docs/subscription-setup.md | 64 +++ docs/subscription-store-setup.md | 82 ++++ hooks/useAIInsightReport.ts | 30 +- hooks/useEntryReflection.ts | 19 +- hooks/useFeatureAccess.ts | 27 ++ hooks/useSubscription.ts | 41 ++ lib/ai/dailyReflectionPromptService.ts | 68 ++- lib/ai/entryReflectionService.ts | 6 +- lib/ai/remoteJournalAssistant.ts | 37 +- lib/environment.ts | 11 + lib/insights/aiInsightReportService.ts | 12 +- lib/subscription/constants.ts | 79 +++ lib/subscription/featureGates.ts | 46 ++ lib/subscription/revenueCat.ts | 91 ++++ package-lock.json | 257 ++++++++++ package.json | 2 + providers/SubscriptionProvider.tsx | 234 +++++++++ store/useAIUsageStore.ts | 101 ++++ supabase/config.toml | 2 +- .../functions/_shared/subscriptionAccess.ts | 320 +++++++++++++ .../generate-insight-report/index.ts | 12 + supabase/functions/journal-ai-chat/index.ts | 22 + supabase/functions/reflect-on-entry/index.ts | 12 + ...080005_add_ai_usage_subscription_gates.sql | 233 +++++++++ tests/environment.test.ts | 2 + 83 files changed, 3735 insertions(+), 110 deletions(-) create mode 100644 .maestro/00-launch.yaml create mode 100644 .maestro/00-smoke-launch.yaml create mode 100644 app/paywall.tsx create mode 100644 assets/images/landing-page.png create mode 100644 components/app-lock/BiometricLockSwitch.tsx create mode 100644 components/paywall/PaywallScreen.tsx create mode 100644 components/paywall/PlanCard.tsx create mode 100644 components/paywall/PremiumFeatureList.tsx create mode 100644 docs/e2e-testid-audit.md create mode 100644 docs/e2e-testid-map.md create mode 100644 docs/subscription-feature-gating-audit.md create mode 100644 docs/subscription-paywall-test.md create mode 100644 docs/subscription-setup.md create mode 100644 docs/subscription-store-setup.md create mode 100644 hooks/useFeatureAccess.ts create mode 100644 hooks/useSubscription.ts create mode 100644 lib/subscription/constants.ts create mode 100644 lib/subscription/featureGates.ts create mode 100644 lib/subscription/revenueCat.ts create mode 100644 providers/SubscriptionProvider.tsx create mode 100644 store/useAIUsageStore.ts create mode 100644 supabase/functions/_shared/subscriptionAccess.ts create mode 100644 supabase/migrations/20260712080005_add_ai_usage_subscription_gates.sql diff --git a/.env.example b/.env.example index c006d5c..2ca31e6 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,10 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY= EXPO_PUBLIC_SUPABASE_URL= EXPO_PUBLIC_SUPABASE_ANON_KEY= EXPO_PUBLIC_ACCOUNT_DELETION_URL= +EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY= +EXPO_PUBLIC_REVENUECAT_IOS_API_KEY= # Server-side only. Set these in Supabase Edge Function secrets, never in Expo public env. SUPABASE_SERVICE_ROLE_KEY= CLERK_SECRET_KEY= +REVENUECAT_SECRET_API_KEY= diff --git a/.env.preview.example b/.env.preview.example index f755f4b..f46e867 100644 --- a/.env.preview.example +++ b/.env.preview.example @@ -4,6 +4,10 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY= EXPO_PUBLIC_SUPABASE_URL= EXPO_PUBLIC_SUPABASE_ANON_KEY= EXPO_PUBLIC_ACCOUNT_DELETION_URL= +EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY= +EXPO_PUBLIC_REVENUECAT_IOS_API_KEY= # Keep server-only credentials in Supabase Edge Function secrets. # Do not add them to this file or an EAS mobile build environment. +# Required by AI Edge Functions for server-side subscription validation: +# REVENUECAT_SECRET_API_KEY diff --git a/.env.production.example b/.env.production.example index a43c147..d2d5be0 100644 --- a/.env.production.example +++ b/.env.production.example @@ -4,6 +4,10 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY= EXPO_PUBLIC_SUPABASE_URL= EXPO_PUBLIC_SUPABASE_ANON_KEY= EXPO_PUBLIC_ACCOUNT_DELETION_URL= +EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY= +EXPO_PUBLIC_REVENUECAT_IOS_API_KEY= # Production server credentials are intentionally pending. # Keep them in backend secret storage, never in the mobile build environment. +# Required by AI Edge Functions for server-side subscription validation: +# REVENUECAT_SECRET_API_KEY diff --git a/.gitignore b/.gitignore index 32d1d2c..8eb225a 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ PROMPTS.md task.md DB.md AI.md +DearDiary.md diff --git a/.maestro/00-launch.yaml b/.maestro/00-launch.yaml new file mode 100644 index 0000000..72aa45e --- /dev/null +++ b/.maestro/00-launch.yaml @@ -0,0 +1,28 @@ +appId: com.aryan.deardiary +--- +- launchApp: + clearState: false + +- assertVisible: + text: "DearDiary" + optional: true + +- assertVisible: + text: "Today" + optional: true + +- assertVisible: + text: "Reflect" + optional: true + +- assertVisible: + text: "History" + optional: true + +- assertVisible: + text: "Insights" + optional: true + +- assertVisible: + text: "Profile" + optional: true diff --git a/.maestro/00-smoke-launch.yaml b/.maestro/00-smoke-launch.yaml new file mode 100644 index 0000000..59631ac --- /dev/null +++ b/.maestro/00-smoke-launch.yaml @@ -0,0 +1,36 @@ +appId: com.aryan.deardiary +--- +- launchApp: + clearState: false + +- assertVisible: + id: "login-screen" + optional: true + +- assertVisible: + id: "signup-screen" + optional: true + +- assertVisible: + id: "home-screen" + optional: true + +- assertVisible: + id: "app-lock-screen" + optional: true + +- tapOn: + id: "tab-profile-button" + optional: true + +- assertVisible: + id: "profile-screen" + optional: true + +- tapOn: + id: "tab-today-button" + optional: true + +- assertVisible: + id: "home-screen" + optional: true diff --git a/app.json b/app.json index d2ac7b5..0fccd7c 100644 --- a/app.json +++ b/app.json @@ -2,14 +2,14 @@ "expo": { "name": "DearDiary", "slug": "dear-diary", - "version": "1.0.4", + "version": "1.0.6", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "deardiary", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "buildNumber": "4", + "buildNumber": "6", "supportsTablet": true }, "android": { @@ -21,7 +21,8 @@ ], "icon": "./assets/images/icon.png", "package": "com.aryan.deardiary", - "versionCode": 4, + "permissions": ["com.android.vending.BILLING"], + "versionCode": 6, "adaptiveIcon": { "backgroundColor": "#FFDDE8", "foregroundImage": "./assets/images/icon.png" diff --git a/app/(onboarding)/onboarding-screen-1.tsx b/app/(onboarding)/onboarding-screen-1.tsx index dbc60bf..721d454 100644 --- a/app/(onboarding)/onboarding-screen-1.tsx +++ b/app/(onboarding)/onboarding-screen-1.tsx @@ -308,6 +308,9 @@ export default function OnboardingScreenOne() { > diff --git a/app/(onboarding)/onboarding-screen-2.tsx b/app/(onboarding)/onboarding-screen-2.tsx index c7f4b3e..d5c4662 100644 --- a/app/(onboarding)/onboarding-screen-2.tsx +++ b/app/(onboarding)/onboarding-screen-2.tsx @@ -372,6 +372,9 @@ export default function OnboardingScreenTwo() { > - + { + if (isLoaded && !isSignedIn) { + router.replace("/login"); + } + }, [isLoaded, isSignedIn, router]); + if (!isLoaded) { return null; } if (!isSignedIn) { - return ; + return null; } return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index d3bbda8..4bc22e0 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -18,6 +18,7 @@ import { useNativeTransitionOptions } from "@/navigation/transitions"; import { AppLockProvider } from "@/providers/AppLockProvider"; import { AppDialogProvider } from "@/providers/AppDialogProvider"; import { ConnectivityProvider } from "@/providers/ConnectivityProvider"; +import { SubscriptionProvider } from "@/providers/SubscriptionProvider"; export default function RootLayout() { if (!publicEnvironmentResult.isValid) { @@ -40,7 +41,9 @@ export default function RootLayout() { > - + + + @@ -111,6 +114,7 @@ function RootNavigator() { name="legal/privacy-policy" options={standardDetailOptions} /> + diff --git a/app/insights/report/[periodType].tsx b/app/insights/report/[periodType].tsx index 690eaf7..4288198 100644 --- a/app/insights/report/[periodType].tsx +++ b/app/insights/report/[periodType].tsx @@ -1,5 +1,11 @@ import { useAuth } from "@clerk/expo"; -import { Redirect, Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { + Redirect, + Stack, + type Href, + useLocalSearchParams, + useRouter, +} from "expo-router"; import { ChevronLeft, RefreshCw } from "lucide-react-native"; import { useMemo } from "react"; import { @@ -41,13 +47,15 @@ import { ReportStatGrid } from "@/components/insights/report/ReportStatGrid"; import { reportColors } from "@/constants/report-theme"; import { useAppDialog } from "@/hooks/useAppDialog"; import { useAIInsightReport } from "@/hooks/useAIInsightReport"; +import { useFeatureAccess } from "@/hooks/useFeatureAccess"; import { getCurrentReportPeriod, isAIInsightPeriodType, } from "@/lib/insights/reportPeriods"; +import { useAIUsageStore } from "@/store/useAIUsageStore"; export default function AIInsightReportScreen() { - const { isLoaded, isSignedIn } = useAuth(); + const { isLoaded, isSignedIn, userId } = useAuth(); const insets = useSafeAreaInsets(); const router = useRouter(); const { showDialog } = useAppDialog(); @@ -64,15 +72,30 @@ export default function AIInsightReportScreen() { const reportState = useAIInsightReport(period, { enabled: isValidPeriodType, }); + const reportFeature = + period.type === "weekly" ? "weekly_report" : "monthly_report"; + const reportAccess = useFeatureAccess(reportFeature, userId); const bottomNavHeight = bottomTabBarBaseHeight + insets.bottom; const minimumEntries = period.type === "weekly" ? 2 : 3; const hasEnoughEntries = reportState.availableEntryCount >= minimumEntries; - function handleGenerate() { - void reportState.generate(); + async function handleGenerate() { + if (!canUseReportAccess()) { + return; + } + + const generated = await reportState.generate(); + + if (generated && userId) { + useAIUsageStore.getState().incrementMonthlyUsage(userId, reportFeature); + } } function handleRegenerate() { + if (!canUseReportAccess()) { + return; + } + showDialog({ cancelText: "Keep Current", confirmText: "Regenerate", @@ -80,13 +103,43 @@ export default function AIInsightReportScreen() { message: "DearDiary will analyze this period again and replace the current visual reflection.", onConfirm: () => { - void reportState.regenerate(); + void handleConfirmedRegenerate(); }, showCancel: true, title: "Regenerate reflection?", }); } + async function handleConfirmedRegenerate() { + const regenerated = await reportState.regenerate(); + + if (regenerated && userId) { + useAIUsageStore.getState().incrementMonthlyUsage(userId, reportFeature); + } + } + + function canUseReportAccess() { + if (reportAccess.allowed) { + return true; + } + + if (reportAccess.reason === "Pro_fair_use_exhausted") { + showDialog({ + confirmText: "OK", + message: + "You've reached this month's DearDiary Pro fair-use limit for reflection reports. Please try again next month.", + title: "Monthly report limit reached", + }); + return false; + } + + router.push({ + pathname: "/paywall", + params: { feature: reportFeature }, + } as unknown as Href); + return false; + } + if (!isLoaded) { return null; } @@ -117,6 +170,37 @@ export default function AIInsightReportScreen() { ); } + if (period.type === "monthly" && !reportAccess.allowed) { + return ( + + + + Monthly AI reports are included with DearDiary Pro. + + + Unlock monthly summaries, advanced patterns, and long-term writing + insights. + + + router.push({ + pathname: "/paywall", + params: { feature: "monthly_report" }, + } as unknown as Href) + } + /> + + + ); + } + return ( @@ -126,6 +210,7 @@ export default function AIInsightReportScreen() { onRetry={reportState.refresh} > router.back()} > @@ -150,6 +236,7 @@ export default function AIInsightReportScreen() { Reflection Report (); + const feature = getSingleRouteParam(params.feature); + + return ( + <> + + + + ); +} diff --git a/app/settings/app-lock/setup.tsx b/app/settings/app-lock/setup.tsx index e990f4e..cde6560 100644 --- a/app/settings/app-lock/setup.tsx +++ b/app/settings/app-lock/setup.tsx @@ -5,12 +5,12 @@ import { ActivityIndicator, Pressable, ScrollView, - Switch, Text, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { BiometricLockSwitch } from "@/components/app-lock/BiometricLockSwitch"; import { PinInput } from "@/components/app-lock/PinInput"; import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; import { appLockColors, appLockLayout } from "@/constants/app-lock-theme"; @@ -108,6 +108,7 @@ export default function AppLockSetupScreen() { style={{ backgroundColor: appLockColors.background }} > router.back()} shadow="0 2px 6px rgba(39, 39, 42, 0.16)" @@ -153,7 +155,9 @@ export default function AppLockSetupScreen() { Create a six-digit PIN {helperMessage ? ( @@ -218,7 +225,8 @@ export default function AppLockSetupScreen() { : "Biometrics are not available or enrolled on this device."} - {delayOptions.map((option) => ( router.push(privacyPolicyHref)} + testID="profile-privacy-policy-link" value="" /> @@ -107,6 +110,7 @@ export default function PrivacySettingsScreen() { icon="clipboard" label="Terms & Conditions" onPress={() => router.push(termsHref)} + testID="profile-terms-link" value="" /> {accountDeletionUrl ? ( @@ -116,6 +120,7 @@ export default function PrivacySettingsScreen() { icon="external-link" label="External Deletion Page" onPress={() => router.push(accountDeletionUrl as Href)} + testID="settings-external-deletion-row" value="View" /> @@ -130,6 +135,7 @@ export default function PrivacySettingsScreen() { isBusy={busyAction === "disable-lock"} label="App Lock" onPress={handleAppLockPress} + testID="settings-app-lock-row" value={isEnabled ? "On" : "Off"} /> diff --git a/app/sso.tsx b/app/sso.tsx index eeece81..82ae6d1 100644 --- a/app/sso.tsx +++ b/app/sso.tsx @@ -12,7 +12,10 @@ export default function SsoCallbackScreen() { >