Bunch of UI
This commit is contained in:
@@ -41,7 +41,7 @@ const GroupContext = React.createContext<{
|
|||||||
disabled: boolean
|
disabled: boolean
|
||||||
type: 'radio' | 'checkbox'
|
type: 'radio' | 'checkbox'
|
||||||
maxSelectionsReached: boolean
|
maxSelectionsReached: boolean
|
||||||
setFieldValue: (props: {name: string; value: boolean}) => void
|
setFieldValue: (props: {name: any; value: boolean}) => void
|
||||||
}>({
|
}>({
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
values: [],
|
values: [],
|
||||||
@@ -50,12 +50,12 @@ const GroupContext = React.createContext<{
|
|||||||
setFieldValue: () => {},
|
setFieldValue: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export type GroupProps = React.PropsWithChildren<{
|
export type GroupProps<V> = React.PropsWithChildren<{
|
||||||
type?: 'radio' | 'checkbox'
|
type?: 'radio' | 'checkbox'
|
||||||
values: string[]
|
values: V[]
|
||||||
maxSelections?: number
|
maxSelections?: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onChange: (value: string[]) => void
|
onChange: (value: V[]) => void
|
||||||
label: string
|
label: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export function useItemContext() {
|
|||||||
return React.useContext(ItemContext)
|
return React.useContext(ItemContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Group({
|
export function Group<V extends string>({
|
||||||
children,
|
children,
|
||||||
values: providedValues,
|
values: providedValues,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -82,13 +82,13 @@ export function Group({
|
|||||||
type = 'checkbox',
|
type = 'checkbox',
|
||||||
maxSelections,
|
maxSelections,
|
||||||
label,
|
label,
|
||||||
}: GroupProps) {
|
}: GroupProps<V>) {
|
||||||
const groupRole = type === 'radio' ? 'radiogroup' : undefined
|
const groupRole = type === 'radio' ? 'radiogroup' : undefined
|
||||||
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
|
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
|
||||||
const [maxReached, setMaxReached] = React.useState(false)
|
const [maxReached, setMaxReached] = React.useState(false)
|
||||||
|
|
||||||
const setFieldValue = React.useCallback<
|
const setFieldValue = React.useCallback<
|
||||||
(props: {name: string; value: boolean}) => void
|
(props: {name: V; value: boolean}) => void
|
||||||
>(
|
>(
|
||||||
({name, value}) => {
|
({name, value}) => {
|
||||||
if (type === 'checkbox') {
|
if (type === 'checkbox') {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {TierId} from '#/state/purchases/subscriptions/types'
|
||||||
|
|
||||||
|
export function useMainSubscriptionTiersCopy() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return {
|
||||||
|
[TierId.Main0]: {
|
||||||
|
title: _(msg`Bluesky Supporter`),
|
||||||
|
},
|
||||||
|
[TierId.Main1]: {
|
||||||
|
title: _(msg`Bluesky Friend`),
|
||||||
|
},
|
||||||
|
[TierId.Main2]: {
|
||||||
|
title: _(msg`Bluesky Besty`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,38 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
|
import {isAndroid, isIOS} from '#/platform/detection'
|
||||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||||
import {
|
import {
|
||||||
useMainSubscriptions,
|
useMainSubscriptions,
|
||||||
usePurchaseSubscription,
|
usePurchaseSubscription,
|
||||||
|
useChangeSubscription,
|
||||||
|
useCancelSubscription,
|
||||||
} from '#/state/purchases/subscriptions'
|
} from '#/state/purchases/subscriptions'
|
||||||
// import {useEntitlements} from '#/state/purchases/subscriptions/useEntitlements'
|
// import {useEntitlements} from '#/state/purchases/subscriptions/useEntitlements'
|
||||||
import {Subscriptions as SubscriptionsResponse} from '#/state/purchases/subscriptions/types'
|
import {
|
||||||
|
SubscriptionTier,
|
||||||
|
Subscription,
|
||||||
|
SubscriptionId,
|
||||||
|
TierId,
|
||||||
|
} from '#/state/purchases/subscriptions/types'
|
||||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||||
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, useTheme, useBreakpoints, tokens} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useMainSubscriptionTiersCopy} from '#/components/subscriptions/localization'
|
||||||
|
import {GradientFill} from '#/components/GradientFill'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {Admonition} from '#/components/Admonition'
|
||||||
|
|
||||||
export type ScreenProps = NativeStackScreenProps<
|
export type ScreenProps = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -28,16 +43,29 @@ export function Subscriptions(_props: ScreenProps) {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
// const {data: entitlements} = useEntitlements()
|
// const {data: entitlements} = useEntitlements()
|
||||||
const {data: subscriptions, isError, error} = useMainSubscriptions()
|
const {data: subscriptions, isError} = useMainSubscriptions()
|
||||||
|
const activeSubscription = React.useMemo(() => {
|
||||||
if (error) {
|
if (!subscriptions) return
|
||||||
console.log(error)
|
for (const sub of subscriptions) {
|
||||||
}
|
if (sub.monthly.subscription.state?.active) {
|
||||||
|
return sub.monthly
|
||||||
|
} else if (sub.annual.subscription.state?.active) {
|
||||||
|
return sub.annual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [subscriptions])
|
||||||
|
const currentPlatform = isAndroid ? 'android' : isIOS ? 'ios' : 'web'
|
||||||
|
const activeSubscriptionPlatform =
|
||||||
|
activeSubscription?.subscription?.state?.platform
|
||||||
|
const isManageable =
|
||||||
|
!activeSubscriptionPlatform ||
|
||||||
|
activeSubscriptionPlatform === currentPlatform
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Screen>
|
<Layout.Screen>
|
||||||
<CenteredView sideBorders={true}>
|
<CenteredView sideBorders={true}>
|
||||||
<ViewHeader title={_(msg`Subscriptions`)} />
|
<ViewHeader title={_(msg`Subscriptions`)} />
|
||||||
|
|
||||||
<View style={[a.px_xl, a.py_xl, a.gap_lg]}>
|
<View style={[a.px_xl, a.py_xl, a.gap_lg]}>
|
||||||
<Text style={[a.text_2xl]}>Subs</Text>
|
<Text style={[a.text_2xl]}>Subs</Text>
|
||||||
|
|
||||||
@@ -46,7 +74,26 @@ export function Subscriptions(_props: ScreenProps) {
|
|||||||
) : !subscriptions ? (
|
) : !subscriptions ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<MainSubscription subscriptions={subscriptions} />
|
<>
|
||||||
|
{subscriptions.map(s => (
|
||||||
|
<SubscriptionCard
|
||||||
|
key={s.id}
|
||||||
|
subscription={s}
|
||||||
|
activeSubscription={activeSubscription}
|
||||||
|
isManageable={isManageable}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isManageable ? (
|
||||||
|
<Admonition type="info">
|
||||||
|
<Trans>
|
||||||
|
You purchased this subscription on{' '}
|
||||||
|
{activeSubscriptionPlatform}. Please access this screen on{' '}
|
||||||
|
{activeSubscriptionPlatform} to manage your plan.
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
@@ -54,95 +101,282 @@ export function Subscriptions(_props: ScreenProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MainSubscription({
|
export function SubscriptionCard({
|
||||||
subscriptions,
|
subscription: s,
|
||||||
|
activeSubscription,
|
||||||
|
isManageable,
|
||||||
}: {
|
}: {
|
||||||
subscriptions: SubscriptionsResponse
|
subscription: SubscriptionTier
|
||||||
|
activeSubscription?: Subscription
|
||||||
|
isManageable: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {i18n} = useLingui()
|
||||||
|
const copy = useMainSubscriptionTiersCopy()
|
||||||
|
|
||||||
const active = subscriptions.active.at(0)
|
const activePlan = React.useMemo(() => {
|
||||||
const {mutateAsync: purchaseSubscription} = usePurchaseSubscription()
|
if (s.monthly.subscription.state?.active) {
|
||||||
|
return s.monthly
|
||||||
|
} else if (s.annual.subscription.state?.active) {
|
||||||
|
return s.annual
|
||||||
|
}
|
||||||
|
}, [s])
|
||||||
|
const isMonthly = s.monthly.subscription.state?.active
|
||||||
|
const isAnnual = s.annual.subscription.state?.active
|
||||||
|
const isActive = isMonthly || isAnnual
|
||||||
|
|
||||||
if (active) {
|
const gradient = React.useMemo(() => {
|
||||||
const contents = (
|
return {
|
||||||
<View style={[a.gap_sm]}>
|
[TierId.Main0]: tokens.gradients.sky,
|
||||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
[TierId.Main1]: tokens.gradients.sunrise,
|
||||||
<Text style={[a.text_xl, a.font_heavy]}>Bluesky Plus</Text>
|
[TierId.Main2]: tokens.gradients.bonfire,
|
||||||
<Text
|
}[s.id]
|
||||||
|
}, [s.id])
|
||||||
|
|
||||||
|
const control = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.w_full]}>
|
||||||
|
<Button
|
||||||
|
disabled={!isManageable}
|
||||||
|
label={copy[s.id].title}
|
||||||
|
style={[a.w_full]}
|
||||||
|
onPress={() => control.open()}>
|
||||||
|
{ctx => (
|
||||||
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.text_md,
|
a.w_full,
|
||||||
a.font_bold,
|
a.rounded_md,
|
||||||
a.p_xs,
|
a.overflow_hidden,
|
||||||
a.rounded_xs,
|
a.border,
|
||||||
{
|
ctx.hovered
|
||||||
color: t.palette.white,
|
? t.atoms.border_contrast_high
|
||||||
backgroundColor: t.palette.primary_500,
|
: t.atoms.border_contrast_low,
|
||||||
},
|
|
||||||
]}>
|
]}>
|
||||||
Supporter
|
<View
|
||||||
</Text>
|
style={[
|
||||||
</View>
|
a.relative,
|
||||||
|
a.w_full,
|
||||||
<Text style={[a.text_md]}>Managed via: {active?.platform}</Text>
|
{
|
||||||
</View>
|
paddingTop: '10%',
|
||||||
)
|
},
|
||||||
|
]}>
|
||||||
return (
|
<GradientFill gradient={gradient} />
|
||||||
<View
|
{isActive && (
|
||||||
style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
|
<View
|
||||||
{contents}
|
style={[
|
||||||
</View>
|
a.absolute,
|
||||||
)
|
a.inset_0,
|
||||||
} else {
|
a.align_center,
|
||||||
return (
|
a.justify_center,
|
||||||
<View
|
a.pr_lg,
|
||||||
style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
|
{
|
||||||
<View style={[a.gap_sm, a.pb_xl]}>
|
left: 'auto',
|
||||||
{subscriptions.available.monthly.map(s => (
|
},
|
||||||
<View key={s.subscription.id}>
|
]}>
|
||||||
<Button
|
<Check fill="white" />
|
||||||
label={s.subscription.id}
|
</View>
|
||||||
size="small"
|
)}
|
||||||
variant="solid"
|
|
||||||
color={
|
|
||||||
s.subscription.state?.status === 'active'
|
|
||||||
? 'primary'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
onPress={() => {
|
|
||||||
purchaseSubscription(s)
|
|
||||||
}}>
|
|
||||||
<ButtonText>
|
|
||||||
{s.subscription.id} ({s.price?.formatted})
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
<View style={[a.p_lg]}>
|
||||||
</View>
|
<Text style={[a.text_2xl, a.font_heavy]}>{copy[s.id].title}</Text>
|
||||||
<View style={[a.gap_sm]}>
|
|
||||||
{subscriptions.available.annual.map(s => (
|
{activePlan && activePlan.subscription.state && (
|
||||||
<View key={s.subscription.id}>
|
<View style={[a.gap_xs, a.pt_md]}>
|
||||||
<Button
|
<Text style={[a.text_md, a.font_bold]}>Current</Text>
|
||||||
label={s.subscription.id}
|
<Text>• {activePlan.subscription.interval}</Text>
|
||||||
size="small"
|
<Text>
|
||||||
variant="solid"
|
• renews{' '}
|
||||||
color={
|
{i18n.date(
|
||||||
s.subscription.state?.status === 'active'
|
new Date(activePlan.subscription.state?.periodEnd),
|
||||||
? 'primary'
|
{dateStyle: 'medium'},
|
||||||
: 'secondary'
|
)}
|
||||||
}
|
</Text>
|
||||||
onPress={() => {
|
</View>
|
||||||
purchaseSubscription(s)
|
)}
|
||||||
}}>
|
|
||||||
<ButtonText>
|
{isManageable && (
|
||||||
{s.subscription.id} ({s.price?.formatted})
|
<>
|
||||||
</ButtonText>
|
<Divider style={[a.my_md]} />
|
||||||
</Button>
|
|
||||||
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
|
{s.annual.price && s.monthly.price ? (
|
||||||
|
<Text>
|
||||||
|
<Trans>
|
||||||
|
<Text style={[a.text_xl, a.font_heavy]}>
|
||||||
|
{s.monthly.price.formatted}
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_sm, a.font_bold]}>
|
||||||
|
/month{' • '}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.font_normal,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>or {s.annual.price?.formatted}/year</Trans>
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
</View>
|
||||||
</View>
|
)}
|
||||||
</View>
|
</Button>
|
||||||
)
|
|
||||||
}
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<SubscriptionDialog
|
||||||
|
subscription={s}
|
||||||
|
activeSubscription={activeSubscription}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionDialog({
|
||||||
|
subscription: s,
|
||||||
|
activeSubscription,
|
||||||
|
}: {
|
||||||
|
subscription: SubscriptionTier
|
||||||
|
activeSubscription?: Subscription
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {close} = Dialog.useDialogContext()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const copy = useMainSubscriptionTiersCopy()
|
||||||
|
|
||||||
|
const activeSubscriptionId = activeSubscription?.id
|
||||||
|
const tierSubscriptions = [s.monthly, s.annual]
|
||||||
|
console.log('tierSubscriptions', tierSubscriptions)
|
||||||
|
const initiallySelectedSubscription = tierSubscriptions.find(
|
||||||
|
s => s.id === activeSubscriptionId,
|
||||||
|
)
|
||||||
|
const isTierActive = !!initiallySelectedSubscription
|
||||||
|
const [values, setValues] = React.useState<SubscriptionId[]>(
|
||||||
|
initiallySelectedSubscription ? [initiallySelectedSubscription.id] : [],
|
||||||
|
)
|
||||||
|
const selectedSubscription = tierSubscriptions.find(s => s.id === values[0])
|
||||||
|
|
||||||
|
const isEditing =
|
||||||
|
activeSubscription && selectedSubscription?.id !== activeSubscription.id
|
||||||
|
const purchaseDisabled =
|
||||||
|
!selectedSubscription ||
|
||||||
|
(activeSubscription && selectedSubscription?.id === activeSubscription.id)
|
||||||
|
|
||||||
|
const {mutateAsync: purchaseSubscription, isPending: isPurchasePending} =
|
||||||
|
usePurchaseSubscription()
|
||||||
|
const {mutateAsync: changeSubscription, isPending: isChangePending} =
|
||||||
|
useChangeSubscription()
|
||||||
|
const {mutateAsync: cancelSubscription, isPending: isCancelPending} =
|
||||||
|
useCancelSubscription()
|
||||||
|
const purchase = React.useCallback(async () => {
|
||||||
|
if (selectedSubscription) {
|
||||||
|
console.log(selectedSubscription)
|
||||||
|
await purchaseSubscription(selectedSubscription)
|
||||||
|
}
|
||||||
|
}, [selectedSubscription, purchaseSubscription])
|
||||||
|
const change = React.useCallback(async () => {
|
||||||
|
const prev = activeSubscription
|
||||||
|
const next = selectedSubscription
|
||||||
|
|
||||||
|
if (!prev || !next) {
|
||||||
|
throw new Error('Invalid subscription change')
|
||||||
|
}
|
||||||
|
|
||||||
|
await changeSubscription({
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
}, [activeSubscription, selectedSubscription, close])
|
||||||
|
const cancel = React.useCallback(async () => {
|
||||||
|
if (activeSubscription) {
|
||||||
|
await cancelSubscription(activeSubscription)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}, [activeSubscription, cancelSubscription, close])
|
||||||
|
const action = isEditing ? change : purchase
|
||||||
|
const isPending = isPurchasePending || isChangePending
|
||||||
|
const canCancel = isTierActive && activeSubscription?.platform === 'web'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={copy[s.id].title}
|
||||||
|
style={[
|
||||||
|
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_heavy, a.pb_md]}>
|
||||||
|
{copy[s.id].title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Toggle.Group
|
||||||
|
label={_(msg`Select plan`)}
|
||||||
|
type="radio"
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}>
|
||||||
|
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
|
||||||
|
{tierSubscriptions.map(s => (
|
||||||
|
<Toggle.Item
|
||||||
|
key={s.id}
|
||||||
|
label={_(msg`Billed annually`)}
|
||||||
|
type="radio"
|
||||||
|
name={s.id}
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
{state => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.p_md,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
{
|
||||||
|
borderColor: state.selected
|
||||||
|
? t.palette.primary_500
|
||||||
|
: t.atoms.border_contrast_low.borderColor,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_heavy]}>
|
||||||
|
{s.price?.formatted}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Toggle.Item>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Purchase`)}
|
||||||
|
disabled={purchaseDisabled}
|
||||||
|
size="large"
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
onPress={action}>
|
||||||
|
<ButtonText>{isEditing ? 'Update' : 'Purchase'}</ButtonText>
|
||||||
|
{isPending && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{canCancel ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Cancel subscription`)}
|
||||||
|
size="large"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
onPress={cancel}>
|
||||||
|
<ButtonText>Cancel</ButtonText>
|
||||||
|
{isCancelPending && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,27 @@ import {Entitlement} from '#/state/purchases/subscriptions/types'
|
|||||||
export async function getMainSubscriptions({
|
export async function getMainSubscriptions({
|
||||||
did,
|
did,
|
||||||
platform,
|
platform,
|
||||||
|
currency,
|
||||||
}: {
|
}: {
|
||||||
did: string
|
did: string
|
||||||
platform: 'web' | 'ios' | 'android'
|
platform: 'web' | 'ios' | 'android'
|
||||||
|
currency: string
|
||||||
}) {
|
}) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://bsky-purchases.ngrok.io/subscriptions/main?user=${did}&platform=${platform}`,
|
`https://bsky-purchases.ngrok.io/subscriptions/main?user=${did}&platform=${platform}¤cy=${currency}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error('error fetching subscriptions', res.status, await res.text())
|
console.error('error fetching subscriptions', res.status, await res.text())
|
||||||
return {
|
return {
|
||||||
active: [],
|
subscriptions: [],
|
||||||
available: [],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
|
|
||||||
return json as {
|
return json as {
|
||||||
active: Subscription[]
|
subscriptions: Subscription[]
|
||||||
available: Subscription[]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,3 +42,39 @@ export async function getEntitlements({did}: {did: string}) {
|
|||||||
|
|
||||||
return entitlements as Entitlement[]
|
return entitlements as Entitlement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSubscription({subscriptionId, newPriceId}: {subscriptionId: string, newPriceId: string}) {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://bsky-purchases.ngrok.io/subscriptions/update`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({stripeSubscriptionId: subscriptionId, newPriceId}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('error updating subscription', res.status, await res.text())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelSubscription({subscriptionId}: {subscriptionId: string}) {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://bsky-purchases.ngrok.io/subscriptions/cancel`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({stripeSubscriptionId: subscriptionId}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('error cancelling subscription', res.status, await res.text())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
EntitlementId,
|
TierId,
|
||||||
SubscriptionId,
|
SubscriptionId,
|
||||||
} from '#/state/purchases/subscriptions/types'
|
} from '#/state/purchases/subscriptions/types'
|
||||||
|
|
||||||
export type SubscriptionState = {
|
export type SubscriptionState = {
|
||||||
|
platform: 'android' | 'ios' | 'web'
|
||||||
active: boolean
|
active: boolean
|
||||||
entitlements: {id: EntitlementId}[]
|
entitlements: {id: TierId}[]
|
||||||
status:
|
status:
|
||||||
| 'trialing'
|
| 'trialing'
|
||||||
| 'active'
|
| 'active'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {getMainSubscriptions} from '#/state/purchases/subscriptions/api'
|
|||||||
import {Subscription as APISubscription} from '#/state/purchases/subscriptions/api/types'
|
import {Subscription as APISubscription} from '#/state/purchases/subscriptions/api/types'
|
||||||
import {
|
import {
|
||||||
Subscription,
|
Subscription,
|
||||||
Subscriptions,
|
SubscriptionTier,
|
||||||
} from '#/state/purchases/subscriptions/types'
|
} from '#/state/purchases/subscriptions/types'
|
||||||
import {organizeMainSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
|
import {organizeMainSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -17,7 +17,7 @@ export function useMainSubscriptions() {
|
|||||||
const did = currentAccount!.did
|
const did = currentAccount!.did
|
||||||
const currencyFormatter = useCurrencyFormatter()
|
const currencyFormatter = useCurrencyFormatter()
|
||||||
|
|
||||||
return useQuery<Subscriptions>({
|
return useQuery<SubscriptionTier[]>({
|
||||||
queryKey: ['availableSubscriptions', did],
|
queryKey: ['availableSubscriptions', did],
|
||||||
async queryFn() {
|
async queryFn() {
|
||||||
const platform = isAndroid ? 'android' : 'ios'
|
const platform = isAndroid ? 'android' : 'ios'
|
||||||
@@ -25,7 +25,7 @@ export function useMainSubscriptions() {
|
|||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
platform,
|
platform,
|
||||||
})
|
})
|
||||||
const platformSubscriptions = rawSubscriptions.available
|
const platformSubscriptions = rawSubscriptions.subscriptions
|
||||||
.filter(s => s.platform !== 'web')
|
.filter(s => s.platform !== 'web')
|
||||||
.filter(s => s.platform === platform)
|
.filter(s => s.platform === platform)
|
||||||
const lookupKeys = Array.from(
|
const lookupKeys = Array.from(
|
||||||
@@ -68,17 +68,11 @@ export function useMainSubscriptions() {
|
|||||||
product: productData,
|
product: productData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const subscriptions = platformSubscriptions
|
const subscriptions = rawSubscriptions.subscriptions
|
||||||
.map(decorateSubscription)
|
|
||||||
.filter(Boolean) as Subscription[]
|
|
||||||
const active = rawSubscriptions.active
|
|
||||||
.map(decorateSubscription)
|
.map(decorateSubscription)
|
||||||
.filter(Boolean) as Subscription[]
|
.filter(Boolean) as Subscription[]
|
||||||
|
|
||||||
return {
|
return organizeMainSubscriptionsByTier(subscriptions)
|
||||||
active: active,
|
|
||||||
available: organizeMainSubscriptionsByTier(subscriptions),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -108,3 +102,15 @@ export function usePurchaseSubscription() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useChangeSubscription() {
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn({prev, next}: {prev: Subscription; next: Subscription}) {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCancelSubscription() {
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn(subscription: Subscription) {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
import {Linking} from 'react-native'
|
import {Linking} from 'react-native'
|
||||||
import {useMutation, useQuery} from '@tanstack/react-query'
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useCurrencyFormatter} from '#/lib/currency'
|
import {useCurrencyFormatter} from '#/lib/currency'
|
||||||
import {getMainSubscriptions} from '#/state/purchases/subscriptions/api'
|
import {
|
||||||
|
getMainSubscriptions,
|
||||||
|
updateSubscription,
|
||||||
|
cancelSubscription,
|
||||||
|
} from '#/state/purchases/subscriptions/api'
|
||||||
import {
|
import {
|
||||||
Subscription,
|
Subscription,
|
||||||
Subscriptions,
|
SubscriptionTier,
|
||||||
} from '#/state/purchases/subscriptions/types'
|
} from '#/state/purchases/subscriptions/types'
|
||||||
import {organizeMainSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
|
import {organizeMainSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {BSKY_PURCHASES_API} from '#/env'
|
import {BSKY_PURCHASES_API} from '#/env'
|
||||||
|
|
||||||
|
const createMainSubscriptionsQueryKey = (did: string) => [
|
||||||
|
'availableSubscriptions',
|
||||||
|
did,
|
||||||
|
]
|
||||||
|
|
||||||
export function useMainSubscriptions() {
|
export function useMainSubscriptions() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const currencyFormatter = useCurrencyFormatter()
|
const currencyFormatter = useCurrencyFormatter({
|
||||||
|
trailingZeroDisplay: 'stripIfInteger',
|
||||||
|
})
|
||||||
|
|
||||||
return useQuery<Subscriptions>({
|
return useQuery<SubscriptionTier[]>({
|
||||||
queryKey: ['availableSubscriptions', currentAccount!.did],
|
refetchOnWindowFocus: true,
|
||||||
|
queryKey: createMainSubscriptionsQueryKey(currentAccount!.did),
|
||||||
async queryFn() {
|
async queryFn() {
|
||||||
const rawSubscriptions = await getMainSubscriptions({
|
const rawSubscriptions = await getMainSubscriptions({
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
platform: 'web',
|
platform: 'web',
|
||||||
|
currency: currencyFormatter.currency,
|
||||||
})
|
})
|
||||||
const platformSubscriptions = rawSubscriptions.available.filter(
|
const platformSubscriptions = rawSubscriptions.subscriptions.filter(
|
||||||
s => s.platform === 'web',
|
s => s.platform === 'web',
|
||||||
)
|
)
|
||||||
const subscriptions: Subscription[] = platformSubscriptions.map(sub => {
|
const subscriptions: Subscription[] = platformSubscriptions.map(sub => {
|
||||||
@@ -40,44 +53,7 @@ export function useMainSubscriptions() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const active: Subscription[] = rawSubscriptions.active.map(sub => {
|
return organizeMainSubscriptionsByTier(subscriptions)
|
||||||
if (sub.platform === 'web') {
|
|
||||||
const s: Subscription = {
|
|
||||||
id: sub.id,
|
|
||||||
platform: 'web',
|
|
||||||
interval: sub.interval,
|
|
||||||
subscription: sub,
|
|
||||||
price: {
|
|
||||||
value: sub.store.price,
|
|
||||||
formatted: currencyFormatter.format(sub.store.price / 100),
|
|
||||||
},
|
|
||||||
// TODO may need to have si_id too
|
|
||||||
product: sub.store.priceId,
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
const s: Subscription = {
|
|
||||||
id: sub.id,
|
|
||||||
platform: sub.platform,
|
|
||||||
interval: sub.interval,
|
|
||||||
subscription: sub,
|
|
||||||
price: {
|
|
||||||
value: 0,
|
|
||||||
formatted: '',
|
|
||||||
},
|
|
||||||
product: undefined,
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* TODO decorate this too
|
|
||||||
*/
|
|
||||||
active: active,
|
|
||||||
available: organizeMainSubscriptionsByTier(subscriptions),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -113,3 +89,59 @@ export function usePurchaseSubscription() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useChangeSubscription() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn({prev, next}: {prev: Subscription; next: Subscription}) {
|
||||||
|
if (
|
||||||
|
prev.subscription.platform !== 'web' ||
|
||||||
|
next.subscription.platform !== 'web'
|
||||||
|
) {
|
||||||
|
throw new Error('Cannot purchase native subscription on web')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prev.subscription.store.subscriptionId) {
|
||||||
|
throw new Error(`Cannot change a subscription that doesn't exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSubscription({
|
||||||
|
subscriptionId: prev.subscription.store.subscriptionId,
|
||||||
|
newPriceId: next.subscription.store.priceId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: createMainSubscriptionsQueryKey(currentAccount!.did),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCancelSubscription() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn(subscription: Subscription) {
|
||||||
|
if (subscription.subscription.platform !== 'web') {
|
||||||
|
throw new Error('Cannot cancel native subscription on web')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription.subscription.store.subscriptionId) {
|
||||||
|
throw new Error(`Cannot cancel a subscription that doesn't exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await cancelSubscription({
|
||||||
|
subscriptionId: subscription.subscription.store.subscriptionId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: createMainSubscriptionsQueryKey(currentAccount!.did),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,14 +11,19 @@ export enum SubscriptionId {
|
|||||||
Main2AnnualAuto = 'main:2:annual:auto',
|
Main2AnnualAuto = 'main:2:annual:auto',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum EntitlementId {
|
export enum TierId {
|
||||||
Main0 = 'main:0',
|
Main0 = 'main:0',
|
||||||
Main1 = 'main:1',
|
Main1 = 'main:1',
|
||||||
Main2 = 'main:2',
|
Main2 = 'main:2',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PaymentInterval {
|
||||||
|
Monthly = 'monthly',
|
||||||
|
Annual = 'annual',
|
||||||
|
}
|
||||||
|
|
||||||
export type Entitlement = {
|
export type Entitlement = {
|
||||||
id: EntitlementId
|
id: TierId
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Subscription =
|
export type Subscription =
|
||||||
@@ -65,10 +70,8 @@ export type Subscription =
|
|||||||
product: string
|
product: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Subscriptions = {
|
export type SubscriptionTier = {
|
||||||
active: Subscription[]
|
id: TierId
|
||||||
available: {
|
[PaymentInterval.Monthly]: Subscription
|
||||||
monthly: Subscription[]
|
[PaymentInterval.Annual]: Subscription
|
||||||
annual: Subscription[]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
import {Subscription, Subscriptions} from './types'
|
import {Subscription, SubscriptionTier} from './types'
|
||||||
|
|
||||||
export function organizeMainSubscriptionsByTier(
|
export function organizeMainSubscriptionsByTier(
|
||||||
subscriptions: Subscription[],
|
subscriptions: Subscription[],
|
||||||
): Subscriptions['available'] {
|
): SubscriptionTier[] {
|
||||||
const result: Subscriptions['available'] = {
|
const interim: Record<string, SubscriptionTier> = {}
|
||||||
monthly: [],
|
|
||||||
annual: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sub of subscriptions) {
|
for (const sub of subscriptions) {
|
||||||
result[sub.interval].push(sub)
|
const [_, tier, interval] = sub.id.split(':')
|
||||||
|
|
||||||
|
interim[tier] = interim[tier] || {
|
||||||
|
id: `main:${tier}`,
|
||||||
|
}
|
||||||
|
interim[tier][interval as 'monthly' | 'annual'] = sub
|
||||||
}
|
}
|
||||||
|
|
||||||
result.monthly = result.monthly.sort((a, b) => {
|
return Object.values(interim)
|
||||||
const _a = parseInt(a.id.split(':')[1])
|
|
||||||
const _b = parseInt(b.id.split(':')[1])
|
|
||||||
return _a - _b
|
|
||||||
})
|
|
||||||
result.annual = result.annual.sort((a, b) => {
|
|
||||||
const _a = parseInt(a.id.split(':')[1])
|
|
||||||
const _b = parseInt(b.id.split(':')[1])
|
|
||||||
return _a - _b
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user