From 7692807e20649d8e27d75e6a1d887070125ab6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qu=E1=BB=91c=20Kh=C3=A1nh?= Date: Sat, 7 Sep 2024 16:34:26 +0700 Subject: [PATCH] feat(mobile): guard features base on user entitlement --- apps/api/v1/constants/wallet.const.ts | 21 ++++---- apps/mobile/app/(app)/(tabs)/_layout.tsx | 17 ++----- apps/mobile/app/(app)/(tabs)/budgets.tsx | 26 ++++++++++ apps/mobile/app/(app)/_layout.tsx | 18 +------ .../app/(app)/category/[categoryId].tsx | 5 +- apps/mobile/app/(app)/category/index.tsx | 25 ++++++++-- .../app/(app)/transaction/new-record.tsx | 4 ++ apps/mobile/app/(app)/wallet/accounts.tsx | 32 +++++++++++- .../mobile/components/transaction/scanner.tsx | 49 +++++++++++++++---- .../transaction/select-budget-field.tsx | 2 +- .../transaction/transaction-form.tsx | 10 +++- apps/mobile/hooks/use-purchases.ts | 6 +++ apps/mobile/lib/constaints.ts | 19 +++++++ 13 files changed, 177 insertions(+), 57 deletions(-) create mode 100644 apps/mobile/lib/constaints.ts diff --git a/apps/api/v1/constants/wallet.const.ts b/apps/api/v1/constants/wallet.const.ts index 97daf242..4a205413 100644 --- a/apps/api/v1/constants/wallet.const.ts +++ b/apps/api/v1/constants/wallet.const.ts @@ -14,14 +14,15 @@ export const DEFAULT_WALLETS = [ vi: 'Thẻ tín dụng', icon: 'CreditCard', }, - { - en: 'Investment', - vi: 'Đầu tư', - icon: 'ChartLine', - }, - { - en: 'Other Wallet', - vi: 'Ví khác', - icon: 'Banknote', - }, + // Limit to 3 wallets for free users + // { + // en: 'Investment', + // vi: 'Đầu tư', + // icon: 'ChartLine', + // }, + // { + // en: 'Other Wallet', + // vi: 'Ví khác', + // icon: 'Banknote', + // }, ] diff --git a/apps/mobile/app/(app)/(tabs)/_layout.tsx b/apps/mobile/app/(app)/(tabs)/_layout.tsx index 3a2160e5..65ad7c71 100644 --- a/apps/mobile/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/app/(app)/(tabs)/_layout.tsx @@ -1,13 +1,10 @@ import { TabBar } from '@/components/common/tab-bar' -import { Button } from '@/components/ui/button' -import { Text } from '@/components/ui/text' import { useColorScheme } from '@/hooks/useColorScheme' import { theme } from '@/lib/theme' import { useUser } from '@clerk/clerk-expo' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' -import { Link, Tabs } from 'expo-router' -import { PlusIcon } from 'lucide-react-native' +import { Tabs } from 'expo-router' import { usePostHog } from 'posthog-react-native' import { useEffect } from 'react' import { useWindowDimensions } from 'react-native' @@ -32,6 +29,10 @@ export default function TabLayout() { name: user?.fullName, }) Purchases.logIn(user.id) + Purchases.setAttributes({ + email: user?.emailAddresses?.[0]?.emailAddress, + displayName: user?.fullName, + }) }, [user, posthog]) return ( @@ -78,14 +79,6 @@ export default function TabLayout() { headerTitleStyle: { marginLeft: 5, }, - headerRight: () => ( - - - - ), headerTitleAlign: 'left', }} /> diff --git a/apps/mobile/app/(app)/(tabs)/budgets.tsx b/apps/mobile/app/(app)/(tabs)/budgets.tsx index 94bcf03b..da4e959d 100644 --- a/apps/mobile/app/(app)/(tabs)/budgets.tsx +++ b/apps/mobile/app/(app)/(tabs)/budgets.tsx @@ -1,10 +1,13 @@ import { BudgetItem } from '@/components/budget/budget-item' import { BudgetStatistic } from '@/components/budget/budget-statistic' import { BurndownChart } from '@/components/budget/burndown-chart' +import { Button } from '@/components/ui/button' // import { Toolbar } from '@/components/common/toolbar' import { Skeleton } from '@/components/ui/skeleton' import { Text } from '@/components/ui/text' +import { useUserEntitlements } from '@/hooks/use-purchases' import { useColorScheme } from '@/hooks/useColorScheme' +import { ENTILEMENT_LIMIT } from '@/lib/constaints' import { theme } from '@/lib/theme' import { useBudgetList } from '@/stores/budget/hooks' import { useTransactionList } from '@/stores/transaction/hooks' @@ -13,7 +16,10 @@ import type { Budget, BudgetPeriodConfig } from '@6pm/validation' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { LinearGradient } from 'expo-linear-gradient' +import { Link, useNavigation } from 'expo-router' import { groupBy, map } from 'lodash-es' +import { PlusIcon } from 'lucide-react-native' +import { useEffect } from 'react' import { Dimensions, SectionList, View } from 'react-native' import Animated, { Extrapolation, @@ -45,6 +51,7 @@ export default function BudgetsScreen() { const headerAnimation = useSharedValue(0) const scrollY = useSharedValue(0) const headerHeight = useSharedValue(height) + const navigation = useNavigation() const dummyHeaderStyle = useAnimatedStyle(() => { return { @@ -137,6 +144,25 @@ export default function BudgetsScreen() { refetch, } = useBudgetList() + const { entilement } = useUserEntitlements() + + const isExceeded = + ENTILEMENT_LIMIT[entilement]?.wallets <= (spendingBudgets?.length ?? 0) + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + + + ), + }) + }, [isExceeded]) + const { transactions } = useTransactionList({ from: dayjsExtended().startOf('month').toDate(), to: dayjsExtended().endOf('month').toDate(), diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index fc3c5afe..a0b028b9 100644 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -1,6 +1,5 @@ import { AuthLocal } from '@/components/auth/auth-local' import { BackButton } from '@/components/common/back-button' -import { Button } from '@/components/ui/button' import { useLocalAuth } from '@/hooks/use-local-auth' import { useScheduleNotificationTrigger } from '@/hooks/use-schedule-notification' import { useUserMetadata } from '@/hooks/use-user-metadata' @@ -9,8 +8,7 @@ import { theme } from '@/lib/theme' import { useUser } from '@clerk/clerk-expo' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' -import { Link, Redirect, SplashScreen, Stack } from 'expo-router' -import { PlusIcon } from 'lucide-react-native' +import { Redirect, SplashScreen, Stack } from 'expo-router' import { useEffect } from 'react' import { View } from 'react-native' @@ -128,13 +126,6 @@ export default function AuthenticatedLayout() { name="wallet/accounts" options={{ headerTitle: t(i18n)`Wallet accounts`, - headerRight: () => ( - - - - ), }} /> ( - - - - ), }} /> Alert.alert( t( @@ -59,7 +62,7 @@ export default function EditCategoryScreen() { ), }) - }, []) + }, [isPro]) if (!category) { return ( diff --git a/apps/mobile/app/(app)/category/index.tsx b/apps/mobile/app/(app)/category/index.tsx index b29ecf1f..d165c005 100644 --- a/apps/mobile/app/(app)/category/index.tsx +++ b/apps/mobile/app/(app)/category/index.tsx @@ -1,11 +1,15 @@ import { CategoryItem } from '@/components/category/category-item' import { AddNewButton } from '@/components/common/add-new-button' +import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Text } from '@/components/ui/text' +import { useUserEntitlements } from '@/hooks/use-purchases' import { useCategoryList } from '@/stores/category/hooks' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' -import { useRouter } from 'expo-router' +import { Link, useNavigation, useRouter } from 'expo-router' +import { PlusIcon } from 'lucide-react-native' +import { useEffect } from 'react' import { SectionList } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -15,6 +19,21 @@ export default function CategoriesScreen() { const { incomeCategories, expenseCategories, isRefetching, refetch } = useCategoryList() const { bottom } = useSafeAreaInsets() + const { isPro } = useUserEntitlements() + const navigation = useNavigation() + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + + + ), + }) + }, [isPro]) const sections = [ { key: 'INCOME', title: t(i18n)`Incomes`, data: incomeCategories }, @@ -25,7 +44,7 @@ export default function CategoriesScreen() { item.id} @@ -51,7 +70,7 @@ export default function CategoriesScreen() { label={t(i18n)`New ${section.key.toLowerCase()}`} onPress={() => router.push({ - pathname: '/category/new-category', + pathname: isPro ? '/category/new-category' : '/paywall', params: { type: section.key }, }) } diff --git a/apps/mobile/app/(app)/transaction/new-record.tsx b/apps/mobile/app/(app)/transaction/new-record.tsx index 0ef593ee..b42cd202 100644 --- a/apps/mobile/app/(app)/transaction/new-record.tsx +++ b/apps/mobile/app/(app)/transaction/new-record.tsx @@ -174,9 +174,13 @@ export default function NewRecordScreen() { { + router.back() toast.success(t(i18n)`Transaction added to processing queue`) }} shouldRender={page === 1} + onLimitExceeded={() => { + router.push('/paywall') + }} /> diff --git a/apps/mobile/app/(app)/wallet/accounts.tsx b/apps/mobile/app/(app)/wallet/accounts.tsx index 502030b4..4a50dc6f 100644 --- a/apps/mobile/app/(app)/wallet/accounts.tsx +++ b/apps/mobile/app/(app)/wallet/accounts.tsx @@ -1,17 +1,42 @@ import { AddNewButton } from '@/components/common/add-new-button' +import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Text } from '@/components/ui/text' import { WalletAccountItem } from '@/components/wallet/wallet-account-item' +import { useUserEntitlements } from '@/hooks/use-purchases' +import { ENTILEMENT_LIMIT } from '@/lib/constaints' import { useWallets } from '@/queries/wallet' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' -import { useRouter } from 'expo-router' +import { Link } from 'expo-router' +import { useNavigation, useRouter } from 'expo-router' +import { PlusIcon } from 'lucide-react-native' +import { useEffect } from 'react' import { FlatList } from 'react-native' export default function WalletAccountsScreen() { const { i18n } = useLingui() const { data: walletAccounts, isLoading, refetch } = useWallets() const router = useRouter() + const navigation = useNavigation() + const { entilement } = useUserEntitlements() + + const isExceeded = + ENTILEMENT_LIMIT[entilement]?.wallets <= (walletAccounts?.length ?? 0) + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + + + ), + }) + }, [isExceeded]) + return ( item.id} refreshing={isLoading} onRefresh={refetch} + extraData={[isExceeded]} ListFooterComponent={ router.push('/wallet/new-account')} + onPress={() => + router.push(isExceeded ? '/paywall' : '/wallet/new-account') + } /> } ListEmptyComponent={ diff --git a/apps/mobile/components/transaction/scanner.tsx b/apps/mobile/components/transaction/scanner.tsx index cb460101..abc2628d 100644 --- a/apps/mobile/components/transaction/scanner.tsx +++ b/apps/mobile/components/transaction/scanner.tsx @@ -3,8 +3,12 @@ import { AnimatedRing } from '@/components/scanner/animated-ring' import { ScanningOverlay } from '@/components/scanner/scanning-overlay' import { Button } from '@/components/ui/button' import { Text } from '@/components/ui/text' +import { useUserEntitlements } from '@/hooks/use-purchases' +import { ENTILEMENT_LIMIT } from '@/lib/constaints' +import { cn } from '@/lib/utils' import { getAITransactionData } from '@/mutations/transaction' import { useTransactionStore } from '@/stores/transaction/store' +import { dayjsExtended } from '@6pm/utilities' import type { UpdateTransaction } from '@6pm/validation' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' @@ -14,7 +18,6 @@ import { type CameraType, CameraView, useCameraPermissions } from 'expo-camera' import * as Haptics from 'expo-haptics' import { SaveFormat, manipulateAsync } from 'expo-image-manipulator' import * as ImagePicker from 'expo-image-picker' -import { useRouter } from 'expo-router' import { CameraIcon, ChevronsRightIcon, @@ -38,12 +41,14 @@ type ScannerProps = { onScanStart?: () => void onScanResult?: (result: UpdateTransaction) => void shouldRender?: boolean + onLimitExceeded?: () => void } export function Scanner({ onScanStart, onScanResult, shouldRender, + onLimitExceeded, }: ScannerProps) { const camera = useRef(null) const [facing, setFacing] = useState('back') @@ -51,15 +56,17 @@ export function Scanner({ const [imageUri, setImageUri] = useState(null) const { i18n } = useLingui() const { bottom } = useSafeAreaInsets() - const { addDraftTransaction, updateDraftTransaction } = useTransactionStore() - const router = useRouter() + const { addDraftTransaction, updateDraftTransaction, transactions } = + useTransactionStore() + const { entilement } = useUserEntitlements() + + const todayTransactions = transactions.filter((t) => + dayjsExtended(t.createdAt).isSame(dayjsExtended(), 'day'), + ) const { mutateAsync } = useMutation({ mutationKey: ['ai-transaction'], mutationFn: getAITransactionData, - onMutate() { - onScanStart?.() - }, onError(error) { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) toast.error(error.message ?? t(i18n)`Cannot extract transaction`) @@ -83,8 +90,17 @@ export function Scanner({ setFacing(facing === 'back' ? 'front' : 'back') } + const transactionQuota = + ENTILEMENT_LIMIT[entilement]?.['ai-transactions'] - todayTransactions.length + async function processImages(uris: string[]) { - router.back() + if (transactionQuota - uris.length <= 0) { + onLimitExceeded?.() + return + } + + onScanStart?.() + await Promise.all( uris.map(async (uri) => { const id = createId() @@ -202,10 +218,23 @@ export function Scanner({ facing={facing} style={{ paddingBottom: bottom }} > - - {t(i18n)`Take a picture of your transaction`} + + {transactionQuota <= 0 ? ( + {t( + i18n, + )`You have reached the daily AI transaction limit`} + ) : ( + {t(i18n)`Take a picture of your transaction`} + )} - +