Bunch of UI
This commit is contained in:
@@ -41,7 +41,7 @@ const GroupContext = React.createContext<{
|
||||
disabled: boolean
|
||||
type: 'radio' | 'checkbox'
|
||||
maxSelectionsReached: boolean
|
||||
setFieldValue: (props: {name: string; value: boolean}) => void
|
||||
setFieldValue: (props: {name: any; value: boolean}) => void
|
||||
}>({
|
||||
type: 'checkbox',
|
||||
values: [],
|
||||
@@ -50,12 +50,12 @@ const GroupContext = React.createContext<{
|
||||
setFieldValue: () => {},
|
||||
})
|
||||
|
||||
export type GroupProps = React.PropsWithChildren<{
|
||||
export type GroupProps<V> = React.PropsWithChildren<{
|
||||
type?: 'radio' | 'checkbox'
|
||||
values: string[]
|
||||
values: V[]
|
||||
maxSelections?: number
|
||||
disabled?: boolean
|
||||
onChange: (value: string[]) => void
|
||||
onChange: (value: V[]) => void
|
||||
label: string
|
||||
}>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function useItemContext() {
|
||||
return React.useContext(ItemContext)
|
||||
}
|
||||
|
||||
export function Group({
|
||||
export function Group<V extends string>({
|
||||
children,
|
||||
values: providedValues,
|
||||
onChange,
|
||||
@@ -82,13 +82,13 @@ export function Group({
|
||||
type = 'checkbox',
|
||||
maxSelections,
|
||||
label,
|
||||
}: GroupProps) {
|
||||
}: GroupProps<V>) {
|
||||
const groupRole = type === 'radio' ? 'radiogroup' : undefined
|
||||
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
|
||||
const [maxReached, setMaxReached] = React.useState(false)
|
||||
|
||||
const setFieldValue = React.useCallback<
|
||||
(props: {name: string; value: boolean}) => void
|
||||
(props: {name: V; value: boolean}) => void
|
||||
>(
|
||||
({name, value}) => {
|
||||
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 {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {
|
||||
useMainSubscriptions,
|
||||
usePurchaseSubscription,
|
||||
useChangeSubscription,
|
||||
useCancelSubscription,
|
||||
} from '#/state/purchases/subscriptions'
|
||||
// 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 {CenteredView} from '#/view/com/util/Views'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {atoms as a, useTheme, useBreakpoints, tokens} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
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<
|
||||
CommonNavigatorParams,
|
||||
@@ -28,16 +43,29 @@ export function Subscriptions(_props: ScreenProps) {
|
||||
const {_} = useLingui()
|
||||
|
||||
// const {data: entitlements} = useEntitlements()
|
||||
const {data: subscriptions, isError, error} = useMainSubscriptions()
|
||||
|
||||
if (error) {
|
||||
console.log(error)
|
||||
}
|
||||
const {data: subscriptions, isError} = useMainSubscriptions()
|
||||
const activeSubscription = React.useMemo(() => {
|
||||
if (!subscriptions) return
|
||||
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 (
|
||||
<Layout.Screen>
|
||||
<CenteredView sideBorders={true}>
|
||||
<ViewHeader title={_(msg`Subscriptions`)} />
|
||||
|
||||
<View style={[a.px_xl, a.py_xl, a.gap_lg]}>
|
||||
<Text style={[a.text_2xl]}>Subs</Text>
|
||||
|
||||
@@ -46,7 +74,26 @@ export function Subscriptions(_props: ScreenProps) {
|
||||
) : !subscriptions ? (
|
||||
<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>
|
||||
</CenteredView>
|
||||
@@ -54,95 +101,282 @@ export function Subscriptions(_props: ScreenProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function MainSubscription({
|
||||
subscriptions,
|
||||
export function SubscriptionCard({
|
||||
subscription: s,
|
||||
activeSubscription,
|
||||
isManageable,
|
||||
}: {
|
||||
subscriptions: SubscriptionsResponse
|
||||
subscription: SubscriptionTier
|
||||
activeSubscription?: Subscription
|
||||
isManageable: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {i18n} = useLingui()
|
||||
const copy = useMainSubscriptionTiersCopy()
|
||||
|
||||
const active = subscriptions.active.at(0)
|
||||
const {mutateAsync: purchaseSubscription} = usePurchaseSubscription()
|
||||
const activePlan = React.useMemo(() => {
|
||||
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 contents = (
|
||||
<View style={[a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Text style={[a.text_xl, a.font_heavy]}>Bluesky Plus</Text>
|
||||
<Text
|
||||
const gradient = React.useMemo(() => {
|
||||
return {
|
||||
[TierId.Main0]: tokens.gradients.sky,
|
||||
[TierId.Main1]: tokens.gradients.sunrise,
|
||||
[TierId.Main2]: tokens.gradients.bonfire,
|
||||
}[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={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.p_xs,
|
||||
a.rounded_xs,
|
||||
{
|
||||
color: t.palette.white,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
ctx.hovered
|
||||
? t.atoms.border_contrast_high
|
||||
: t.atoms.border_contrast_low,
|
||||
]}>
|
||||
Supporter
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_md]}>Managed via: {active?.platform}</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
|
||||
{contents}
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<View
|
||||
style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
|
||||
<View style={[a.gap_sm, a.pb_xl]}>
|
||||
{subscriptions.available.monthly.map(s => (
|
||||
<View key={s.subscription.id}>
|
||||
<Button
|
||||
label={s.subscription.id}
|
||||
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
|
||||
style={[
|
||||
a.relative,
|
||||
a.w_full,
|
||||
{
|
||||
paddingTop: '10%',
|
||||
},
|
||||
]}>
|
||||
<GradientFill gradient={gradient} />
|
||||
{isActive && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.pr_lg,
|
||||
{
|
||||
left: 'auto',
|
||||
},
|
||||
]}>
|
||||
<Check fill="white" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={[a.gap_sm]}>
|
||||
{subscriptions.available.annual.map(s => (
|
||||
<View key={s.subscription.id}>
|
||||
<Button
|
||||
label={s.subscription.id}
|
||||
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 style={[a.p_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_heavy]}>{copy[s.id].title}</Text>
|
||||
|
||||
{activePlan && activePlan.subscription.state && (
|
||||
<View style={[a.gap_xs, a.pt_md]}>
|
||||
<Text style={[a.text_md, a.font_bold]}>Current</Text>
|
||||
<Text>• {activePlan.subscription.interval}</Text>
|
||||
<Text>
|
||||
• renews{' '}
|
||||
{i18n.date(
|
||||
new Date(activePlan.subscription.state?.periodEnd),
|
||||
{dateStyle: 'medium'},
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isManageable && (
|
||||
<>
|
||||
<Divider style={[a.my_md]} />
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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({
|
||||
did,
|
||||
platform,
|
||||
currency,
|
||||
}: {
|
||||
did: string
|
||||
platform: 'web' | 'ios' | 'android'
|
||||
currency: string
|
||||
}) {
|
||||
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) {
|
||||
console.error('error fetching subscriptions', res.status, await res.text())
|
||||
return {
|
||||
active: [],
|
||||
available: [],
|
||||
subscriptions: [],
|
||||
}
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
return json as {
|
||||
active: Subscription[]
|
||||
available: Subscription[]
|
||||
subscriptions: Subscription[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,3 +42,39 @@ export async function getEntitlements({did}: {did: string}) {
|
||||
|
||||
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 {
|
||||
EntitlementId,
|
||||
TierId,
|
||||
SubscriptionId,
|
||||
} from '#/state/purchases/subscriptions/types'
|
||||
|
||||
export type SubscriptionState = {
|
||||
platform: 'android' | 'ios' | 'web'
|
||||
active: boolean
|
||||
entitlements: {id: EntitlementId}[]
|
||||
entitlements: {id: TierId}[]
|
||||
status:
|
||||
| 'trialing'
|
||||
| 'active'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {getMainSubscriptions} from '#/state/purchases/subscriptions/api'
|
||||
import {Subscription as APISubscription} from '#/state/purchases/subscriptions/api/types'
|
||||
import {
|
||||
Subscription,
|
||||
Subscriptions,
|
||||
SubscriptionTier,
|
||||
} from '#/state/purchases/subscriptions/types'
|
||||
import {organizeMainSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -17,7 +17,7 @@ export function useMainSubscriptions() {
|
||||
const did = currentAccount!.did
|
||||
const currencyFormatter = useCurrencyFormatter()
|
||||
|
||||
return useQuery<Subscriptions>({
|
||||
return useQuery<SubscriptionTier[]>({
|
||||
queryKey: ['availableSubscriptions', did],
|
||||
async queryFn() {
|
||||
const platform = isAndroid ? 'android' : 'ios'
|
||||
@@ -25,7 +25,7 @@ export function useMainSubscriptions() {
|
||||
did: currentAccount!.did,
|
||||
platform,
|
||||
})
|
||||
const platformSubscriptions = rawSubscriptions.available
|
||||
const platformSubscriptions = rawSubscriptions.subscriptions
|
||||
.filter(s => s.platform !== 'web')
|
||||
.filter(s => s.platform === platform)
|
||||
const lookupKeys = Array.from(
|
||||
@@ -68,17 +68,11 @@ export function useMainSubscriptions() {
|
||||
product: productData,
|
||||
}
|
||||
}
|
||||
const subscriptions = platformSubscriptions
|
||||
.map(decorateSubscription)
|
||||
.filter(Boolean) as Subscription[]
|
||||
const active = rawSubscriptions.active
|
||||
const subscriptions = rawSubscriptions.subscriptions
|
||||
.map(decorateSubscription)
|
||||
.filter(Boolean) as Subscription[]
|
||||
|
||||
return {
|
||||
active: active,
|
||||
available: organizeMainSubscriptionsByTier(subscriptions),
|
||||
}
|
||||
return 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 {useMutation, useQuery} from '@tanstack/react-query'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useCurrencyFormatter} from '#/lib/currency'
|
||||
import {getMainSubscriptions} from '#/state/purchases/subscriptions/api'
|
||||
import {
|
||||
getMainSubscriptions,
|
||||
updateSubscription,
|
||||
cancelSubscription,
|
||||
} from '#/state/purchases/subscriptions/api'
|
||||
import {
|
||||
Subscription,
|
||||
Subscriptions,
|
||||
SubscriptionTier,
|
||||
} from '#/state/purchases/subscriptions/types'
|
||||
import {organizeMainSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {BSKY_PURCHASES_API} from '#/env'
|
||||
|
||||
const createMainSubscriptionsQueryKey = (did: string) => [
|
||||
'availableSubscriptions',
|
||||
did,
|
||||
]
|
||||
|
||||
export function useMainSubscriptions() {
|
||||
const {currentAccount} = useSession()
|
||||
const currencyFormatter = useCurrencyFormatter()
|
||||
const currencyFormatter = useCurrencyFormatter({
|
||||
trailingZeroDisplay: 'stripIfInteger',
|
||||
})
|
||||
|
||||
return useQuery<Subscriptions>({
|
||||
queryKey: ['availableSubscriptions', currentAccount!.did],
|
||||
return useQuery<SubscriptionTier[]>({
|
||||
refetchOnWindowFocus: true,
|
||||
queryKey: createMainSubscriptionsQueryKey(currentAccount!.did),
|
||||
async queryFn() {
|
||||
const rawSubscriptions = await getMainSubscriptions({
|
||||
did: currentAccount!.did,
|
||||
platform: 'web',
|
||||
currency: currencyFormatter.currency,
|
||||
})
|
||||
const platformSubscriptions = rawSubscriptions.available.filter(
|
||||
const platformSubscriptions = rawSubscriptions.subscriptions.filter(
|
||||
s => s.platform === 'web',
|
||||
)
|
||||
const subscriptions: Subscription[] = platformSubscriptions.map(sub => {
|
||||
@@ -40,44 +53,7 @@ export function useMainSubscriptions() {
|
||||
}
|
||||
})
|
||||
|
||||
const active: Subscription[] = rawSubscriptions.active.map(sub => {
|
||||
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),
|
||||
}
|
||||
return 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',
|
||||
}
|
||||
|
||||
export enum EntitlementId {
|
||||
export enum TierId {
|
||||
Main0 = 'main:0',
|
||||
Main1 = 'main:1',
|
||||
Main2 = 'main:2',
|
||||
}
|
||||
|
||||
export enum PaymentInterval {
|
||||
Monthly = 'monthly',
|
||||
Annual = 'annual',
|
||||
}
|
||||
|
||||
export type Entitlement = {
|
||||
id: EntitlementId
|
||||
id: TierId
|
||||
}
|
||||
|
||||
export type Subscription =
|
||||
@@ -65,10 +70,8 @@ export type Subscription =
|
||||
product: string
|
||||
}
|
||||
|
||||
export type Subscriptions = {
|
||||
active: Subscription[]
|
||||
available: {
|
||||
monthly: Subscription[]
|
||||
annual: Subscription[]
|
||||
}
|
||||
export type SubscriptionTier = {
|
||||
id: TierId
|
||||
[PaymentInterval.Monthly]: Subscription
|
||||
[PaymentInterval.Annual]: Subscription
|
||||
}
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import {Subscription, Subscriptions} from './types'
|
||||
import {Subscription, SubscriptionTier} from './types'
|
||||
|
||||
export function organizeMainSubscriptionsByTier(
|
||||
subscriptions: Subscription[],
|
||||
): Subscriptions['available'] {
|
||||
const result: Subscriptions['available'] = {
|
||||
monthly: [],
|
||||
annual: [],
|
||||
}
|
||||
): SubscriptionTier[] {
|
||||
const interim: Record<string, SubscriptionTier> = {}
|
||||
|
||||
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) => {
|
||||
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
|
||||
return Object.values(interim)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user