Cleanup
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm1 6a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l2.5 2.5a1 1 0 0 0 1.414-1.414L13 11.586V8Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
+45
-40
@@ -2,9 +2,9 @@ import 'react-native-url-polyfill/auto'
|
|||||||
import '#/lib/sentry' // must be near top
|
import '#/lib/sentry' // must be near top
|
||||||
import '#/view/icons'
|
import '#/view/icons'
|
||||||
|
|
||||||
import Purchases from 'react-native-purchases'
|
|
||||||
import React, {useEffect, useState} from 'react'
|
import React, {useEffect, useState} from 'react'
|
||||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||||
|
import Purchases from 'react-native-purchases'
|
||||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||||
import {
|
import {
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
@@ -45,6 +45,7 @@ import {init as initPersistedState} from '#/state/persisted'
|
|||||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||||
|
import {Provider as PurchasesProvider} from '#/state/purchases'
|
||||||
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||||
import {
|
import {
|
||||||
Provider as SessionProvider,
|
Provider as SessionProvider,
|
||||||
@@ -133,37 +134,39 @@ function InnerApp() {
|
|||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<ComposerProvider>
|
<PurchasesProvider>
|
||||||
<StatsigProvider>
|
<ComposerProvider>
|
||||||
<MessagesProvider>
|
<StatsigProvider>
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
<MessagesProvider>
|
||||||
<LabelDefsProvider>
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
<ModerationOptsProvider>
|
<LabelDefsProvider>
|
||||||
<LoggedOutViewProvider>
|
<ModerationOptsProvider>
|
||||||
<SelectedFeedProvider>
|
<LoggedOutViewProvider>
|
||||||
<HiddenRepliesProvider>
|
<SelectedFeedProvider>
|
||||||
<UnreadNotifsProvider>
|
<HiddenRepliesProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<UnreadNotifsProvider>
|
||||||
<MutedThreadsProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<ProgressGuideProvider>
|
<MutedThreadsProvider>
|
||||||
<GestureHandlerRootView
|
<ProgressGuideProvider>
|
||||||
style={s.h100pct}>
|
<GestureHandlerRootView
|
||||||
<TestCtrls />
|
style={s.h100pct}>
|
||||||
<Shell />
|
<TestCtrls />
|
||||||
<NuxDialogs />
|
<Shell />
|
||||||
</GestureHandlerRootView>
|
<NuxDialogs />
|
||||||
</ProgressGuideProvider>
|
</GestureHandlerRootView>
|
||||||
</MutedThreadsProvider>
|
</ProgressGuideProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</MutedThreadsProvider>
|
||||||
</UnreadNotifsProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</HiddenRepliesProvider>
|
</UnreadNotifsProvider>
|
||||||
</SelectedFeedProvider>
|
</HiddenRepliesProvider>
|
||||||
</LoggedOutViewProvider>
|
</SelectedFeedProvider>
|
||||||
</ModerationOptsProvider>
|
</LoggedOutViewProvider>
|
||||||
</LabelDefsProvider>
|
</ModerationOptsProvider>
|
||||||
</MessagesProvider>
|
</LabelDefsProvider>
|
||||||
</StatsigProvider>
|
</MessagesProvider>
|
||||||
</ComposerProvider>
|
</StatsigProvider>
|
||||||
|
</ComposerProvider>
|
||||||
|
</PurchasesProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</VideoVolumeProvider>
|
</VideoVolumeProvider>
|
||||||
@@ -178,15 +181,17 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Promise.all([initPersistedState(), ensureGeolocationResolved(), Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG)]).then(() => {
|
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(
|
||||||
if (isIOS) {
|
() => {
|
||||||
Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY})
|
setReady(true)
|
||||||
} else if (isAndroid) {
|
},
|
||||||
Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY})
|
)
|
||||||
}
|
|
||||||
|
|
||||||
setReady(true)
|
if (isIOS) {
|
||||||
})
|
Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY})
|
||||||
|
} else if (isAndroid) {
|
||||||
|
Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY})
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {init as initPersistedState} from '#/state/persisted'
|
|||||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||||
|
import {Provider as PurchasesProvider} from '#/state/purchases'
|
||||||
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||||
import {
|
import {
|
||||||
Provider as SessionProvider,
|
Provider as SessionProvider,
|
||||||
@@ -110,6 +111,8 @@ function InnerApp() {
|
|||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
|
<PurchasesProvider>
|
||||||
|
|
||||||
<ComposerProvider>
|
<ComposerProvider>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
<MessagesProvider>
|
<MessagesProvider>
|
||||||
@@ -139,6 +142,7 @@ function InnerApp() {
|
|||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
</ComposerProvider>
|
</ComposerProvider>
|
||||||
|
</PurchasesProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {
|
|
||||||
OfferingId,
|
|
||||||
SubscriptionGroupId,
|
|
||||||
} from '#/state/purchases/subscriptions/types'
|
|
||||||
import {
|
import {
|
||||||
usePurchaseOffering,
|
usePurchaseOffering,
|
||||||
useSubscriptionGroup,
|
useSubscriptionGroup,
|
||||||
} from '#/state/purchases/subscriptions/useSubscriptionGroup'
|
} from '#/state/purchases/hooks/useSubscriptionGroup'
|
||||||
import {parseOfferingId} from '#/state/purchases/subscriptions/util'
|
import {
|
||||||
|
SubscriptionGroupId,
|
||||||
|
SubscriptionOfferingId,
|
||||||
|
} from '#/state/purchases/types'
|
||||||
|
import {parseOfferingId} from '#/state/purchases/types'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
|
||||||
@@ -22,7 +23,7 @@ import {GradientFill} from '#/components/GradientFill'
|
|||||||
import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus'
|
import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus'
|
||||||
import {CheckThick_Stroke2_Corner0_Rounded as CheckThink} from '#/components/icons/Check'
|
import {CheckThick_Stroke2_Corner0_Rounded as CheckThink} from '#/components/icons/Check'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText, Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -30,20 +31,20 @@ export function BlueskyPlus({control}: {control: Dialog.DialogControlProps}) {
|
|||||||
return (
|
return (
|
||||||
<Dialog.Outer control={control}>
|
<Dialog.Outer control={control}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<DialogInner />
|
<DialogInner control={control} />
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogInner() {
|
function DialogInner({control}: {control: Dialog.DialogControlProps}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const copy = useCoreOfferingCopy()
|
const copy = useCoreOfferingCopy()
|
||||||
|
|
||||||
const [offeringId, setOfferingId] = React.useState<OfferingId>(
|
const [offeringId, setOfferingId] = React.useState<SubscriptionOfferingId>(
|
||||||
OfferingId.CoreAnnual,
|
SubscriptionOfferingId.CoreAnnual,
|
||||||
)
|
)
|
||||||
const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core)
|
const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core)
|
||||||
const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering()
|
const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering()
|
||||||
@@ -57,13 +58,29 @@ function DialogInner() {
|
|||||||
throw new Error('No offering')
|
throw new Error('No offering')
|
||||||
}
|
}
|
||||||
|
|
||||||
await purchaseOffering({
|
control.close(async () => {
|
||||||
did: currentAccount.did,
|
try {
|
||||||
email: currentAccount.email!,
|
await purchaseOffering({
|
||||||
offering,
|
did: currentAccount.did,
|
||||||
|
email: currentAccount.email!,
|
||||||
|
offering,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
/**
|
||||||
|
* @see https://www.revenuecat.com/docs/test-and-launch/errors
|
||||||
|
*/
|
||||||
|
if (e.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
|
||||||
|
control.open()
|
||||||
|
} else {
|
||||||
|
Toast.show(
|
||||||
|
_(msg`Hmmmm, something went wrong. Please try again.`),
|
||||||
|
'xmark',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Toast.show(_(msg`Could not take you to checkout`), 'xmark')
|
Toast.show(_(msg`Hmmmm. We couldn't locate that subscription.`), 'xmark')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +93,7 @@ function DialogInner() {
|
|||||||
]}>
|
]}>
|
||||||
<BlueskyPlusLogo width={100} fill="nordic" />
|
<BlueskyPlusLogo width={100} fill="nordic" />
|
||||||
|
|
||||||
<Text style={[a.text_3xl, a.font_heavy, a.pt_lg]}>
|
<Text style={[a.text_3xl, a.font_heavy, a.pt_md]}>
|
||||||
<Trans>Let's build the social web.</Trans>
|
<Trans>Let's build the social web.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -99,7 +116,10 @@ function DialogInner() {
|
|||||||
values={[offeringId]}
|
values={[offeringId]}
|
||||||
onChange={values => setOfferingId(parseOfferingId(values[0]))}
|
onChange={values => setOfferingId(parseOfferingId(values[0]))}
|
||||||
style={[a.w_full, a.gap_sm, a.pt_lg]}>
|
style={[a.w_full, a.gap_sm, a.pt_lg]}>
|
||||||
{[OfferingId.CoreMonthly, OfferingId.CoreAnnual].map(id => {
|
{[
|
||||||
|
SubscriptionOfferingId.CoreMonthly,
|
||||||
|
SubscriptionOfferingId.CoreAnnual,
|
||||||
|
].map(id => {
|
||||||
return (
|
return (
|
||||||
<Toggle.Item key={id} name={id} label={_(msg`Monthly plan`)}>
|
<Toggle.Item key={id} name={id} label={_(msg`Monthly plan`)}>
|
||||||
{({selected, hovered}) => (
|
{({selected, hovered}) => (
|
||||||
@@ -209,7 +229,18 @@ function DialogInner() {
|
|||||||
})}
|
})}
|
||||||
</Toggle.Group>
|
</Toggle.Group>
|
||||||
|
|
||||||
<View style={[a.pt_md]}>
|
<View style={[a.flex_row, a.pt_md, a.gap_sm]}>
|
||||||
|
<Link
|
||||||
|
to="/subscriptions"
|
||||||
|
label={_(msg`Learn more about Bluesky+`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="large"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<ButtonText style={[a.flex_1]}>
|
||||||
|
<Trans>Learn more</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Subscribe`)}
|
label={_(msg`Subscribe`)}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@@ -217,7 +248,7 @@ function DialogInner() {
|
|||||||
size="large"
|
size="large"
|
||||||
onPress={onPressSubscribe}
|
onPress={onPressSubscribe}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
style={[a.overflow_hidden]}>
|
style={[a.flex_1, a.overflow_hidden]}>
|
||||||
<GradientFill gradient={tokens.gradients.nordic} />
|
<GradientFill gradient={tokens.gradients.nordic} />
|
||||||
<ButtonText style={[t.atoms.text]}>
|
<ButtonText style={[t.atoms.text]}>
|
||||||
<Trans>Subscribe</Trans>
|
<Trans>Subscribe</Trans>
|
||||||
@@ -264,12 +295,12 @@ function useCoreOfferingCopy() {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
return React.useMemo(() => {
|
return React.useMemo(() => {
|
||||||
return {
|
return {
|
||||||
[OfferingId.CoreMonthly]: {
|
[SubscriptionOfferingId.CoreMonthly]: {
|
||||||
title: _(msg`1 month`),
|
title: _(msg`1 month`),
|
||||||
price: _(msg`$8 / month`),
|
price: _(msg`$8 / month`),
|
||||||
discount: undefined,
|
discount: undefined,
|
||||||
},
|
},
|
||||||
[OfferingId.CoreAnnual]: {
|
[SubscriptionOfferingId.CoreAnnual]: {
|
||||||
title: _(msg`12 months`),
|
title: _(msg`12 months`),
|
||||||
price: _(msg`$72 / year`),
|
price: _(msg`$72 / year`),
|
||||||
discount: _(msg`Save 25%`),
|
discount: _(msg`Save 25%`),
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Clock_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm1 6a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l2.5 2.5a1 1 0 0 0 1.414-1.414L13 11.586V8Z',
|
||||||
|
})
|
||||||
@@ -5,20 +5,27 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||||
import {useSubscriptionsState} from '#/state/purchases/subscriptions/useSubscriptionsState'
|
import {PurchasesState,usePurchases} from '#/state/purchases'
|
||||||
import {useSyncPurchases} from '#/state/purchases/subscriptions/useSyncPurchases'
|
import {useManageSubscription} from '#/state/purchases/hooks/useManageSubscription'
|
||||||
import {useManageSubscription} from '#/state/purchases/subscriptions/useManageSubscription'
|
import {SubscriptionGroupId} from '#/state/purchases/types'
|
||||||
import {Subscription} from '#/state/purchases/subscriptions/types'
|
import {APISubscription} from '#/state/purchases/types'
|
||||||
import {CenteredView} from '#/view/com/util/Views'
|
import {CenteredView} from '#/view/com/util/Views'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, tokens,useTheme} from '#/alf'
|
||||||
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {BlueskyPlus} from '#/components/dialogs/BlueskyPlus'
|
import {BlueskyPlus} from '#/components/dialogs/BlueskyPlus'
|
||||||
|
import {GradientFill} from '#/components/GradientFill'
|
||||||
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Rotate} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||||
|
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 {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
|
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {createStaticClick,InlineLinkText, Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {InlineLinkText, createStaticClick} from '#/components/Link'
|
|
||||||
|
|
||||||
export type ScreenProps = NativeStackScreenProps<
|
export type ScreenProps = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -27,22 +34,7 @@ export type ScreenProps = NativeStackScreenProps<
|
|||||||
|
|
||||||
export function Subscriptions(_props: ScreenProps) {
|
export function Subscriptions(_props: ScreenProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {data: state, isLoading: isStateLoading} =
|
const purchases = usePurchases()
|
||||||
useSubscriptionsState()
|
|
||||||
const isSubscribed = state?.entitlements?.some(e => e.id === 'core')
|
|
||||||
const control = useDialogControl()
|
|
||||||
|
|
||||||
const {mutateAsync: syncPurchases} = useSyncPurchases()
|
|
||||||
const [data, setData] = React.useState<any>(null)
|
|
||||||
|
|
||||||
const sync = async () => {
|
|
||||||
try {
|
|
||||||
const data = await syncPurchases()
|
|
||||||
setData(data)
|
|
||||||
} catch (e: any) {
|
|
||||||
setData({ message: e.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Screen>
|
<Layout.Screen>
|
||||||
@@ -50,43 +42,14 @@ export function Subscriptions(_props: ScreenProps) {
|
|||||||
|
|
||||||
<Layout.Content>
|
<Layout.Content>
|
||||||
<CenteredView sideBorders={true} style={[a.util_screen_outer]}>
|
<CenteredView sideBorders={true} style={[a.util_screen_outer]}>
|
||||||
<View style={[a.px_xl, a.py_xl, a.gap_lg]}>
|
<View style={[a.px_xl, a.py_xl]}>
|
||||||
{isStateLoading ? (
|
{purchases.status === 'loading' ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : purchases.status === 'error' ? (
|
||||||
<>
|
<View />
|
||||||
{isSubscribed ? (
|
) : (
|
||||||
<CoreSubscriptions subscriptions={state!.subscriptions} />
|
<Core state={purchases} />
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
label={_('Subscribe')}
|
|
||||||
onPress={() => control.open()}
|
|
||||||
size="large"
|
|
||||||
variant="solid"
|
|
||||||
color="primary">
|
|
||||||
<ButtonText>Subscribe</ButtonText>
|
|
||||||
<ButtonIcon icon={Plus} />
|
|
||||||
</Button>
|
|
||||||
<BlueskyPlus control={control} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
label={_('Subscribe')}
|
|
||||||
onPress={() => sync()}
|
|
||||||
size="large"
|
|
||||||
variant="solid"
|
|
||||||
color="secondary">
|
|
||||||
<ButtonText>Restore Purchase</ButtonText>
|
|
||||||
<ButtonIcon icon={Plus} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{data && (
|
|
||||||
<Text>{JSON.stringify(data, null, 2)}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
@@ -94,33 +57,263 @@ export function Subscriptions(_props: ScreenProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoreSubscriptions(props: { subscriptions: Subscription[] }) {
|
function Core({
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
state: Exclude<PurchasesState, {status: 'loading' | 'error'}>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const control = useDialogControl()
|
||||||
|
const coreSubscriptions = state.subscriptions.filter(
|
||||||
|
s => s.group === SubscriptionGroupId.Core,
|
||||||
|
)
|
||||||
|
const isSubscribedToCore = !!coreSubscriptions.length
|
||||||
|
console.log(state)
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
available: true,
|
||||||
|
icon: Check,
|
||||||
|
text: _(msg`Bluesky+ supporter badge`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: true,
|
||||||
|
icon: Check,
|
||||||
|
text: _(msg`Custom app icons`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: true,
|
||||||
|
icon: Check,
|
||||||
|
text: _(msg`Profile customizations`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: true,
|
||||||
|
icon: Check,
|
||||||
|
text: _(msg`Higher video upload limits`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: true,
|
||||||
|
icon: Check,
|
||||||
|
text: _(msg`High quality video resolution`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: false,
|
||||||
|
icon: Clock,
|
||||||
|
text: _(msg`Inline post translations (coming soon)`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: false,
|
||||||
|
icon: Clock,
|
||||||
|
text: _(msg`Post analytics (coming soon)`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: false,
|
||||||
|
icon: Clock,
|
||||||
|
text: _(msg`Bookmark folders (coming soon)`),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.pt_sm]}>
|
||||||
|
<BlueskyPlusLogo width={130} fill="nordic" />
|
||||||
|
|
||||||
|
{isSubscribedToCore ? (
|
||||||
|
<View style={[a.pt_md]}>
|
||||||
|
<CoreSubscriptions subscriptions={state.subscriptions} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>{' '}
|
||||||
|
·{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
to="#"
|
||||||
|
label="TODO REPLACE"
|
||||||
|
style={[a.text_xs, t.atoms.text_contrast_low]}>
|
||||||
|
Privacy Policy
|
||||||
|
</InlineLinkText>{' '}
|
||||||
|
·{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
to="#"
|
||||||
|
label="TODO REPLACE"
|
||||||
|
style={[a.text_xs, t.atoms.text_contrast_low]}>
|
||||||
|
EULA
|
||||||
|
</InlineLinkText>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Admonition type="tip">
|
||||||
|
<Trans>
|
||||||
|
Learn more about Bluesky+ and our roadmap{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
to="https://bsky.social/about"
|
||||||
|
label={_(msg`Learn more in our FAQ`)}>
|
||||||
|
here
|
||||||
|
</InlineLinkText>
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoreSubscriptions(props: {subscriptions: APISubscription[]}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_, i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const {mutateAsync: manageSubscription} = useManageSubscription()
|
const {mutateAsync: manageSubscription} = useManageSubscription()
|
||||||
|
|
||||||
return props.subscriptions.map(sub => (
|
return props.subscriptions.map(sub => {
|
||||||
<View style={[a.p_lg, a.rounded_md, a.border, a.gap_xs, t.atoms.border_contrast_low]}>
|
const endDate = i18n.date(new Date(sub.periodEndsAt), {dateStyle: 'medium'})
|
||||||
<Text style={[a.text_lg, a.font_heavy]}>Bluesky+</Text>
|
const StatusIcon = sub.renews ? Rotate : Clock
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={sub.purchasedAt}
|
||||||
|
style={[
|
||||||
|
a.p_lg,
|
||||||
|
a.py_md,
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
a.gap_xs,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<GradientFill
|
||||||
|
gradient={tokens.gradients.nordic}
|
||||||
|
style={{opacity: 0.1}}
|
||||||
|
/>
|
||||||
|
|
||||||
{sub.renews ? (
|
<View style={[a.flex_row, a.justify_between, a.align_center]}>
|
||||||
<Text style={[a.text_sm]}>
|
<View>
|
||||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}><Trans>Renews:</Trans></Text>{' '}
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<Text style={[a.text_sm]}>{i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium", timeStyle: "medium" })}</Text>
|
<Text
|
||||||
</Text>
|
style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
) : (
|
Active
|
||||||
<Text style={[a.text_sm]}>
|
</Text>
|
||||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}><Trans>Ends:</Trans></Text>{' '}
|
</View>
|
||||||
<Text style={[a.text_sm]}>{i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium" })}</Text>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<InlineLinkText
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
label={_('Manage subscription')}
|
<StatusIcon size="sm" fill={t.atoms.text_contrast_low.color} />
|
||||||
{...createStaticClick(() => {manageSubscription()})}
|
<Text style={[a.text_sm]}>{endDate}</Text>
|
||||||
>
|
</View>
|
||||||
<Trans>Manage subscription</Trans>
|
</View>
|
||||||
</InlineLinkText>
|
<Link
|
||||||
</View>
|
label={_('Manage subscription')}
|
||||||
))
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
{...createStaticClick(() => {
|
||||||
|
manageSubscription()
|
||||||
|
})}
|
||||||
|
style={[a.justify_center]}>
|
||||||
|
<ButtonIcon icon={Gear} size="lg" />
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {Linking} from 'react-native'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
|
||||||
import Purchases from 'react-native-purchases'
|
import Purchases from 'react-native-purchases'
|
||||||
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
export function useManageSubscription() {
|
export function useManageSubscription() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
+6
-4
@@ -1,9 +1,9 @@
|
|||||||
import {Linking} from 'react-native'
|
import {Linking} from 'react-native'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {IS_DEV} from '#/env'
|
import {api} from '#/state/purchases/api'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {api} from '#/state/purchases/subscriptions/api'
|
import {IS_DEV} from '#/env'
|
||||||
|
|
||||||
export function useManageSubscription() {
|
export function useManageSubscription() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -16,8 +16,10 @@ export function useManageSubscription() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
json: {
|
json: {
|
||||||
did: currentAccount!.did,
|
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()
|
}).json()
|
||||||
|
|
||||||
if (error || !data) {
|
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 Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||||
import {useQuery, useMutation} from '@tanstack/react-query'
|
import {useMutation,useQuery} from '@tanstack/react-query'
|
||||||
import Purchases, { PURCHASES_ERROR_CODE } from 'react-native-purchases'
|
|
||||||
|
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {api} from '#/state/purchases/subscriptions/api'
|
import {api} from '#/state/purchases/api'
|
||||||
import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types'
|
import {
|
||||||
|
parseOfferingId,
|
||||||
|
PlatformId,
|
||||||
|
SubscriptionGroupId,
|
||||||
|
SubscriptionOffering,
|
||||||
|
} from '#/state/purchases/types'
|
||||||
|
|
||||||
export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||||
return useQuery<{ offerings: Offering[] }>({
|
return useQuery<{offerings: SubscriptionOffering[]}>({
|
||||||
queryKey: ['subscription-group', group],
|
queryKey: ['subscription-group', group],
|
||||||
async queryFn() {
|
async queryFn() {
|
||||||
const platform = isIOS ? 'ios' : 'android'
|
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) {
|
if (error || !data) {
|
||||||
console.log(error, response)
|
console.log(error, response)
|
||||||
throw new Error('Failed to fetch subscription group')
|
throw new Error('Failed to fetch subscription group')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { offerings } = data
|
const {offerings} = data
|
||||||
const productIds = offerings.map((o: any) => {
|
const productIds = offerings.map((o: any) => {
|
||||||
return isIOS ? o.productId : o.productId.split(':')[0]
|
return isIOS ? o.productId : o.productId.split(':')[0]
|
||||||
})
|
})
|
||||||
@@ -28,16 +34,18 @@ export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
offerings: offerings.map((o: any) => {
|
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 {
|
return {
|
||||||
id: o.id as OfferingId,
|
id: parseOfferingId(o.id),
|
||||||
platform: isIOS ? PlatformId.Ios : PlatformId.Android,
|
platform: isIOS ? PlatformId.Ios : PlatformId.Android,
|
||||||
price: product?.priceString,
|
price: product?.priceString,
|
||||||
package: product,
|
package: product,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +55,11 @@ export function usePurchaseOffering() {
|
|||||||
did,
|
did,
|
||||||
email,
|
email,
|
||||||
offering,
|
offering,
|
||||||
}: { did: string, email: string, offering: Offering }) {
|
}: {
|
||||||
|
did: string
|
||||||
|
email: string
|
||||||
|
offering: SubscriptionOffering
|
||||||
|
}) {
|
||||||
if (offering.platform === PlatformId.Web) {
|
if (offering.platform === PlatformId.Web) {
|
||||||
throw new Error('Unsupported platform')
|
throw new Error('Unsupported platform')
|
||||||
}
|
}
|
||||||
@@ -63,6 +75,6 @@ export function usePurchaseOffering() {
|
|||||||
|
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+21
-10
@@ -1,31 +1,38 @@
|
|||||||
import {Linking} from 'react-native'
|
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 {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) {
|
export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||||
return useQuery<{ offerings: Offering[] }>({
|
return useQuery<{offerings: SubscriptionOffering[]}>({
|
||||||
queryKey: ['subscription-group', group],
|
queryKey: ['subscription-group', group],
|
||||||
async queryFn() {
|
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) {
|
if (error || !data) {
|
||||||
throw new Error('Failed to fetch subscription group')
|
throw new Error('Failed to fetch subscription group')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { offerings } = data
|
const {offerings} = data
|
||||||
|
|
||||||
return {
|
return {
|
||||||
offerings: offerings.map((o: any) => ({
|
offerings: offerings.map((o: any) => ({
|
||||||
id: o.id as OfferingId,
|
id: parseOfferingId(o.id),
|
||||||
platform: PlatformId.Web,
|
platform: PlatformId.Web,
|
||||||
package: {
|
package: {
|
||||||
priceId: o.productId,
|
priceId: o.productId,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +42,11 @@ export function usePurchaseOffering() {
|
|||||||
did,
|
did,
|
||||||
email,
|
email,
|
||||||
offering,
|
offering,
|
||||||
}: { did: string, email: string, offering: Offering }) {
|
}: {
|
||||||
|
did: string
|
||||||
|
email: string
|
||||||
|
offering: SubscriptionOffering
|
||||||
|
}) {
|
||||||
if (offering.platform !== PlatformId.Web) {
|
if (offering.platform !== PlatformId.Web) {
|
||||||
throw new Error('Unsupported platform')
|
throw new Error('Unsupported platform')
|
||||||
}
|
}
|
||||||
@@ -59,6 +70,6 @@ export function usePurchaseOffering() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Linking.openURL(data.checkoutUrl)
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,10 +30,6 @@ import {
|
|||||||
Bell_Stroke2_Corner0_Rounded as Bell,
|
Bell_Stroke2_Corner0_Rounded as Bell,
|
||||||
} from '#/components/icons/Bell'
|
} from '#/components/icons/Bell'
|
||||||
import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList'
|
import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList'
|
||||||
import {
|
|
||||||
Gift1_Filled_Corner0_Rounded as GiftFilled,
|
|
||||||
Gift1_Stroke2_Corner0_Rounded as Gift,
|
|
||||||
} from '#/components/icons/Gift1'
|
|
||||||
import {
|
import {
|
||||||
Hashtag_Filled_Corner0_Rounded as HashtagFilled,
|
Hashtag_Filled_Corner0_Rounded as HashtagFilled,
|
||||||
Hashtag_Stroke2_Corner0_Rounded as Hashtag,
|
Hashtag_Stroke2_Corner0_Rounded as Hashtag,
|
||||||
@@ -207,11 +203,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
|||||||
setDrawerOpen(false)
|
setDrawerOpen(false)
|
||||||
}, [navigation, setDrawerOpen])
|
}, [navigation, setDrawerOpen])
|
||||||
|
|
||||||
const onPressSubscriptions = React.useCallback(() => {
|
|
||||||
navigation.navigate('Subscriptions')
|
|
||||||
setDrawerOpen(false)
|
|
||||||
}, [navigation, setDrawerOpen])
|
|
||||||
|
|
||||||
const onPressFeedback = React.useCallback(() => {
|
const onPressFeedback = React.useCallback(() => {
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
FEEDBACK_FORM_URL({
|
FEEDBACK_FORM_URL({
|
||||||
@@ -273,10 +264,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
|||||||
onPress={onPressProfile}
|
onPress={onPressProfile}
|
||||||
/>
|
/>
|
||||||
<SettingsMenuItem onPress={onPressSettings} />
|
<SettingsMenuItem onPress={onPressSettings} />
|
||||||
<SubscriptionsMenuItem
|
|
||||||
isActive={false}
|
|
||||||
onPress={onPressSubscriptions}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -546,31 +533,6 @@ let SettingsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
|
|||||||
}
|
}
|
||||||
SettingsMenuItem = React.memo(SettingsMenuItem)
|
SettingsMenuItem = React.memo(SettingsMenuItem)
|
||||||
|
|
||||||
let SubscriptionsMenuItem = ({
|
|
||||||
isActive,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
isActive: boolean
|
|
||||||
onPress: () => void
|
|
||||||
}): React.ReactNode => {
|
|
||||||
const {_} = useLingui()
|
|
||||||
const t = useTheme()
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
icon={
|
|
||||||
isActive ? (
|
|
||||||
<GiftFilled style={[t.atoms.text]} width={iconWidth} />
|
|
||||||
) : (
|
|
||||||
<Gift style={[t.atoms.text]} width={iconWidth} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
label={_(msg`Support Bluesky`)}
|
|
||||||
onPress={onPress}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SubscriptionsMenuItem = React.memo(SubscriptionsMenuItem)
|
|
||||||
|
|
||||||
function MenuItem({icon, label, count, bold, onPress}: MenuItemProps) {
|
function MenuItem({icon, label, count, bold, onPress}: MenuItemProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
|
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {useKawaiiMode} from '#/state/preferences/kawaii'
|
import {useKawaiiMode} from '#/state/preferences/kawaii'
|
||||||
import {useEntitlements} from '#/state/purchases/subscriptions/useEntitlements'
|
import {usePurchases} from '#/state/purchases'
|
||||||
|
import {EntitlementId} from '#/state/purchases/types'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {DesktopFeeds} from '#/view/shell/desktop/Feeds'
|
import {DesktopFeeds} from '#/view/shell/desktop/Feeds'
|
||||||
import {DesktopSearch} from '#/view/shell/desktop/Search'
|
import {DesktopSearch} from '#/view/shell/desktop/Search'
|
||||||
@@ -27,8 +28,10 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {hasSession, currentAccount} = useSession()
|
const {hasSession, currentAccount} = useSession()
|
||||||
const subscriptionsDialogControl = useDialogControl()
|
const subscriptionsDialogControl = useDialogControl()
|
||||||
const {data: entitlements} = useEntitlements()
|
const purchases = usePurchases()
|
||||||
const isSubscribed = entitlements?.some(e => e.id === 'core')
|
const isSubscribed =
|
||||||
|
purchases.status === 'ready' &&
|
||||||
|
purchases.entitlements?.some(e => e.id === EntitlementId.Core)
|
||||||
|
|
||||||
const kawaii = useKawaiiMode()
|
const kawaii = useKawaiiMode()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user