This commit is contained in:
Eric Bailey
2024-11-25 10:11:24 -06:00
parent 0cf44f7749
commit 2e2b650209
11 changed files with 514 additions and 405 deletions
+4 -4
View File
@@ -5,10 +5,8 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigationState} from '@react-navigation/native'
import {
usePurchaseOffering,
useSubscriptionGroup,
} from '#/state/purchases/hooks/useSubscriptionGroup'
import {usePurchaseOffering} from '#/state/purchases/hooks/usePurchaseOffering'
import {useSubscriptionGroup} from '#/state/purchases/hooks/useSubscriptionGroup'
import {
SubscriptionGroupId,
SubscriptionOfferingId,
@@ -53,6 +51,8 @@ function DialogInner({control}: {control: Dialog.DialogControlProps}) {
const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core)
const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering()
console.log({coreOffering})
const onPressSubscribe = async () => {
try {
if (!currentAccount) return
+278 -254
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {View} from 'react-native'
import {TextStyle,View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -8,12 +8,16 @@ import {CommonNavigatorParams} from '#/lib/routes/types'
import {isAndroid, isIOS, isWeb} from '#/platform/detection'
import {PurchasesState, usePurchases} from '#/state/purchases'
import {useManageSubscription} from '#/state/purchases/hooks/useManageSubscription'
import {useNativeUserState} from '#/state/purchases/hooks/useNativeUserState'
import {
PlatformId,
SubscriptionGroupId,
SubscriptionOfferingId,
} from '#/state/purchases/types'
import {APISubscription} from '#/state/purchases/types'
import {
APISubscription,
NativePurchaseRestricted,
} from '#/state/purchases/types'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
@@ -25,7 +29,7 @@ import {GradientFill} from '#/components/GradientFill'
import {AndroidLogo} from '#/components/icons/AndroidLogo'
import {AppleLogo} from '#/components/icons/AppleLogo'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Rotate} from '#/components/icons/ArrowRotateCounterClockwise'
import {Full as BlueskyPlusLogo, Logotype} from '#/components/icons/BlueskyPlus'
import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus'
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
@@ -43,6 +47,7 @@ export type ScreenProps = NativeStackScreenProps<
export function Subscriptions(_props: ScreenProps) {
const {_} = useLingui()
const purchases = usePurchases()
const {loading, restricted} = useNativeUserState()
return (
<Layout.Screen>
@@ -51,12 +56,12 @@ export function Subscriptions(_props: ScreenProps) {
<Layout.Content>
<CenteredView style={[a.util_screen_outer]}>
<View style={[a.px_xl, a.py_xl]}>
{purchases.status === 'loading' ? (
{purchases.status === 'loading' || loading ? (
<Loader />
) : purchases.status === 'error' ? (
<View />
) : (
<Core state={purchases} />
<Core state={purchases} restricted={restricted} />
)}
</View>
</CenteredView>
@@ -67,16 +72,48 @@ export function Subscriptions(_props: ScreenProps) {
function Core({
state,
restricted,
}: {
state: Exclude<PurchasesState, {status: 'loading' | 'error'}>
restricted: NativePurchaseRestricted
}) {
const coreSubscriptions = state.subscriptions
.filter(s => s.group === SubscriptionGroupId.Core)
.filter(s => {
return ['active', 'paused'].includes(s.status)
})
const isSubscribedToCore = coreSubscriptions.length > 0
return (
<View>
<BlueskyPlusLogo width={130} gradient="midnight" />
{restricted === 'yes' ? (
<Admonition type="info">
<Trans>
Another account on this device is already subscribed to Bluesky+.
Please subscribe additional accounts through our web application.
</Trans>
</Admonition>
) : isSubscribedToCore ? null : (
<Purchase />
)}
{isSubscribedToCore ? (
<View style={[a.gap_sm]}>
{coreSubscriptions.map(sub => (
<Subscription key={sub.purchasedAt} subscription={sub} />
))}
</View>
) : null}
</View>
)
}
function Purchase() {
const t = useTheme()
const {_} = useLingui()
const control = useDialogControl()
const coreSubscriptions = state.subscriptions.filter(
s => s.group === SubscriptionGroupId.Core,
)
const isSubscribedToCore = !!coreSubscriptions.length
const features = [
{
@@ -122,277 +159,264 @@ function Core({
]
return (
<View style={[]}>
{isSubscribedToCore ? (
<CoreSubscriptions subscriptions={state.subscriptions} />
) : null}
<>
<BlueskyPlusLogo width={130} gradient="nordic" />
{!isSubscribedToCore && (
<>
{state.config.nativePurchaseRestricted === 'yes' ? (
<View style={[a.pt_md]}>
<Admonition type="info">
<Trans>
Another account on this device is already subscribed to
Bluesky+. Please subscribe additional accounts through our web
application.
</Trans>
</Admonition>
</View>
) : (
<>
<BlueskyPlusLogo width={130} gradient="nordic" />
<Text style={[a.text_3xl, a.font_heavy, a.pt_md, a.pb_xs]}>
<Trans>Building a better internet needs your support.</Trans>
</Text>
<Text style={[a.text_3xl, a.font_heavy, a.pt_md, a.pb_xs]}>
<Trans>Building a better internet needs your support.</Trans>
</Text>
<View style={[a.gap_xs]}>
<Text
style={[
a.text_md,
a.leading_snug,
a.pt_xs,
t.atoms.text_contrast_medium,
]}>
<Trans>
Subscribing to Bluesky+ helps ensure that our work to build
an open, secure, and user-first internet can continue.
</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.pt_xs,
t.atoms.text_contrast_medium,
]}>
<Trans>Plus, you'll get access to exclusive features!</Trans>
</Text>
</View>
<View
style={[
a.my_md,
a.p_lg,
a.gap_sm,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
{features.map(f => (
<View key={f.text} style={[a.flex_row, a.gap_md]}>
<View style={{paddingTop: 2}}>
<f.icon
fill={
f.available
? t.palette.primary_500
: t.atoms.text_contrast_low.color
}
size="sm"
/>
</View>
<Text
style={[
a.text_md,
a.leading_snug,
f.available
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{f.text}
</Text>
</View>
))}
</View>
<Button
label={_('Subscribe')}
onPress={() => control.open()}
size="large"
variant="solid"
color="primary"
style={[a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.nordic} />
<ButtonText style={[t.atoms.text]}>
<Trans>Subscribe</Trans>
</ButtonText>
<ButtonIcon
icon={Plus}
position="right"
style={[t.atoms.text]}
/>
</Button>
<BlueskyPlus control={control} />
</>
)}
</>
)}
{/*
<View style={[a.pt_md]}>
<Text style={[a.mb_md, a.text_xs]}>
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_low]}>
Terms and Conditions
</InlineLinkText>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_low]}>
Privacy Policy
</InlineLinkText>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_low]}>
EULA
</InlineLinkText>
<View style={[a.gap_xs]}>
<Text
style={[
a.text_md,
a.leading_snug,
a.pt_xs,
t.atoms.text_contrast_medium,
]}>
<Trans>
Subscribing to Bluesky+ helps ensure that our work to build an open,
secure, and user-first internet can continue.
</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.pt_xs,
t.atoms.text_contrast_medium,
]}>
<Trans>Plus, you'll get access to exclusive features!</Trans>
</Text>
</View>
*/}
</View>
<View
style={[
a.my_md,
a.p_lg,
a.gap_sm,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
{features.map(f => (
<View key={f.text} style={[a.flex_row, a.gap_md]}>
<View style={{paddingTop: 2}}>
<f.icon
fill={
f.available
? t.palette.primary_500
: t.atoms.text_contrast_low.color
}
size="sm"
/>
</View>
<Text
style={[
a.text_md,
a.leading_snug,
f.available ? t.atoms.text : t.atoms.text_contrast_medium,
]}>
{f.text}
</Text>
</View>
))}
</View>
<Button
label={_('Subscribe')}
onPress={() => control.open()}
size="large"
variant="solid"
color="primary"
style={[a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.nordic} />
<ButtonText style={[t.atoms.text]}>
<Trans>Subscribe</Trans>
</ButtonText>
<ButtonIcon icon={Plus} position="right" style={[t.atoms.text]} />
</Button>
<BlueskyPlus control={control} />
</>
)
}
function CoreSubscriptions(props: {subscriptions: APISubscription[]}) {
const t = useTheme()
export function useSubscriptionUI({
subscription: sub,
}: {
subscription: APISubscription
}) {
const {_, i18n} = useLingui()
const {mutateAsync: manageSubscription} = useManageSubscription()
const copy = React.useMemo(() => {
return {
[SubscriptionOfferingId.CoreMonthly]: {
title: _(msg`Monthly`),
},
[SubscriptionOfferingId.CoreAnnual]: {
title: _(msg`Annual`),
},
}
}, [_])
return props.subscriptions.map(sub => {
const endDate = i18n.date(new Date(sub.periodEndsAt), {dateStyle: 'medium'})
const StatusIcon = sub.renews ? Rotate : Clock
const c = copy[sub.offering]
const canManage =
(isWeb && sub.platform === PlatformId.Web) ||
(isIOS && sub.platform === PlatformId.Ios) ||
(isAndroid && sub.platform === PlatformId.Android)
const PlatformIcon = {
return React.useMemo(() => {
const periodEndsAt = i18n.date(new Date(sub.periodEndsAt), {
dateStyle: 'short',
})
const title = {
[SubscriptionOfferingId.CoreMonthly]: _(msg`Monthly`),
[SubscriptionOfferingId.CoreAnnual]: _(msg`Annual`),
}[sub.offering]
const PlatformLogo = {
[PlatformId.Ios]: AppleLogo,
[PlatformId.Android]: AndroidLogo,
[PlatformId.Web]: Globe,
}[sub.platform]
return (
<View
key={sub.purchasedAt}
style={[
a.p_md,
a.px_lg,
a.rounded_md,
a.border,
a.gap_xs,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
{paddingBottom: 6},
]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Logotype width={75} fill={t.atoms.text.color} />
const platformName = {
[PlatformId.Ios]: _('iOS'),
[PlatformId.Android]: _('Android'),
[PlatformId.Web]: _('Web'),
}[sub.platform]
const status = {
active: _(msg`Active`),
expired: _(msg`Expired`),
paused: _(msg`Paused`),
unknown: _(msg`Unknown`),
}[sub.status]
const renewalStatus = {
will_renew: _(msg`Renews ${periodEndsAt}`),
will_not_renew: {
active: _(msg`Expires ${periodEndsAt}`),
expired: _(msg`Expired ${periodEndsAt}`),
paused: _(msg`Expires ${periodEndsAt}`),
unknown: _(msg`Unknown`),
}[sub.status],
will_pause: _(msg`Pauses ${periodEndsAt}`),
unknown: _(msg`Unknown`),
}[sub.renewalStatus]
const RenewalStatusIcon = {
will_renew: Rotate,
will_not_renew: Clock,
will_pause: Clock,
unknown: Clock,
}[sub.renewalStatus]
<Text
style={[
a.text_sm,
a.rounded_full,
a.font_bold,
{
paddingVertical: 3,
paddingHorizontal: 5,
backgroundColor: tokens.blueskyPlus.dark,
color: tokens.blueskyPlus.light,
},
]}>
{c.title}
</Text>
return {
title,
status,
renewalStatus,
platformName,
PlatformLogo,
RenewalStatusIcon,
}
}, [_, i18n, sub])
}
<Text
style={[
a.text_sm,
a.rounded_full,
a.font_bold,
{
paddingVertical: 3,
paddingHorizontal: 5,
borderWidth: sub.renews ? 0 : a.border.borderWidth,
borderColor: t.palette.negative_500,
backgroundColor: sub.renews
? tokens.blueskyPlus.light
: t.atoms.bg.backgroundColor,
color: sub.renews
? tokens.blueskyPlus.dark
: t.atoms.text.color,
},
]}>
{sub.renews ? <Trans>Active</Trans> : <Trans>Cancelled</Trans>}
</Text>
</View>
export function Subscription({
subscription: sub,
}: {
subscription: APISubscription
}) {
const t = useTheme()
const {_} = useLingui()
const {mutateAsync: manageSubscription} = useManageSubscription()
const ui = useSubscriptionUI({subscription: sub})
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<StatusIcon size="sm" fill={t.atoms.text_contrast_low.color} />
<Text style={[a.text_sm, a.font_bold]}>{endDate}</Text>
</View>
const canManage =
(isWeb && sub.platform === PlatformId.Web) ||
(isIOS && sub.platform === PlatformId.Ios) ||
(isAndroid && sub.platform === PlatformId.Android)
const statusStyles = React.useMemo<TextStyle>(() => {
return {
active: {
color: tokens.blueskyPlus.dark,
backgroundColor: tokens.blueskyPlus.light,
borderWidth: undefined,
borderColor: undefined,
},
expired: {
color: t.atoms.text_contrast_medium.color,
backgroundColor: t.atoms.bg.backgroundColor,
borderWidth: a.border.borderWidth,
borderColor: t.atoms.border_contrast_high.borderColor,
},
paused: {
color: t.atoms.bg.backgroundColor,
backgroundColor: t.atoms.text_contrast_medium.color,
borderWidth: undefined,
borderColor: undefined,
},
unknown: {
color: t.atoms.text_contrast_medium.color,
backgroundColor: t.atoms.bg.backgroundColor,
borderWidth: a.border.borderWidth,
borderColor: t.atoms.border_contrast_high.borderColor,
},
}[sub.status]
}, [t, sub.status])
return (
<View
key={sub.purchasedAt}
style={[
a.px_lg,
a.rounded_md,
a.border,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_row, a.justify_between, a.align_center, a.py_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text style={[a.text_lg, a.font_heavy]}>{ui.title}</Text>
<Text
style={[
a.text_xs,
a.rounded_full,
a.font_bold,
{
paddingVertical: 2,
paddingHorizontal: 6,
...statusStyles,
},
]}>
{ui.status}
</Text>
</View>
<Divider />
{canManage && (
<Link
label={_('Manage subscription')}
size="tiny"
variant="solid"
color="secondary_inverted"
{...createStaticClick(() => {
manageSubscription()
})}
style={[a.justify_center]}>
<ButtonText>
<Trans>Manage</Trans>
</ButtonText>
</Link>
)}
</View>
<Divider />
<View style={[a.py_sm, a.flex_row, a.justify_between, a.align_center]}>
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
{paddingTop: 6},
a.gap_xs,
{left: -1},
]}>
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_xs,
{left: -1},
]}>
<PlatformIcon size="md" fill={t.atoms.text_contrast_low.color} />
<Text
style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Managed via web</Trans>
</Text>
</View>
<ui.PlatformLogo size="md" fill={t.atoms.text_contrast_low.color} />
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
{ui.platformName}
</Text>
</View>
{canManage && (
<Link
label={_('Manage subscription')}
size="tiny"
variant="solid"
color="secondary_inverted"
{...createStaticClick(() => {
manageSubscription()
})}
style={[a.justify_center]}>
<ButtonText>
<Trans>Manage</Trans>
</ButtonText>
</Link>
)}
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<ui.RenewalStatusIcon
size="sm"
fill={t.atoms.text_contrast_low.color}
/>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{ui.renewalStatus}
</Text>
</View>
</View>
)
})
</View>
)
}
+2 -6
View File
@@ -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 -6
View File
@@ -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() {
+25 -5
View File
@@ -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 {