diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index adc2a922..4445be92 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -17,6 +17,7 @@ import { StyleSheet } from 'react-native-unistyles'; import { config } from '$domain/constants'; import { AuthContextProvider } from '$domain/contexts'; +import { SubscriptionContextProvider } from '$domain/contexts/subscriptionContext'; import { useAppFocusManager } from '$infra/api'; import { persistOptions, queryClient } from '$infra/api/queryClient'; import { ErrorMonitoring } from '$infra/monitoring'; @@ -81,29 +82,35 @@ const RootLayout = () => { - <> - - - - + + <> + + + + + + + + + + + + + + - - - - - - - - - - - + - + - - + + + diff --git a/src/domain/contexts/authContext/AuthContextProvider.tsx b/src/domain/contexts/authContext/AuthContextProvider.tsx index 7cbb318d..535beeba 100644 --- a/src/domain/contexts/authContext/AuthContextProvider.tsx +++ b/src/domain/contexts/authContext/AuthContextProvider.tsx @@ -40,7 +40,7 @@ export const AuthContextProvider = ({ children }: AuthContextProviderProps) => { ErrorMonitoring.setUser(user); - await Purchase.setUser(user.id); + await Purchase.setUser(user); Notifications.setUser(user.id); Notifications.setUserEmail(user.email); diff --git a/src/domain/contexts/subscriptionContext/SubscriptionContext.ts b/src/domain/contexts/subscriptionContext/SubscriptionContext.ts new file mode 100644 index 00000000..10ef71fd --- /dev/null +++ b/src/domain/contexts/subscriptionContext/SubscriptionContext.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react'; +import { PurchasesOffering } from 'react-native-purchases'; + +const SubscriptionContext = createContext<{ + isPayingUser: boolean | null; + offeringToDisplay: PurchasesOffering | null; +}>({ + isPayingUser: null, + offeringToDisplay: null, +}); + +export default SubscriptionContext; diff --git a/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx b/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx new file mode 100644 index 00000000..3d15340e --- /dev/null +++ b/src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from 'react'; +import { PurchasesOffering } from 'react-native-purchases'; + +import { hasActiveEntitlements } from '$domain/subscription'; +import { OfferingFlagType, useGetRemoteConfigSync } from '$infra/featureFlags'; +import { Logger } from '$infra/logger'; +import { Purchase } from '$infra/purchase'; +import { useRunOnMount } from '$shared/hooks'; + +import SubscriptionContext from './SubscriptionContext'; + +interface SubscriptionContextProviderProps { + children: React.ReactNode; +} + +export const SubscriptionContextProvider = ({ + children, +}: SubscriptionContextProviderProps) => { + const [isPayingUser, setIsPayingUser] = useState(null); + const [offeringToDisplay, setOfferingToDisplay] = + useState(null); + + const { getFlagPayloadSync } = useGetRemoteConfigSync(); + + useRunOnMount(() => { + const fetchIsPayingUser = async () => { + try { + const isPayingUser = await Purchase.isPayingUser(); + + setIsPayingUser(isPayingUser); + } catch (error) { + Logger.error({ + error, + level: 'warning', + message: 'Failed to fetch user subscription status', + }); + + setIsPayingUser(false); + } + }; + + void fetchIsPayingUser(); + }); + + useRunOnMount(() => { + return Purchase.customerListener((customerInfo) => { + setIsPayingUser(hasActiveEntitlements(customerInfo)); + }); + }); + + useRunOnMount(() => { + const fetchOfferingToDisplay = async () => { + try { + const offering = await Purchase.getOfferings(); + const payload = getFlagPayloadSync( + 'offering-to-display', + ); + + if (payload?.type === 'offering' && payload.offering) { + const remotelySelectedOffering = offering.all[payload.offering]; + + if (remotelySelectedOffering) { + setOfferingToDisplay(remotelySelectedOffering); + } else { + setOfferingToDisplay(offering.current); + } + } else { + setOfferingToDisplay(offering.current); + } + } catch (error) { + Logger.error({ + error, + level: 'warning', + message: 'Failed to fetch offering to display', + }); + + setOfferingToDisplay(null); + } + }; + + void fetchOfferingToDisplay(); + }); + + const value = useMemo( + () => ({ + isPayingUser, + offeringToDisplay, + }), + [isPayingUser, offeringToDisplay], + ); + + return ( + + {children} + + ); +}; diff --git a/src/domain/contexts/subscriptionContext/index.ts b/src/domain/contexts/subscriptionContext/index.ts new file mode 100644 index 00000000..0951b8de --- /dev/null +++ b/src/domain/contexts/subscriptionContext/index.ts @@ -0,0 +1,2 @@ +export * from './SubscriptionContextProvider'; +export * from './useSubscriptionContext'; diff --git a/src/domain/contexts/subscriptionContext/useSubscriptionContext.ts b/src/domain/contexts/subscriptionContext/useSubscriptionContext.ts new file mode 100644 index 00000000..98733c09 --- /dev/null +++ b/src/domain/contexts/subscriptionContext/useSubscriptionContext.ts @@ -0,0 +1,9 @@ +import { useContext } from 'react'; + +import SubscriptionContext from './SubscriptionContext'; + +export const useSubscriptionContext = () => { + const value = useContext(SubscriptionContext); + + return value; +}; diff --git a/src/domain/subscription/index.ts b/src/domain/subscription/index.ts new file mode 100644 index 00000000..04bca77e --- /dev/null +++ b/src/domain/subscription/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/src/domain/subscription/utils/hasActiveEntitlements.ts b/src/domain/subscription/utils/hasActiveEntitlements.ts new file mode 100644 index 00000000..87a96762 --- /dev/null +++ b/src/domain/subscription/utils/hasActiveEntitlements.ts @@ -0,0 +1,5 @@ +import { CustomerInfo } from 'react-native-purchases'; + +export const hasActiveEntitlements = (customerInfo: CustomerInfo): boolean => { + return Object.entries(customerInfo.entitlements.active).length > 0; +}; diff --git a/src/domain/subscription/utils/index.ts b/src/domain/subscription/utils/index.ts new file mode 100644 index 00000000..dd850d65 --- /dev/null +++ b/src/domain/subscription/utils/index.ts @@ -0,0 +1 @@ +export * from './hasActiveEntitlements'; diff --git a/src/infra/analytics/analytics.ts b/src/infra/analytics/analytics.ts index 272f46cf..1f73809c 100644 --- a/src/infra/analytics/analytics.ts +++ b/src/infra/analytics/analytics.ts @@ -41,16 +41,6 @@ class AnalyticsClass { }); } - /* ***** ***** Revenue ***** ***** */ - - trackRevenue(revenueData: { - productId: AnalyticsType.ProductIds; - price: number; - revenueType: AnalyticsType.RevenueTypes; - }) { - this.trackEvent('purchase', revenueData); - } - /* ***** ***** Events ***** ***** */ trackEvent( diff --git a/src/infra/analytics/analytics.types.ts b/src/infra/analytics/analytics.types.ts index b13fa32e..d87954a6 100644 --- a/src/infra/analytics/analytics.types.ts +++ b/src/infra/analytics/analytics.types.ts @@ -24,16 +24,6 @@ export namespace AnalyticsType { | 'deep-link-opened' | 'purchase'; - export type ProductIds = - | 'monthly-subscription' - | 'monthly-subscription-30-off' - | 'monthly-subscription-50-off' - | 'yearly-subscription' - | 'yearly-subscription-30-off' - | 'yearly-subscription-50-off'; - - export type RevenueTypes = 'purchase'; - export type JsonType = | string | number diff --git a/src/infra/featureFlags/defaultFlags.ts b/src/infra/featureFlags/defaultFlags.ts index 645f188c..13d26656 100644 --- a/src/infra/featureFlags/defaultFlags.ts +++ b/src/infra/featureFlags/defaultFlags.ts @@ -1,6 +1,7 @@ import type { AvailableFeatureFlags, AvailableRemoteConfig, + OfferingFlagType, VersionFlagType, } from './featureFlags.types'; @@ -13,8 +14,9 @@ export const defaultFeatureFlags: Record< export const defaultRemoteConfig: Record< AvailableRemoteConfig, - VersionFlagType + VersionFlagType | OfferingFlagType > = { 'last-supported-app-version': { type: 'version', version: '2.0.0' }, 'latest-released-app-version': { type: 'version', version: '2.1.0' }, + 'offering-to-display': { type: 'offering', offering: 'default' }, }; diff --git a/src/infra/featureFlags/featureFlags.types.ts b/src/infra/featureFlags/featureFlags.types.ts index 26c8b3e6..b4080bec 100644 --- a/src/infra/featureFlags/featureFlags.types.ts +++ b/src/infra/featureFlags/featureFlags.types.ts @@ -2,13 +2,19 @@ export type AvailableFeatureFlags = 'is-maintenance-mode'; export type AvailableRemoteConfig = | 'last-supported-app-version' - | 'latest-released-app-version'; + | 'latest-released-app-version' + | 'offering-to-display'; export type BooleanFeatureFlags = Extract< AvailableFeatureFlags, 'is-maintenance-mode' >; +export interface OfferingFlagType { + type: 'offering'; + offering: string; +} + export interface VersionFlagType { type: 'version'; version: string; diff --git a/src/infra/purchase/purchase.ts b/src/infra/purchase/purchase.ts index aea63e16..c5c6e5e6 100644 --- a/src/infra/purchase/purchase.ts +++ b/src/infra/purchase/purchase.ts @@ -3,9 +3,13 @@ import type { LOG_LEVEL, PurchasesPackage, } from 'react-native-purchases'; -import RevenueCat from 'react-native-purchases'; +import RevenueCat, { + LOG_LEVEL as PURCHASES_LOG_LEVEL, +} from 'react-native-purchases'; import { IS_IOS, config } from '$domain/constants'; +import { User } from '$domain/entities'; +import { hasActiveEntitlements } from '$domain/subscription'; import { ErrorMonitoring } from '$infra/monitoring'; const API_KEY = IS_IOS @@ -17,6 +21,8 @@ class PurchaseClass { init() { RevenueCat.configure({ apiKey: API_KEY }); + + void this.setLogLevel(PURCHASES_LOG_LEVEL.ERROR); } async setLogLevel(logLevel: LOG_LEVEL) { @@ -25,8 +31,12 @@ class PurchaseClass { /* ***** ***** User related ***** ***** */ - async setUser(appUserID: string) { - await RevenueCat.logIn(appUserID); + async setUser(user: User) { + await RevenueCat.logIn(user.id); + await RevenueCat.setEmail(user.email); + await this.setAttributes({ + $posthogUserId: user.id, + }); } async clearUser() { @@ -45,12 +55,16 @@ class PurchaseClass { return await RevenueCat.getCustomerInfo(); } + async isPayingUser() { + const customerInfo = await this.getUserInformations(); + + return hasActiveEntitlements(customerInfo); + } + /* ***** ***** RevenueCat ***** ***** */ async getOfferings() { - const offerings = await RevenueCat.getOfferings(); - - return offerings.current; + return await RevenueCat.getOfferings(); } async restorePurchases() { @@ -69,6 +83,10 @@ class PurchaseClass { customerListener(callback: (customerInfo: CustomerInfo) => void) { RevenueCat.addCustomerInfoUpdateListener(callback); + + return () => { + RevenueCat.removeCustomerInfoUpdateListener(callback); + }; } }