Files
bsky-social-app/src/screens/Subscriptions/index.tsx
T
2024-12-08 16:28:30 -06:00

627 lines
18 KiB
TypeScript

import React from 'react'
import {RefreshControl, TextStyle, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {isAndroid, isIOS, isWeb} from '#/platform/detection'
import {PurchasesState, usePurchases, usePurchasesApi} from '#/state/purchases'
import {useManageSubscription} from '#/state/purchases/hooks/useManageSubscription'
import {useNativeUserState} from '#/state/purchases/hooks/useNativeUserState'
import {
PlatformId,
SubscriptionGroupId,
SubscriptionOfferingId,
} from '#/state/purchases/types'
import {
APISubscription,
NativePurchaseRestricted,
} from '#/state/purchases/types'
import {atoms as a, tokens, useBreakpoints, useGutters,useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {BlueskyPlusCore} from '#/components/dialogs/BlueskyPlusCore'
import {Divider} from '#/components/Divider'
import {GradientFill} from '#/components/GradientFill'
import {AndroidLogo} from '#/components/icons/AndroidLogo'
import {AppleLogo} from '#/components/icons/AppleLogo'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Rotate} from '#/components/icons/ArrowRotateCounterClockwise'
import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus'
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
// import {Mark} from '#/components/icons/Logo'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import * as Layout from '#/components/Layout'
import {createStaticClick, InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export type ScreenProps = NativeStackScreenProps<
CommonNavigatorParams,
'Subscriptions'
>
export function Subscriptions(_props: ScreenProps) {
const gutters = useGutters(['base', 'base'])
const insets = useSafeAreaInsets()
const purchases = usePurchases()
const {refetch} = usePurchasesApi()
const {loading, restricted} = useNativeUserState()
const [refreshing, setRefreshing] = React.useState(false)
const onRefresh = async () => {
setRefreshing(true)
try {
await refetch()
} finally {
setRefreshing(false)
}
}
return (
<Layout.Screen noPaddingTop>
<Layout.Center>
<GradientFill gradient={tokens.gradients.nordic} />
<View style={{height: insets.top}} />
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content>
<BlueskyPlusLogo width={100} fill="white" />
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
{/*
<View style={[
a.px_xl,
]}>
<Divider style={{borderColor: 'white', opacity: 0.2}} />
</View>
*/}
</Layout.Center>
<Layout.Content
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}>
<View style={[gutters]}>
{purchases.status === 'loading' || loading ? (
<Loader />
) : purchases.status === 'error' ? (
<Admonition type="error">
{purchases.error?.message ?? 'Something went wrong.'}
</Admonition>
) : (
<Core state={purchases} restricted={restricted} />
)}
</View>
</Layout.Content>
</Layout.Screen>
)
}
function Core({
state,
restricted,
}: {
state: Exclude<PurchasesState, {status: 'loading' | 'error'}>
restricted: NativePurchaseRestricted
}) {
const t = useTheme()
const coreSubscriptions = state.subscriptions.filter(
s => s.group === SubscriptionGroupId.Core,
)
const activeCoreSubscriptions = coreSubscriptions.filter(
s => s.status === 'active' || s.status === 'paused',
)
const inactiveCoreSubscriptions = coreSubscriptions.filter(
s => s.status !== 'active' && s.status !== 'paused',
)
const hasActiveSubscriptions = activeCoreSubscriptions.length > 0
const hasInactiveCoreSubscriptions = inactiveCoreSubscriptions.length > 0
return (
<View style={[a.gap_md]}>
{restricted === 'yes' ? (
<Admonition type="info">
<Trans>
Another account on this device is already subscribed to Bluesky+.
Please subscribe additional accounts through our web application.
</Trans>
</Admonition>
) : hasActiveSubscriptions ? null : hasInactiveCoreSubscriptions ? (
<Repurchase />
) : (
<Purchase />
)}
<Divider />
{hasActiveSubscriptions && (
<View style={[a.gap_sm]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>My subscriptions</Trans>
</Text>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
{activeCoreSubscriptions.map(sub => (
<Subscription key={sub.purchasedAt} subscription={sub} />
))}
</View>
</View>
</View>
)}
{hasInactiveCoreSubscriptions && (
<View style={[a.gap_sm]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Past subscriptions</Trans>
</Text>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
{inactiveCoreSubscriptions.map(sub => (
<Subscription key={sub.purchasedAt} subscription={sub} />
))}
</View>
</View>
</View>
)}
<Divider />
<InfoCards />
<View style={[]}>
<Text style={[a.text_xs]}>
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_medium]}>
Terms and Conditions
</InlineLinkText>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_medium]}>
Privacy Policy
</InlineLinkText>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_medium]}>
EULA
</InlineLinkText>
</Text>
</View>
</View>
)
}
function InfoCards() {
const t = useTheme()
const {_} = useLingui()
const {gtPhone} = useBreakpoints()
const {features} = useFeatures()
const activeFeatures = features.filter(f => f.available)
return (
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
<View
style={[
gtPhone ? a.flex_1 : a.w_full,
a.p_lg,
a.rounded_md,
a.gap_sm,
t.atoms.bg_contrast_25,
]}>
<Text
style={[
a.text_sm,
a.font_bold,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>Included in subscription</Trans>
</Text>
<View style={[a.py_xs, a.gap_sm]}>
{activeFeatures.map(f => (
<View key={f.text} style={[a.flex_row, a.align_center, a.gap_xs]}>
<f.icon fill={t.atoms.text_contrast_low.color} size="xs" />
<Text style={[a.text_sm]}>{f.text}</Text>
</View>
))}
</View>
</View>
<View
style={[
gtPhone ? a.flex_1 : a.w_full,
a.p_lg,
a.rounded_md,
a.border,
a.gap_sm,
t.atoms.border_contrast_low,
]}>
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Your support helps Bluesky build a better internet. Thanks for being
a part of the ATmosphere!
</Trans>
</Text>
<Link
to="https://blueskyweb.zendesk.com/hc/en-us"
label={_(msg`Contact support`)}
size="small"
variant="solid"
color="secondary"
style={[a.justify_center]}>
<ButtonText>
<Trans>Get support</Trans>
</ButtonText>
</Link>
<Link
to="https://blueskyweb.zendesk.com/hc/en-us"
label={_(msg`Subscriptions FAQ`)}
size="small"
variant="outline"
color="secondary"
style={[a.justify_center]}>
<ButtonText>
<Trans>FAQ</Trans>
</ButtonText>
</Link>
</View>
</View>
)
}
function Repurchase() {
const {_} = useLingui()
const control = useDialogControl()
return (
<View style={[a.gap_sm]}>
<Text style={[a.text_3xl, a.font_heavy]}>
<Trans>You're part of something bigger.</Trans>
</Text>
<View style={[a.gap_xs]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Subscribing to Bluesky+ helps ensure that our work to build an open,
secure, and user-first internet can continue.
</Trans>
</Text>
</View>
<Button
label={_('Subscribe')}
onPress={() => control.open()}
size="large"
variant="solid"
color="primary"
style={[a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.nordic} />
<ButtonText style={[{color: 'white'}]}>
<Trans>Subscribe</Trans>
</ButtonText>
<ButtonIcon icon={Plus} position="right" style={[{color: 'white'}]} />
</Button>
<BlueskyPlusCore control={control} />
</View>
)
}
function Purchase() {
const t = useTheme()
const {_} = useLingui()
const control = useDialogControl()
const {features} = useFeatures()
return (
<>
<Text style={[a.text_3xl, a.font_heavy, 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]}>
<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]}>
<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,
a.border,
t.atoms.border_contrast_low,
]}>
{features.map(f => (
<View key={f.text} style={[a.flex_row, a.gap_sm]}>
<View style={{paddingTop: 2}}>
<f.icon
fill={
f.available
? tokens.blueskyPlus.mid
: 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={[{color: 'white'}]}>
<Trans>Subscribe</Trans>
</ButtonText>
<ButtonIcon icon={Plus} position="right" style={[{color: 'white'}]} />
</Button>
<BlueskyPlusCore control={control} />
</>
)
}
export function useSubscriptionUI({
subscription: sub,
}: {
subscription: APISubscription
}) {
const {_, i18n} = useLingui()
return React.useMemo(() => {
const periodEndsAt = i18n.date(new Date(sub.periodEndsAt), {
dateStyle: 'short',
})
const title = {
[SubscriptionOfferingId.CoreMonthly]: _(msg`Monthly`),
[SubscriptionOfferingId.CoreAnnual]: _(msg`Annual`),
}[sub.offering]
const PlatformLogo = {
[PlatformId.Ios]: AppleLogo,
[PlatformId.Android]: AndroidLogo,
[PlatformId.Web]: Globe,
}[sub.platform]
const platformName = {
[PlatformId.Ios]: _('iOS'),
[PlatformId.Android]: _('Android'),
[PlatformId.Web]: _('Web'),
}[sub.platform]
const status = {
active: _(msg`Active`),
expired: _(msg`Expired`),
paused: _(msg`Paused`),
unknown: _(msg`Unknown`),
}[sub.status]
const renewalStatus = {
will_renew: _(msg`Renews ${periodEndsAt}`),
will_not_renew: {
active: _(msg`Expires ${periodEndsAt}`),
expired: _(msg`Expired ${periodEndsAt}`),
paused: _(msg`Expires ${periodEndsAt}`),
unknown: _(msg`Unknown`),
}[sub.status],
will_pause: _(msg`Pauses ${periodEndsAt}`),
unknown: _(msg`Unknown`),
}[sub.renewalStatus]
const RenewalStatusIcon = {
will_renew: Rotate,
will_not_renew: Clock,
will_pause: Clock,
unknown: Clock,
}[sub.renewalStatus]
return {
title,
status,
renewalStatus,
platformName,
PlatformLogo,
RenewalStatusIcon,
}
}, [_, i18n, sub])
}
export function Subscription({
subscription: sub,
}: {
subscription: APISubscription
}) {
const t = useTheme()
const {_} = useLingui()
const {mutateAsync: manageSubscription} = useManageSubscription()
const ui = useSubscriptionUI({subscription: sub})
const canManage =
(isWeb && sub.platform === PlatformId.Web) ||
(isIOS && sub.platform === PlatformId.Ios) ||
(isAndroid && sub.platform === PlatformId.Android)
const showManage = canManage && sub.status !== 'expired'
const statusStyles = React.useMemo<TextStyle>(() => {
return {
active: {
color: tokens.blueskyPlus.dark,
backgroundColor: tokens.blueskyPlus.light,
borderWidth: undefined,
borderColor: undefined,
},
expired: {
color: t.atoms.text_contrast_medium.color,
backgroundColor: t.atoms.bg.backgroundColor,
borderWidth: a.border.borderWidth,
borderColor: t.atoms.border_contrast_high.borderColor,
},
paused: {
color: t.atoms.bg.backgroundColor,
backgroundColor: t.atoms.text_contrast_medium.color,
borderWidth: undefined,
borderColor: undefined,
},
unknown: {
color: t.atoms.text_contrast_medium.color,
backgroundColor: t.atoms.bg.backgroundColor,
borderWidth: a.border.borderWidth,
borderColor: t.atoms.border_contrast_high.borderColor,
},
}[sub.status]
}, [t, sub.status])
return (
<View
key={sub.purchasedAt}
style={[a.px_lg, a.rounded_md, a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.nordic} />
<View
style={[
a.absolute,
t.atoms.bg,
{
borderRadius: a.rounded_md.borderRadius - 2,
top: 2,
bottom: 2,
left: 2,
right: 2,
},
]}
/>
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
{
paddingTop: 10,
paddingBottom: 8,
},
]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text style={[a.text_lg, a.font_heavy]}>{ui.title}</Text>
<Text
style={[
a.text_xs,
a.rounded_full,
a.font_bold,
{
paddingVertical: 2,
paddingHorizontal: 6,
...statusStyles,
},
]}>
{ui.status}
</Text>
</View>
{showManage && (
<Link
label={_('Manage subscription')}
size="tiny"
variant="solid"
color="secondary_inverted"
{...createStaticClick(() => {
manageSubscription()
})}
style={[a.justify_center]}>
<ButtonText>
<Trans>Manage</Trans>
</ButtonText>
</Link>
)}
</View>
<Divider />
<View
style={[
a.flex_row,
a.gap_md,
a.align_center,
{
paddingTop: 8,
paddingBottom: 10,
},
]}>
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_xs,
{left: -1},
]}>
<ui.PlatformLogo size="sm" fill={t.atoms.text_contrast_low.color} />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{ui.platformName}
</Text>
</View>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<ui.RenewalStatusIcon
size="sm"
fill={t.atoms.text_contrast_low.color}
/>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{ui.renewalStatus}
</Text>
</View>
</View>
</View>
)
}
function useFeatures() {
const {_} = useLingui()
const features = [
{
available: true,
icon: Check,
text: _(msg`Custom app icons`),
},
{
available: false,
icon: Clock,
text: _(msg`Feature feature feature`),
},
]
return {
features,
}
}