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 '#/view/icons'
|
||||
|
||||
import Purchases from 'react-native-purchases'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import Purchases from 'react-native-purchases'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
@@ -45,6 +45,7 @@ import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
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 SessionProvider,
|
||||
@@ -133,37 +134,39 @@ function InnerApp() {
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<ComposerProvider>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</ComposerProvider>
|
||||
<PurchasesProvider>
|
||||
<ComposerProvider>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</ComposerProvider>
|
||||
</PurchasesProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</VideoVolumeProvider>
|
||||
@@ -178,15 +181,17 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([initPersistedState(), ensureGeolocationResolved(), Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG)]).then(() => {
|
||||
if (isIOS) {
|
||||
Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY})
|
||||
} else if (isAndroid) {
|
||||
Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY})
|
||||
}
|
||||
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(
|
||||
() => {
|
||||
setReady(true)
|
||||
},
|
||||
)
|
||||
|
||||
setReady(true)
|
||||
})
|
||||
if (isIOS) {
|
||||
Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY})
|
||||
} else if (isAndroid) {
|
||||
Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY})
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
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 SessionProvider,
|
||||
@@ -110,6 +111,8 @@ function InnerApp() {
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PurchasesProvider>
|
||||
|
||||
<ComposerProvider>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
@@ -139,6 +142,7 @@ function InnerApp() {
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</ComposerProvider>
|
||||
</PurchasesProvider>
|
||||
</QueryProvider>
|
||||
<ToastContainer />
|
||||
</React.Fragment>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
OfferingId,
|
||||
SubscriptionGroupId,
|
||||
} from '#/state/purchases/subscriptions/types'
|
||||
import {
|
||||
usePurchaseOffering,
|
||||
useSubscriptionGroup,
|
||||
} from '#/state/purchases/subscriptions/useSubscriptionGroup'
|
||||
import {parseOfferingId} from '#/state/purchases/subscriptions/util'
|
||||
} from '#/state/purchases/hooks/useSubscriptionGroup'
|
||||
import {
|
||||
SubscriptionGroupId,
|
||||
SubscriptionOfferingId,
|
||||
} from '#/state/purchases/types'
|
||||
import {parseOfferingId} from '#/state/purchases/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
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 {CheckThick_Stroke2_Corner0_Rounded as CheckThink} from '#/components/icons/Check'
|
||||
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 {Text} from '#/components/Typography'
|
||||
|
||||
@@ -30,20 +31,20 @@ export function BlueskyPlus({control}: {control: Dialog.DialogControlProps}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner />
|
||||
<DialogInner control={control} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner() {
|
||||
function DialogInner({control}: {control: Dialog.DialogControlProps}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
const copy = useCoreOfferingCopy()
|
||||
|
||||
const [offeringId, setOfferingId] = React.useState<OfferingId>(
|
||||
OfferingId.CoreAnnual,
|
||||
const [offeringId, setOfferingId] = React.useState<SubscriptionOfferingId>(
|
||||
SubscriptionOfferingId.CoreAnnual,
|
||||
)
|
||||
const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core)
|
||||
const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering()
|
||||
@@ -57,13 +58,29 @@ function DialogInner() {
|
||||
throw new Error('No offering')
|
||||
}
|
||||
|
||||
await purchaseOffering({
|
||||
did: currentAccount.did,
|
||||
email: currentAccount.email!,
|
||||
offering,
|
||||
control.close(async () => {
|
||||
try {
|
||||
await purchaseOffering({
|
||||
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) {
|
||||
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" />
|
||||
|
||||
<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>
|
||||
</Text>
|
||||
|
||||
@@ -99,7 +116,10 @@ function DialogInner() {
|
||||
values={[offeringId]}
|
||||
onChange={values => setOfferingId(parseOfferingId(values[0]))}
|
||||
style={[a.w_full, a.gap_sm, a.pt_lg]}>
|
||||
{[OfferingId.CoreMonthly, OfferingId.CoreAnnual].map(id => {
|
||||
{[
|
||||
SubscriptionOfferingId.CoreMonthly,
|
||||
SubscriptionOfferingId.CoreAnnual,
|
||||
].map(id => {
|
||||
return (
|
||||
<Toggle.Item key={id} name={id} label={_(msg`Monthly plan`)}>
|
||||
{({selected, hovered}) => (
|
||||
@@ -209,7 +229,18 @@ function DialogInner() {
|
||||
})}
|
||||
</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
|
||||
label={_(msg`Subscribe`)}
|
||||
variant="solid"
|
||||
@@ -217,7 +248,7 @@ function DialogInner() {
|
||||
size="large"
|
||||
onPress={onPressSubscribe}
|
||||
disabled={isPending}
|
||||
style={[a.overflow_hidden]}>
|
||||
style={[a.flex_1, a.overflow_hidden]}>
|
||||
<GradientFill gradient={tokens.gradients.nordic} />
|
||||
<ButtonText style={[t.atoms.text]}>
|
||||
<Trans>Subscribe</Trans>
|
||||
@@ -264,12 +295,12 @@ function useCoreOfferingCopy() {
|
||||
const {_} = useLingui()
|
||||
return React.useMemo(() => {
|
||||
return {
|
||||
[OfferingId.CoreMonthly]: {
|
||||
[SubscriptionOfferingId.CoreMonthly]: {
|
||||
title: _(msg`1 month`),
|
||||
price: _(msg`$8 / month`),
|
||||
discount: undefined,
|
||||
},
|
||||
[OfferingId.CoreAnnual]: {
|
||||
[SubscriptionOfferingId.CoreAnnual]: {
|
||||
title: _(msg`12 months`),
|
||||
price: _(msg`$72 / year`),
|
||||
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 {CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {useSubscriptionsState} from '#/state/purchases/subscriptions/useSubscriptionsState'
|
||||
import {useSyncPurchases} from '#/state/purchases/subscriptions/useSyncPurchases'
|
||||
import {useManageSubscription} from '#/state/purchases/subscriptions/useManageSubscription'
|
||||
import {Subscription} from '#/state/purchases/subscriptions/types'
|
||||
import {PurchasesState,usePurchases} from '#/state/purchases'
|
||||
import {useManageSubscription} from '#/state/purchases/hooks/useManageSubscription'
|
||||
import {SubscriptionGroupId} from '#/state/purchases/types'
|
||||
import {APISubscription} from '#/state/purchases/types'
|
||||
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 {useDialogControl} from '#/components/Dialog'
|
||||
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 {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {createStaticClick,InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InlineLinkText, createStaticClick} from '#/components/Link'
|
||||
|
||||
export type ScreenProps = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
@@ -27,22 +34,7 @@ export type ScreenProps = NativeStackScreenProps<
|
||||
|
||||
export function Subscriptions(_props: ScreenProps) {
|
||||
const {_} = useLingui()
|
||||
const {data: state, isLoading: isStateLoading} =
|
||||
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 })
|
||||
}
|
||||
}
|
||||
const purchases = usePurchases()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
@@ -50,43 +42,14 @@ export function Subscriptions(_props: ScreenProps) {
|
||||
|
||||
<Layout.Content>
|
||||
<CenteredView sideBorders={true} style={[a.util_screen_outer]}>
|
||||
<View style={[a.px_xl, a.py_xl, a.gap_lg]}>
|
||||
{isStateLoading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<>
|
||||
{isSubscribed ? (
|
||||
<CoreSubscriptions subscriptions={state!.subscriptions} />
|
||||
) : (
|
||||
<>
|
||||
<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 style={[a.px_xl, a.py_xl]}>
|
||||
{purchases.status === 'loading' ? (
|
||||
<Loader />
|
||||
) : purchases.status === 'error' ? (
|
||||
<View />
|
||||
) : (
|
||||
<Core state={purchases} />
|
||||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
</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 {_, i18n} = useLingui()
|
||||
const {mutateAsync: manageSubscription} = useManageSubscription()
|
||||
|
||||
return props.subscriptions.map(sub => (
|
||||
<View style={[a.p_lg, a.rounded_md, a.border, a.gap_xs, t.atoms.border_contrast_low]}>
|
||||
<Text style={[a.text_lg, a.font_heavy]}>Bluesky+</Text>
|
||||
return props.subscriptions.map(sub => {
|
||||
const endDate = i18n.date(new Date(sub.periodEndsAt), {dateStyle: 'medium'})
|
||||
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 ? (
|
||||
<Text style={[a.text_sm]}>
|
||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}><Trans>Renews:</Trans></Text>{' '}
|
||||
<Text style={[a.text_sm]}>{i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium", timeStyle: "medium" })}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[a.text_sm]}>
|
||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}><Trans>Ends:</Trans></Text>{' '}
|
||||
<Text style={[a.text_sm]}>{i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium" })}</Text>
|
||||
</Text>
|
||||
)}
|
||||
<View style={[a.flex_row, a.justify_between, a.align_center]}>
|
||||
<View>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
Active
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<InlineLinkText
|
||||
label={_('Manage subscription')}
|
||||
{...createStaticClick(() => {manageSubscription()})}
|
||||
>
|
||||
<Trans>Manage subscription</Trans>
|
||||
</InlineLinkText>
|
||||
</View>
|
||||
))
|
||||
<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]}>{endDate}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Link
|
||||
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 {useMutation} from '@tanstack/react-query'
|
||||
import Purchases from 'react-native-purchases'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
export function useManageSubscription() {
|
||||
return useMutation({
|
||||
+6
-4
@@ -1,9 +1,9 @@
|
||||
import {Linking} from 'react-native'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {IS_DEV} from '#/env'
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {useSession} from '#/state/session'
|
||||
import {api} from '#/state/purchases/subscriptions/api'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
export function useManageSubscription() {
|
||||
const {currentAccount} = useSession()
|
||||
@@ -16,8 +16,10 @@ export function useManageSubscription() {
|
||||
method: 'POST',
|
||||
json: {
|
||||
did: currentAccount!.did,
|
||||
redirecturl: IS_DEV ? 'http://localhost:19006/subscriptions' : 'https://bsky.app/subscriptions',
|
||||
}
|
||||
redirecturl: IS_DEV
|
||||
? 'http://localhost:19006/subscriptions'
|
||||
: 'https://bsky.app/subscriptions',
|
||||
},
|
||||
}).json()
|
||||
|
||||
if (error || !data) {
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import Purchases, {CustomerInfo} from 'react-native-purchases'
|
||||
|
||||
export function useNativeEventsListener({
|
||||
onCustomerInfoUpdated,
|
||||
}: {
|
||||
onCustomerInfoUpdated: (info: CustomerInfo) => void
|
||||
}) {
|
||||
React.useEffect(() => {
|
||||
Purchases.addCustomerInfoUpdateListener(onCustomerInfoUpdated)
|
||||
return () => {
|
||||
Purchases.removeCustomerInfoUpdateListener(onCustomerInfoUpdated)
|
||||
}
|
||||
}, [onCustomerInfoUpdated])
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export function useNativeEventsListener() {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||
|
||||
import {NativePurchaseRestricted} from '#/state/purchases/context'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export function useNativeUserState() {
|
||||
const {currentAccount} = useSession()
|
||||
const [restricted, setRestricted] =
|
||||
React.useState<NativePurchaseRestricted>('unknown')
|
||||
|
||||
React.useEffect(() => {
|
||||
async function check() {
|
||||
if (!currentAccount?.did) return
|
||||
|
||||
try {
|
||||
// MUST ensure we're the correct user first
|
||||
await Purchases.logIn(currentAccount?.did)
|
||||
await Purchases.restorePurchases()
|
||||
setRestricted('no')
|
||||
} catch (e: any) {
|
||||
/**
|
||||
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
|
||||
*/
|
||||
if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) {
|
||||
setRestricted('yes')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
}, [currentAccount?.did])
|
||||
|
||||
return {
|
||||
restricted,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function useNativeUserState() {
|
||||
return {
|
||||
restricted: false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {APIEntitlement,APISubscription} from '#/state/purchases/types'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export const rootPurchasesStateQueryKey = 'subscriptions-state'
|
||||
export const createPurchasesStateQueryKey = (did?: string) => [
|
||||
rootPurchasesStateQueryKey,
|
||||
did,
|
||||
]
|
||||
|
||||
export function usePurchasesState() {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return useQuery({
|
||||
enabled: !!currentAccount,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 1000 * 60 * 2,
|
||||
queryKey: createPurchasesStateQueryKey(currentAccount?.did),
|
||||
async queryFn() {
|
||||
const {data, error} = await api<{
|
||||
email: string
|
||||
subscriptions: APISubscription[]
|
||||
entitlements: APIEntitlement[]
|
||||
}>(`/account?did=${currentAccount!.did}`).json()
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error('Failed to fetch subscriptions state')
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
+25
-13
@@ -1,23 +1,29 @@
|
||||
import React from 'react'
|
||||
import {useQuery, useMutation} from '@tanstack/react-query'
|
||||
import Purchases, { PURCHASES_ERROR_CODE } from 'react-native-purchases'
|
||||
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||
import {useMutation,useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {api} from '#/state/purchases/subscriptions/api'
|
||||
import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types'
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {
|
||||
parseOfferingId,
|
||||
PlatformId,
|
||||
SubscriptionGroupId,
|
||||
SubscriptionOffering,
|
||||
} from '#/state/purchases/types'
|
||||
|
||||
export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||
return useQuery<{ offerings: Offering[] }>({
|
||||
return useQuery<{offerings: SubscriptionOffering[]}>({
|
||||
queryKey: ['subscription-group', group],
|
||||
async queryFn() {
|
||||
const platform = isIOS ? 'ios' : 'android'
|
||||
const { data, error, response } = await api(`/subscriptions/${group}?platform=${platform}`).json()
|
||||
const {data, error, response} = await api(
|
||||
`/subscriptions/${group}?platform=${platform}`,
|
||||
).json()
|
||||
if (error || !data) {
|
||||
console.log(error, response)
|
||||
throw new Error('Failed to fetch subscription group')
|
||||
}
|
||||
|
||||
const { offerings } = data
|
||||
const {offerings} = data
|
||||
const productIds = offerings.map((o: any) => {
|
||||
return isIOS ? o.productId : o.productId.split(':')[0]
|
||||
})
|
||||
@@ -28,16 +34,18 @@ export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||
|
||||
return {
|
||||
offerings: offerings.map((o: any) => {
|
||||
const product = products.find((p: any) => p.identifier === o.productId)
|
||||
const product = products.find(
|
||||
(p: any) => p.identifier === o.productId,
|
||||
)
|
||||
return {
|
||||
id: o.id as OfferingId,
|
||||
id: parseOfferingId(o.id),
|
||||
platform: isIOS ? PlatformId.Ios : PlatformId.Android,
|
||||
price: product?.priceString,
|
||||
package: product,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,7 +55,11 @@ export function usePurchaseOffering() {
|
||||
did,
|
||||
email,
|
||||
offering,
|
||||
}: { did: string, email: string, offering: Offering }) {
|
||||
}: {
|
||||
did: string
|
||||
email: string
|
||||
offering: SubscriptionOffering
|
||||
}) {
|
||||
if (offering.platform === PlatformId.Web) {
|
||||
throw new Error('Unsupported platform')
|
||||
}
|
||||
@@ -63,6 +75,6 @@ export function usePurchaseOffering() {
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
+21
-10
@@ -1,31 +1,38 @@
|
||||
import {Linking} from 'react-native'
|
||||
import {useQuery, useMutation} from '@tanstack/react-query'
|
||||
import {useMutation,useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {
|
||||
parseOfferingId,
|
||||
PlatformId,
|
||||
SubscriptionGroupId,
|
||||
SubscriptionOffering,
|
||||
} from '#/state/purchases/types'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {api} from '#/state/purchases/subscriptions/api'
|
||||
import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types'
|
||||
|
||||
export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||
return useQuery<{ offerings: Offering[] }>({
|
||||
return useQuery<{offerings: SubscriptionOffering[]}>({
|
||||
queryKey: ['subscription-group', group],
|
||||
async queryFn() {
|
||||
const { data, error } = await api(`/subscriptions/${group}?platform=web`).json()
|
||||
const {data, error} = await api(
|
||||
`/subscriptions/${group}?platform=web`,
|
||||
).json()
|
||||
if (error || !data) {
|
||||
throw new Error('Failed to fetch subscription group')
|
||||
}
|
||||
|
||||
const { offerings } = data
|
||||
const {offerings} = data
|
||||
|
||||
return {
|
||||
offerings: offerings.map((o: any) => ({
|
||||
id: o.id as OfferingId,
|
||||
id: parseOfferingId(o.id),
|
||||
platform: PlatformId.Web,
|
||||
package: {
|
||||
priceId: o.productId,
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,7 +42,11 @@ export function usePurchaseOffering() {
|
||||
did,
|
||||
email,
|
||||
offering,
|
||||
}: { did: string, email: string, offering: Offering }) {
|
||||
}: {
|
||||
did: string
|
||||
email: string
|
||||
offering: SubscriptionOffering
|
||||
}) {
|
||||
if (offering.platform !== PlatformId.Web) {
|
||||
throw new Error('Unsupported platform')
|
||||
}
|
||||
@@ -59,6 +70,6 @@ export function usePurchaseOffering() {
|
||||
}
|
||||
|
||||
Linking.openURL(data.checkoutUrl)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
|
||||
import {Context, PurchasesState} from '#/state/purchases/context'
|
||||
import {useNativeEventsListener} from '#/state/purchases/hooks/useNativeEventsListener'
|
||||
import {useNativeUserState} from '#/state/purchases/hooks/useNativeUserState'
|
||||
import {usePurchasesState} from '#/state/purchases/hooks/usePurchasesState'
|
||||
|
||||
export type {PurchasesState} from '#/state/purchases/context'
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const {
|
||||
data: purchases,
|
||||
error: purchasesStateError,
|
||||
refetch,
|
||||
} = usePurchasesState()
|
||||
const {restricted} = useNativeUserState()
|
||||
const ctx = React.useMemo<PurchasesState>(() => {
|
||||
if (purchasesStateError) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: purchasesStateError,
|
||||
}
|
||||
} else if (!purchases) {
|
||||
return {
|
||||
status: 'loading',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: 'ready',
|
||||
email: purchases?.email,
|
||||
subscriptions: purchases?.subscriptions ?? [],
|
||||
entitlements: purchases?.entitlements ?? [],
|
||||
config: {
|
||||
nativePurchaseRestricted: restricted,
|
||||
},
|
||||
}
|
||||
}
|
||||
}, [purchases, purchasesStateError, restricted])
|
||||
|
||||
useNativeEventsListener({
|
||||
onCustomerInfoUpdated() {
|
||||
refetch()
|
||||
},
|
||||
})
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function usePurchases() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { PurchasesStoreProduct } from 'react-native-purchases'
|
||||
|
||||
export enum EntitlementId {
|
||||
Core = 'core',
|
||||
}
|
||||
|
||||
export enum PlatformId {
|
||||
Android = 'android',
|
||||
Ios = 'ios',
|
||||
Web = 'web',
|
||||
}
|
||||
|
||||
export enum SubscriptionGroupId {
|
||||
Core = 'core',
|
||||
}
|
||||
|
||||
export enum OfferingId {
|
||||
CoreMonthly = 'coreMonthly',
|
||||
CoreAnnual = 'coreAnnual',
|
||||
}
|
||||
|
||||
export type Offering =
|
||||
| {
|
||||
id: OfferingId
|
||||
platform: PlatformId.Ios | PlatformId.Android
|
||||
price: number
|
||||
package: PurchasesStoreProduct
|
||||
}
|
||||
| {
|
||||
id: OfferingId
|
||||
platform: PlatformId.Web
|
||||
price: number
|
||||
package: {
|
||||
priceId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Subscription = {
|
||||
group: SubscriptionGroupId
|
||||
platform: PlatformId
|
||||
renews: boolean
|
||||
periodDtartsAt: string
|
||||
periodEndsAt: string
|
||||
purchasedAt: string
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {api} from '#/state/purchases/subscriptions/api'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
export function useCreateCheckout() {
|
||||
return useMutation({
|
||||
async mutationFn(props: {price: string; did: string; email: string}) {
|
||||
const {data, error} = await api<{
|
||||
checkoutUrl: string
|
||||
}>('/checkout/create', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
...props,
|
||||
redirectUrl: IS_DEV
|
||||
? `http://localhost:19006/subscriptions`
|
||||
: `https://bsky.app/subscriptions`,
|
||||
},
|
||||
}).json()
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {api} from '#/state/purchases/subscriptions/api'
|
||||
|
||||
export function useEntitlements() {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return useQuery({
|
||||
enabled: !!currentAccount,
|
||||
queryKey: ['entitlements', currentAccount?.did],
|
||||
refetchOnWindowFocus: true,
|
||||
async queryFn() {
|
||||
const params = new URLSearchParams('/entitlements')
|
||||
params.set('did', currentAccount!.did)
|
||||
const url = `/account?${params.toString()}`
|
||||
const {data, error} = await api<{
|
||||
entitlements: {id: 'core'; platform: 'web'}[]
|
||||
}>(url).json()
|
||||
|
||||
if (error) {
|
||||
return []
|
||||
}
|
||||
|
||||
return data?.entitlements
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {api} from '#/state/purchases/subscriptions/api'
|
||||
import {SubscriptionGroupId, PlatformId, Subscription} from '#/state/purchases/subscriptions/types'
|
||||
|
||||
export function useSubscriptionsState() {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return useQuery({
|
||||
enabled: !!currentAccount,
|
||||
queryKey: ['subscriptions-state', currentAccount?.did],
|
||||
refetchOnWindowFocus: true,
|
||||
async queryFn() {
|
||||
const url = `/account?did=${currentAccount!.did}`
|
||||
const {data, error} = await api<{
|
||||
subscriptions: Subscription[]
|
||||
entitlements: {id: 'core'; platform: 'web'}[]
|
||||
}>(url).json()
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error('Failed to fetch subscriptions state')
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import Purchases, { PURCHASES_ERROR_CODE } from 'react-native-purchases'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export function useSyncPurchases() {
|
||||
const {currentAccount} = useSession()
|
||||
return useMutation({
|
||||
async mutationFn() {
|
||||
if (!currentAccount) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
|
||||
try {
|
||||
await Purchases.logIn(currentAccount.did)
|
||||
return await Purchases.restorePurchases()
|
||||
} catch (e: any) {
|
||||
if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) {
|
||||
console.log('recipt error', e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import {OfferingId} from '#/state/purchases/subscriptions/types';
|
||||
|
||||
export function parseOfferingId(id: string): OfferingId {
|
||||
switch (id) {
|
||||
case 'coreMonthly':
|
||||
return OfferingId.CoreMonthly;
|
||||
case 'coreAnnual':
|
||||
return OfferingId.CoreAnnual;
|
||||
default:
|
||||
throw new Error(`Unknown offering id: ${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {PurchasesStoreProduct} from 'react-native-purchases'
|
||||
|
||||
/**
|
||||
* Primitives
|
||||
*/
|
||||
|
||||
export enum EntitlementId {
|
||||
Core = 'core',
|
||||
}
|
||||
|
||||
export enum PlatformId {
|
||||
Android = 'android',
|
||||
Ios = 'ios',
|
||||
Web = 'web',
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription primitives
|
||||
*/
|
||||
|
||||
export enum SubscriptionGroupId {
|
||||
Core = 'core',
|
||||
}
|
||||
|
||||
export enum SubscriptionOfferingId {
|
||||
CoreMonthly = 'coreMonthly',
|
||||
CoreAnnual = 'coreAnnual',
|
||||
}
|
||||
|
||||
export type SubscriptionOffering =
|
||||
| {
|
||||
id: SubscriptionOfferingId
|
||||
platform: PlatformId.Ios | PlatformId.Android
|
||||
package: PurchasesStoreProduct
|
||||
}
|
||||
| {
|
||||
id: SubscriptionOfferingId
|
||||
platform: PlatformId.Web
|
||||
package: {
|
||||
priceId: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API types for our toy server
|
||||
*/
|
||||
|
||||
export type APISubscription = {
|
||||
group: SubscriptionGroupId
|
||||
platform: PlatformId
|
||||
renews: boolean
|
||||
periodDtartsAt: string
|
||||
periodEndsAt: string
|
||||
purchasedAt: string
|
||||
}
|
||||
|
||||
export type APIEntitlement = {
|
||||
id: EntitlementId
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsers
|
||||
*/
|
||||
|
||||
export function parseOfferingId(id: string): SubscriptionOfferingId {
|
||||
switch (id) {
|
||||
case 'coreMonthly':
|
||||
return SubscriptionOfferingId.CoreMonthly
|
||||
case 'coreAnnual':
|
||||
return SubscriptionOfferingId.CoreAnnual
|
||||
default:
|
||||
throw new Error(`Unknown offering id: ${id}`)
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,6 @@ import {
|
||||
Bell_Stroke2_Corner0_Rounded as Bell,
|
||||
} from '#/components/icons/Bell'
|
||||
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 {
|
||||
Hashtag_Filled_Corner0_Rounded as HashtagFilled,
|
||||
Hashtag_Stroke2_Corner0_Rounded as Hashtag,
|
||||
@@ -207,11 +203,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
||||
setDrawerOpen(false)
|
||||
}, [navigation, setDrawerOpen])
|
||||
|
||||
const onPressSubscriptions = React.useCallback(() => {
|
||||
navigation.navigate('Subscriptions')
|
||||
setDrawerOpen(false)
|
||||
}, [navigation, setDrawerOpen])
|
||||
|
||||
const onPressFeedback = React.useCallback(() => {
|
||||
Linking.openURL(
|
||||
FEEDBACK_FORM_URL({
|
||||
@@ -273,10 +264,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
||||
onPress={onPressProfile}
|
||||
/>
|
||||
<SettingsMenuItem onPress={onPressSettings} />
|
||||
<SubscriptionsMenuItem
|
||||
isActive={false}
|
||||
onPress={onPressSubscriptions}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -546,31 +533,6 @@ let SettingsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
|
||||
}
|
||||
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) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,8 @@ import {useLingui} from '@lingui/react'
|
||||
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
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 {DesktopFeeds} from '#/view/shell/desktop/Feeds'
|
||||
import {DesktopSearch} from '#/view/shell/desktop/Search'
|
||||
@@ -27,8 +28,10 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
const {_} = useLingui()
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const subscriptionsDialogControl = useDialogControl()
|
||||
const {data: entitlements} = useEntitlements()
|
||||
const isSubscribed = entitlements?.some(e => e.id === 'core')
|
||||
const purchases = usePurchases()
|
||||
const isSubscribed =
|
||||
purchases.status === 'ready' &&
|
||||
purchases.entitlements?.some(e => e.id === EntitlementId.Core)
|
||||
|
||||
const kawaii = useKawaiiMode()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user