Bop it
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
import {APIEntitlement,APISubscription} from '#/state/purchases/types'
|
||||
|
||||
export type NativePurchaseRestricted = 'yes' | 'no' | 'unknown'
|
||||
import {APIEntitlement, APISubscription} from '#/state/purchases/types'
|
||||
|
||||
export type PurchasesState =
|
||||
| {
|
||||
@@ -17,9 +15,7 @@ export type PurchasesState =
|
||||
email?: string
|
||||
subscriptions: APISubscription[]
|
||||
entitlements: APIEntitlement[]
|
||||
config: {
|
||||
nativePurchaseRestricted: NativePurchaseRestricted
|
||||
}
|
||||
config: {}
|
||||
}
|
||||
|
||||
export const Context = React.createContext<PurchasesState>({
|
||||
|
||||
@@ -1,40 +1,57 @@
|
||||
import React from 'react'
|
||||
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {NativePurchaseRestricted} from '#/state/purchases/context'
|
||||
import {NativePurchaseRestricted} from '#/state/purchases/types'
|
||||
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
|
||||
|
||||
// reset on each run
|
||||
setRestricted('unknown')
|
||||
const {data, isFetching} = useQuery<{
|
||||
restricted: NativePurchaseRestricted
|
||||
}>({
|
||||
queryKey: ['purchases-native-user-state', currentAccount?.did],
|
||||
placeholderData: {restricted: 'unknown'},
|
||||
/**
|
||||
* Once we fetch this, no need to do so again for the current session. We
|
||||
* don't do any transfer logic between accounts, so this doesn't change
|
||||
* during the course of a user session.
|
||||
*/
|
||||
staleTime: Infinity,
|
||||
async queryFn() {
|
||||
if (!currentAccount) {
|
||||
return {restricted: 'unknown'}
|
||||
}
|
||||
|
||||
try {
|
||||
// MUST ensure we're the correct user first
|
||||
await Purchases.logIn(currentAccount?.did)
|
||||
await Purchases.logIn(currentAccount.did)
|
||||
await Purchases.restorePurchases()
|
||||
setRestricted('no')
|
||||
return {restricted: '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')
|
||||
switch (e.code) {
|
||||
/**
|
||||
* Indicates there are active subscriptions for another account on
|
||||
* this device. User cannot make additional purchases on this device.
|
||||
*
|
||||
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
|
||||
*/
|
||||
case PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR:
|
||||
return {restricted: 'yes'}
|
||||
default:
|
||||
/**
|
||||
* Any other error is considered unknown. Let the user proceed.
|
||||
* Additional errors can occur at time of purchase and should be
|
||||
* handled there.
|
||||
*/
|
||||
return {restricted: 'no'}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
}, [currentAccount?.did])
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
restricted,
|
||||
loading: isFetching,
|
||||
restricted: data?.restricted ?? 'unknown',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export function useNativeUserState() {
|
||||
import {NativePurchaseRestricted} from '#/state/purchases/types'
|
||||
|
||||
export function useNativeUserState(): {
|
||||
loading: boolean
|
||||
restricted: NativePurchaseRestricted
|
||||
} {
|
||||
return {
|
||||
restricted: false,
|
||||
loading: false,
|
||||
restricted: 'no',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
PlatformId,
|
||||
ReceiptAlreadyInUseError,
|
||||
SubscriptionOffering,
|
||||
} from '#/state/purchases/types'
|
||||
|
||||
type Params = {
|
||||
did: string
|
||||
email: string
|
||||
offering: SubscriptionOffering
|
||||
}
|
||||
|
||||
export function usePurchaseOffering() {
|
||||
const {_} = useLingui()
|
||||
|
||||
return useMutation<void, Error | ReceiptAlreadyInUseError, Params>({
|
||||
async mutationFn({did, email, offering}: Params) {
|
||||
if (offering.platform === PlatformId.Web) {
|
||||
throw new Error('Unsupported platform')
|
||||
}
|
||||
|
||||
try {
|
||||
await Purchases.logIn(did)
|
||||
await Purchases.setEmail(email)
|
||||
await Purchases.purchaseStoreProduct(offering.package)
|
||||
} 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) {
|
||||
throw new ReceiptAlreadyInUseError(
|
||||
_(
|
||||
msg`This device has existing active subscriptions for a different account. Multiple account subscriptions are not possible on native platforms at this time.`,
|
||||
),
|
||||
// @ts-ignore
|
||||
{cause: e},
|
||||
)
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {Linking} from 'react-native'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {PlatformId, SubscriptionOffering} from '#/state/purchases/types'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
export function usePurchaseOffering() {
|
||||
return useMutation({
|
||||
async mutationFn({
|
||||
did,
|
||||
email,
|
||||
offering,
|
||||
}: {
|
||||
did: string
|
||||
email: string
|
||||
offering: SubscriptionOffering
|
||||
}) {
|
||||
if (offering.platform !== PlatformId.Web) {
|
||||
throw new Error('Unsupported platform')
|
||||
}
|
||||
|
||||
const {data, error} = await api<{
|
||||
checkoutUrl: string
|
||||
}>('/checkout/create', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
did,
|
||||
email,
|
||||
price: offering.package.priceId,
|
||||
redirectUrl: IS_DEV
|
||||
? `http://localhost:19006/subscriptions`
|
||||
: `https://bsky.app/subscriptions`,
|
||||
},
|
||||
}).json()
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error(`Failed to create checkout URL`)
|
||||
}
|
||||
|
||||
Linking.openURL(data.checkoutUrl)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||
import {useMutation,useQuery} from '@tanstack/react-query'
|
||||
import Purchases from 'react-native-purchases'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {
|
||||
parseOfferingId,
|
||||
APIOffering,
|
||||
PlatformId,
|
||||
SubscriptionGroupId,
|
||||
SubscriptionOffering,
|
||||
@@ -14,67 +14,56 @@ export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||
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 platform = isAndroid ? 'android' : 'ios'
|
||||
const {data, error} = await api<{
|
||||
offerings: APIOffering[]
|
||||
}>(`/subscriptions/${group}?platform=${platform}`).json()
|
||||
|
||||
if (error || !data) {
|
||||
console.log(error, response)
|
||||
throw new Error('Failed to fetch subscription group')
|
||||
throw new Error(`Failed to fetch subscription group`)
|
||||
}
|
||||
|
||||
const {offerings} = data
|
||||
const productIds = offerings.map((o: any) => {
|
||||
return isIOS ? o.productId : o.productId.split(':')[0]
|
||||
})
|
||||
// console.log({ offers: false })
|
||||
// const offers = await Purchases.getOfferings()
|
||||
// console.log({ offers })
|
||||
const products = await Purchases.getProducts(productIds)
|
||||
const revenueCatIdentifiers = offerings.map(o =>
|
||||
isAndroid ? parseIdentifierFromAndroidProductId(o.product) : o.product,
|
||||
)
|
||||
const products = await Purchases.getProducts(revenueCatIdentifiers)
|
||||
|
||||
const parsed: SubscriptionOffering[] = []
|
||||
for (const o of offerings) {
|
||||
if (o.platform === PlatformId.Web) continue
|
||||
|
||||
const product = products.find(p => p.identifier === o.product)
|
||||
if (!product) continue
|
||||
|
||||
parsed.push({
|
||||
id: o.id,
|
||||
platform: o.platform,
|
||||
package: product,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
offerings: offerings.map((o: any) => {
|
||||
const product = products.find(
|
||||
(p: any) => p.identifier === o.productId,
|
||||
)
|
||||
return {
|
||||
id: parseOfferingId(o.id),
|
||||
platform: isIOS ? PlatformId.Ios : PlatformId.Android,
|
||||
price: product?.priceString,
|
||||
package: product,
|
||||
}
|
||||
}),
|
||||
offerings: parsed,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePurchaseOffering() {
|
||||
return useMutation({
|
||||
async mutationFn({
|
||||
did,
|
||||
email,
|
||||
offering,
|
||||
}: {
|
||||
did: string
|
||||
email: string
|
||||
offering: SubscriptionOffering
|
||||
}) {
|
||||
if (offering.platform === PlatformId.Web) {
|
||||
throw new Error('Unsupported platform')
|
||||
}
|
||||
/**
|
||||
* Whereas iOS has separate product IDs for each subscription product, Android
|
||||
* has a single ID with a suffixed payment plan e.g. `monthly` and `annual` for
|
||||
* our core offerings.
|
||||
*
|
||||
* However, the full "identifier" is concatenated, so we just pass around the
|
||||
* full thing and parse from there.
|
||||
*/
|
||||
function parseIdentifierFromAndroidProductId(productId: string) {
|
||||
if (!productId.includes(':')) {
|
||||
throw new Error(
|
||||
`Expected Android product ID to contain a colon: ${productId}`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await Purchases.logIn(did)
|
||||
await Purchases.setEmail(email)
|
||||
await Purchases.purchaseStoreProduct(offering.package)
|
||||
} catch (e: any) {
|
||||
if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) {
|
||||
console.log('recipt error', e)
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
},
|
||||
})
|
||||
return productId.split(':')[0]
|
||||
}
|
||||
|
||||
@@ -1,75 +1,43 @@
|
||||
import {Linking} from 'react-native'
|
||||
import {useMutation,useQuery} from '@tanstack/react-query'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {
|
||||
parseOfferingId,
|
||||
APIOffering,
|
||||
PlatformId,
|
||||
SubscriptionGroupId,
|
||||
SubscriptionOffering,
|
||||
} from '#/state/purchases/types'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||
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<{
|
||||
offerings: APIOffering[]
|
||||
}>(`/subscriptions/${group}?platform=web`).json()
|
||||
if (error || !data) {
|
||||
throw new Error('Failed to fetch subscription group')
|
||||
}
|
||||
|
||||
const {offerings} = data
|
||||
|
||||
return {
|
||||
offerings: offerings.map((o: any) => ({
|
||||
id: parseOfferingId(o.id),
|
||||
platform: PlatformId.Web,
|
||||
const parsed: SubscriptionOffering[] = []
|
||||
|
||||
for (const o of offerings) {
|
||||
if (o.platform !== PlatformId.Web) continue
|
||||
|
||||
parsed.push({
|
||||
id: o.id,
|
||||
platform: o.platform,
|
||||
package: {
|
||||
priceId: o.productId,
|
||||
priceId: o.product,
|
||||
},
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
offerings: parsed,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePurchaseOffering() {
|
||||
return useMutation({
|
||||
async mutationFn({
|
||||
did,
|
||||
email,
|
||||
offering,
|
||||
}: {
|
||||
did: string
|
||||
email: string
|
||||
offering: SubscriptionOffering
|
||||
}) {
|
||||
if (offering.platform !== PlatformId.Web) {
|
||||
throw new Error('Unsupported platform')
|
||||
}
|
||||
|
||||
const {data, error} = await api<{
|
||||
checkoutUrl: string
|
||||
}>('/checkout/create', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
did,
|
||||
email,
|
||||
price: offering.package.priceId,
|
||||
redirectUrl: IS_DEV
|
||||
? `http://localhost:19006/subscriptions`
|
||||
: `https://bsky.app/subscriptions`,
|
||||
},
|
||||
}).json()
|
||||
|
||||
if (error || !data) {
|
||||
throw error
|
||||
}
|
||||
|
||||
Linking.openURL(data.checkoutUrl)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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'
|
||||
@@ -13,7 +12,6 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
error: purchasesStateError,
|
||||
refetch,
|
||||
} = usePurchasesState()
|
||||
const {restricted} = useNativeUserState()
|
||||
const ctx = React.useMemo<PurchasesState>(() => {
|
||||
if (purchasesStateError) {
|
||||
return {
|
||||
@@ -30,12 +28,10 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
email: purchases?.email,
|
||||
subscriptions: purchases?.subscriptions ?? [],
|
||||
entitlements: purchases?.entitlements ?? [],
|
||||
config: {
|
||||
nativePurchaseRestricted: restricted,
|
||||
},
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
}, [purchases, purchasesStateError, restricted])
|
||||
}, [purchases, purchasesStateError])
|
||||
|
||||
useNativeEventsListener({
|
||||
onCustomerInfoUpdated() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {PurchasesStoreProduct} from 'react-native-purchases'
|
||||
|
||||
/**
|
||||
* Primitives
|
||||
* PRIMITIVES
|
||||
*/
|
||||
|
||||
export enum EntitlementId {
|
||||
@@ -15,7 +15,7 @@ export enum PlatformId {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription primitives
|
||||
* SUBSCRIPTION PRIMITIVES
|
||||
*/
|
||||
|
||||
export enum SubscriptionGroupId {
|
||||
@@ -41,15 +41,18 @@ export type SubscriptionOffering =
|
||||
}
|
||||
}
|
||||
|
||||
export type NativePurchaseRestricted = 'yes' | 'no' | 'unknown'
|
||||
|
||||
/**
|
||||
* API types for our toy server
|
||||
* API TYPES FROM OUR TOY SERVER
|
||||
*/
|
||||
|
||||
export type APISubscription = {
|
||||
status: 'active' | 'paused' | 'expired' | 'unknown'
|
||||
renewalStatus: 'will_renew' | 'will_not_renew' | 'will_pause' | 'unknown'
|
||||
group: SubscriptionGroupId
|
||||
platform: PlatformId
|
||||
offering: SubscriptionOfferingId
|
||||
renews: boolean
|
||||
periodDtartsAt: string
|
||||
periodEndsAt: string
|
||||
purchasedAt: string
|
||||
@@ -59,8 +62,25 @@ export type APIEntitlement = {
|
||||
id: EntitlementId
|
||||
}
|
||||
|
||||
export type APIOffering = {
|
||||
id: SubscriptionOfferingId
|
||||
platform: PlatformId
|
||||
product: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsers
|
||||
* CUSTOM ERROR TYPES
|
||||
*/
|
||||
|
||||
/**
|
||||
* Thrown if the device already has active subscriptions for other accounts.
|
||||
*
|
||||
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
|
||||
*/
|
||||
export class ReceiptAlreadyInUseError extends Error {}
|
||||
|
||||
/**
|
||||
* PARSERS
|
||||
*/
|
||||
|
||||
export function parseOfferingId(id: string): SubscriptionOfferingId {
|
||||
|
||||
Reference in New Issue
Block a user