This commit is contained in:
Eric Bailey
2024-11-20 21:11:49 -06:00
parent 8857758789
commit a39c51490d
27 changed files with 687 additions and 379 deletions
+27
View File
@@ -0,0 +1,27 @@
import React from 'react'
import {APIEntitlement,APISubscription} from '#/state/purchases/types'
export type NativePurchaseRestricted = 'yes' | 'no' | 'unknown'
export type PurchasesState =
| {
status: 'loading'
}
| {
status: 'error'
error: Error
}
| {
status: 'ready'
email?: string
subscriptions: APISubscription[]
entitlements: APIEntitlement[]
config: {
nativePurchaseRestricted: NativePurchaseRestricted
}
}
export const Context = React.createContext<PurchasesState>({
status: 'loading',
})
@@ -1,6 +1,6 @@
import {Linking} from 'react-native'
import {useMutation} from '@tanstack/react-query'
import Purchases from 'react-native-purchases'
import {useMutation} from '@tanstack/react-query'
export function useManageSubscription() {
return useMutation({
@@ -1,9 +1,9 @@
import {Linking} from 'react-native'
import {useMutation} from '@tanstack/react-query'
import {IS_DEV} from '#/env'
import {api} from '#/state/purchases/api'
import {useSession} from '#/state/session'
import {api} from '#/state/purchases/subscriptions/api'
import {IS_DEV} from '#/env'
export function useManageSubscription() {
const {currentAccount} = useSession()
@@ -16,8 +16,10 @@ export function useManageSubscription() {
method: 'POST',
json: {
did: currentAccount!.did,
redirecturl: IS_DEV ? 'http://localhost:19006/subscriptions' : 'https://bsky.app/subscriptions',
}
redirecturl: IS_DEV
? 'http://localhost:19006/subscriptions'
: 'https://bsky.app/subscriptions',
},
}).json()
if (error || !data) {
@@ -0,0 +1,15 @@
import React from 'react'
import Purchases, {CustomerInfo} from 'react-native-purchases'
export function useNativeEventsListener({
onCustomerInfoUpdated,
}: {
onCustomerInfoUpdated: (info: CustomerInfo) => void
}) {
React.useEffect(() => {
Purchases.addCustomerInfoUpdateListener(onCustomerInfoUpdated)
return () => {
Purchases.removeCustomerInfoUpdateListener(onCustomerInfoUpdated)
}
}, [onCustomerInfoUpdated])
}
@@ -0,0 +1 @@
export function useNativeEventsListener() {}
@@ -0,0 +1,37 @@
import React from 'react'
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
import {NativePurchaseRestricted} from '#/state/purchases/context'
import {useSession} from '#/state/session'
export function useNativeUserState() {
const {currentAccount} = useSession()
const [restricted, setRestricted] =
React.useState<NativePurchaseRestricted>('unknown')
React.useEffect(() => {
async function check() {
if (!currentAccount?.did) return
try {
// MUST ensure we're the correct user first
await Purchases.logIn(currentAccount?.did)
await Purchases.restorePurchases()
setRestricted('no')
} catch (e: any) {
/**
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
*/
if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) {
setRestricted('yes')
}
}
}
check()
}, [currentAccount?.did])
return {
restricted,
}
}
@@ -0,0 +1,5 @@
export function useNativeUserState() {
return {
restricted: false,
}
}
@@ -0,0 +1,35 @@
import {useQuery} from '@tanstack/react-query'
import {api} from '#/state/purchases/api'
import {APIEntitlement,APISubscription} from '#/state/purchases/types'
import {useSession} from '#/state/session'
export const rootPurchasesStateQueryKey = 'subscriptions-state'
export const createPurchasesStateQueryKey = (did?: string) => [
rootPurchasesStateQueryKey,
did,
]
export function usePurchasesState() {
const {currentAccount} = useSession()
return useQuery({
enabled: !!currentAccount,
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60 * 2,
queryKey: createPurchasesStateQueryKey(currentAccount?.did),
async queryFn() {
const {data, error} = await api<{
email: string
subscriptions: APISubscription[]
entitlements: APIEntitlement[]
}>(`/account?did=${currentAccount!.did}`).json()
if (error || !data) {
throw new Error('Failed to fetch subscriptions state')
}
return data
},
})
}
@@ -1,23 +1,29 @@
import React from 'react'
import {useQuery, useMutation} from '@tanstack/react-query'
import Purchases, { PURCHASES_ERROR_CODE } from 'react-native-purchases'
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
import {useMutation,useQuery} from '@tanstack/react-query'
import {isIOS} from '#/platform/detection'
import {api} from '#/state/purchases/subscriptions/api'
import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types'
import {api} from '#/state/purchases/api'
import {
parseOfferingId,
PlatformId,
SubscriptionGroupId,
SubscriptionOffering,
} from '#/state/purchases/types'
export function useSubscriptionGroup(group: SubscriptionGroupId) {
return useQuery<{ offerings: Offering[] }>({
return useQuery<{offerings: SubscriptionOffering[]}>({
queryKey: ['subscription-group', group],
async queryFn() {
const platform = isIOS ? 'ios' : 'android'
const { data, error, response } = await api(`/subscriptions/${group}?platform=${platform}`).json()
const {data, error, response} = await api(
`/subscriptions/${group}?platform=${platform}`,
).json()
if (error || !data) {
console.log(error, response)
throw new Error('Failed to fetch subscription group')
}
const { offerings } = data
const {offerings} = data
const productIds = offerings.map((o: any) => {
return isIOS ? o.productId : o.productId.split(':')[0]
})
@@ -28,16 +34,18 @@ export function useSubscriptionGroup(group: SubscriptionGroupId) {
return {
offerings: offerings.map((o: any) => {
const product = products.find((p: any) => p.identifier === o.productId)
const product = products.find(
(p: any) => p.identifier === o.productId,
)
return {
id: o.id as OfferingId,
id: parseOfferingId(o.id),
platform: isIOS ? PlatformId.Ios : PlatformId.Android,
price: product?.priceString,
package: product,
}
}),
}
}
},
})
}
@@ -47,7 +55,11 @@ export function usePurchaseOffering() {
did,
email,
offering,
}: { did: string, email: string, offering: Offering }) {
}: {
did: string
email: string
offering: SubscriptionOffering
}) {
if (offering.platform === PlatformId.Web) {
throw new Error('Unsupported platform')
}
@@ -63,6 +75,6 @@ export function usePurchaseOffering() {
throw e
}
}
},
})
}
@@ -1,31 +1,38 @@
import {Linking} from 'react-native'
import {useQuery, useMutation} from '@tanstack/react-query'
import {useMutation,useQuery} from '@tanstack/react-query'
import {api} from '#/state/purchases/api'
import {
parseOfferingId,
PlatformId,
SubscriptionGroupId,
SubscriptionOffering,
} from '#/state/purchases/types'
import {IS_DEV} from '#/env'
import {api} from '#/state/purchases/subscriptions/api'
import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types'
export function useSubscriptionGroup(group: SubscriptionGroupId) {
return useQuery<{ offerings: Offering[] }>({
return useQuery<{offerings: SubscriptionOffering[]}>({
queryKey: ['subscription-group', group],
async queryFn() {
const { data, error } = await api(`/subscriptions/${group}?platform=web`).json()
const {data, error} = await api(
`/subscriptions/${group}?platform=web`,
).json()
if (error || !data) {
throw new Error('Failed to fetch subscription group')
}
const { offerings } = data
const {offerings} = data
return {
offerings: offerings.map((o: any) => ({
id: o.id as OfferingId,
id: parseOfferingId(o.id),
platform: PlatformId.Web,
package: {
priceId: o.productId,
},
})),
}
}
},
})
}
@@ -35,7 +42,11 @@ export function usePurchaseOffering() {
did,
email,
offering,
}: { did: string, email: string, offering: Offering }) {
}: {
did: string
email: string
offering: SubscriptionOffering
}) {
if (offering.platform !== PlatformId.Web) {
throw new Error('Unsupported platform')
}
@@ -59,6 +70,6 @@ export function usePurchaseOffering() {
}
Linking.openURL(data.checkoutUrl)
}
},
})
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react'
import {Context, PurchasesState} from '#/state/purchases/context'
import {useNativeEventsListener} from '#/state/purchases/hooks/useNativeEventsListener'
import {useNativeUserState} from '#/state/purchases/hooks/useNativeUserState'
import {usePurchasesState} from '#/state/purchases/hooks/usePurchasesState'
export type {PurchasesState} from '#/state/purchases/context'
export function Provider({children}: {children: React.ReactNode}) {
const {
data: purchases,
error: purchasesStateError,
refetch,
} = usePurchasesState()
const {restricted} = useNativeUserState()
const ctx = React.useMemo<PurchasesState>(() => {
if (purchasesStateError) {
return {
status: 'error',
error: purchasesStateError,
}
} else if (!purchases) {
return {
status: 'loading',
}
} else {
return {
status: 'ready',
email: purchases?.email,
subscriptions: purchases?.subscriptions ?? [],
entitlements: purchases?.entitlements ?? [],
config: {
nativePurchaseRestricted: restricted,
},
}
}
}, [purchases, purchasesStateError, restricted])
useNativeEventsListener({
onCustomerInfoUpdated() {
refetch()
},
})
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
export function usePurchases() {
return React.useContext(Context)
}
@@ -1,45 +0,0 @@
import type { PurchasesStoreProduct } from 'react-native-purchases'
export enum EntitlementId {
Core = 'core',
}
export enum PlatformId {
Android = 'android',
Ios = 'ios',
Web = 'web',
}
export enum SubscriptionGroupId {
Core = 'core',
}
export enum OfferingId {
CoreMonthly = 'coreMonthly',
CoreAnnual = 'coreAnnual',
}
export type Offering =
| {
id: OfferingId
platform: PlatformId.Ios | PlatformId.Android
price: number
package: PurchasesStoreProduct
}
| {
id: OfferingId
platform: PlatformId.Web
price: number
package: {
priceId: string
}
}
export type Subscription = {
group: SubscriptionGroupId
platform: PlatformId
renews: boolean
periodDtartsAt: string
periodEndsAt: string
purchasedAt: string
}
@@ -1,28 +0,0 @@
import {useMutation} from '@tanstack/react-query'
import {api} from '#/state/purchases/subscriptions/api'
import {IS_DEV} from '#/env'
export function useCreateCheckout() {
return useMutation({
async mutationFn(props: {price: string; did: string; email: string}) {
const {data, error} = await api<{
checkoutUrl: string
}>('/checkout/create', {
method: 'POST',
json: {
...props,
redirectUrl: IS_DEV
? `http://localhost:19006/subscriptions`
: `https://bsky.app/subscriptions`,
},
}).json()
if (error) {
throw error
}
return data
},
})
}
@@ -1,28 +0,0 @@
import {useQuery} from '@tanstack/react-query'
import {useSession} from '#/state/session'
import {api} from '#/state/purchases/subscriptions/api'
export function useEntitlements() {
const {currentAccount} = useSession()
return useQuery({
enabled: !!currentAccount,
queryKey: ['entitlements', currentAccount?.did],
refetchOnWindowFocus: true,
async queryFn() {
const params = new URLSearchParams('/entitlements')
params.set('did', currentAccount!.did)
const url = `/account?${params.toString()}`
const {data, error} = await api<{
entitlements: {id: 'core'; platform: 'web'}[]
}>(url).json()
if (error) {
return []
}
return data?.entitlements
},
})
}
@@ -1,28 +0,0 @@
import {useQuery} from '@tanstack/react-query'
import {useSession} from '#/state/session'
import {api} from '#/state/purchases/subscriptions/api'
import {SubscriptionGroupId, PlatformId, Subscription} from '#/state/purchases/subscriptions/types'
export function useSubscriptionsState() {
const {currentAccount} = useSession()
return useQuery({
enabled: !!currentAccount,
queryKey: ['subscriptions-state', currentAccount?.did],
refetchOnWindowFocus: true,
async queryFn() {
const url = `/account?did=${currentAccount!.did}`
const {data, error} = await api<{
subscriptions: Subscription[]
entitlements: {id: 'core'; platform: 'web'}[]
}>(url).json()
if (error || !data) {
throw new Error('Failed to fetch subscriptions state')
}
return data
},
})
}
@@ -1,25 +0,0 @@
import {useMutation} from '@tanstack/react-query'
import Purchases, { PURCHASES_ERROR_CODE } from 'react-native-purchases'
import {useSession} from '#/state/session'
export function useSyncPurchases() {
const {currentAccount} = useSession()
return useMutation({
async mutationFn() {
if (!currentAccount) {
throw new Error('Not logged in')
}
try {
await Purchases.logIn(currentAccount.did)
return await Purchases.restorePurchases()
} catch (e: any) {
if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) {
console.log('recipt error', e)
}
throw e
}
},
})
}
-12
View File
@@ -1,12 +0,0 @@
import {OfferingId} from '#/state/purchases/subscriptions/types';
export function parseOfferingId(id: string): OfferingId {
switch (id) {
case 'coreMonthly':
return OfferingId.CoreMonthly;
case 'coreAnnual':
return OfferingId.CoreAnnual;
default:
throw new Error(`Unknown offering id: ${id}`);
}
}
+74
View File
@@ -0,0 +1,74 @@
import type {PurchasesStoreProduct} from 'react-native-purchases'
/**
* Primitives
*/
export enum EntitlementId {
Core = 'core',
}
export enum PlatformId {
Android = 'android',
Ios = 'ios',
Web = 'web',
}
/**
* Subscription primitives
*/
export enum SubscriptionGroupId {
Core = 'core',
}
export enum SubscriptionOfferingId {
CoreMonthly = 'coreMonthly',
CoreAnnual = 'coreAnnual',
}
export type SubscriptionOffering =
| {
id: SubscriptionOfferingId
platform: PlatformId.Ios | PlatformId.Android
package: PurchasesStoreProduct
}
| {
id: SubscriptionOfferingId
platform: PlatformId.Web
package: {
priceId: string
}
}
/**
* API types for our toy server
*/
export type APISubscription = {
group: SubscriptionGroupId
platform: PlatformId
renews: boolean
periodDtartsAt: string
periodEndsAt: string
purchasedAt: string
}
export type APIEntitlement = {
id: EntitlementId
}
/**
* Parsers
*/
export function parseOfferingId(id: string): SubscriptionOfferingId {
switch (id) {
case 'coreMonthly':
return SubscriptionOfferingId.CoreMonthly
case 'coreAnnual':
return SubscriptionOfferingId.CoreAnnual
default:
throw new Error(`Unknown offering id: ${id}`)
}
}