Cleanup
This commit is contained in:
@@ -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
-1
@@ -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({
|
||||
+6
-4
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
+25
-13
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
+21
-10
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user