From 008cdf8d27edd5cf09563b3647ecfb2b5addb90d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 31 Oct 2024 10:31:59 -0500 Subject: [PATCH] Bunch of UI --- src/components/forms/Toggle.tsx | 14 +- src/components/subscriptions/localization.ts | 21 + src/screens/Subscriptions/index.tsx | 418 ++++++++++++++---- .../purchases/subscriptions/api/index.ts | 46 +- .../purchases/subscriptions/api/types.ts | 5 +- src/state/purchases/subscriptions/index.ts | 28 +- .../purchases/subscriptions/index.web.ts | 122 +++-- src/state/purchases/subscriptions/types.ts | 19 +- src/state/purchases/subscriptions/util.ts | 29 +- 9 files changed, 513 insertions(+), 189 deletions(-) create mode 100644 src/components/subscriptions/localization.ts diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 4e3695bbf2..ce8daeea29 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -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 = 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({ children, values: providedValues, onChange, @@ -82,13 +82,13 @@ export function Group({ type = 'checkbox', maxSelections, label, -}: GroupProps) { +}: GroupProps) { 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') { diff --git a/src/components/subscriptions/localization.ts b/src/components/subscriptions/localization.ts new file mode 100644 index 0000000000..0d06f128c3 --- /dev/null +++ b/src/components/subscriptions/localization.ts @@ -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`), + }, + } +} diff --git a/src/screens/Subscriptions/index.tsx b/src/screens/Subscriptions/index.tsx index c2950f98e4..8af61df9d8 100644 --- a/src/screens/Subscriptions/index.tsx +++ b/src/screens/Subscriptions/index.tsx @@ -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 ( + Subs @@ -46,7 +74,26 @@ export function Subscriptions(_props: ScreenProps) { ) : !subscriptions ? ( ) : ( - + <> + {subscriptions.map(s => ( + + ))} + + {!isManageable ? ( + + + You purchased this subscription on{' '} + {activeSubscriptionPlatform}. Please access this screen on{' '} + {activeSubscriptionPlatform} to manage your plan. + + + ) : null} + )} @@ -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 = ( - - - Bluesky Plus - { + 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 ( + + + + + {isActive && ( + + + + )} - ))} - - - {subscriptions.available.annual.map(s => ( - - + + {copy[s.id].title} + + {activePlan && activePlan.subscription.state && ( + + Current + • {activePlan.subscription.interval} + + • renews{' '} + {i18n.date( + new Date(activePlan.subscription.state?.periodEnd), + {dateStyle: 'medium'}, + )} + + + )} + + {isManageable && ( + <> + + + + {s.annual.price && s.monthly.price ? ( + + + + {s.monthly.price.formatted} + + + /month{' • '} + + or {s.annual.price?.formatted}/year + + + + + ) : null} + + + )} - ))} - - - ) - } + + )} + + + + + + + + ) +} + +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( + 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 ( + + + {copy[s.id].title} + + + + + {tierSubscriptions.map(s => ( + + {state => ( + + + {s.price?.formatted} + + + )} + + ))} + + + + + + + {canCancel ? ( + + ) : null} + + + ) } diff --git a/src/state/purchases/subscriptions/api/index.ts b/src/state/purchases/subscriptions/api/index.ts index c81da3f8ca..bcadbf4aa7 100644 --- a/src/state/purchases/subscriptions/api/index.ts +++ b/src/state/purchases/subscriptions/api/index.ts @@ -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 + } +} diff --git a/src/state/purchases/subscriptions/api/types.ts b/src/state/purchases/subscriptions/api/types.ts index 780c10b068..3435756213 100644 --- a/src/state/purchases/subscriptions/api/types.ts +++ b/src/state/purchases/subscriptions/api/types.ts @@ -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' diff --git a/src/state/purchases/subscriptions/index.ts b/src/state/purchases/subscriptions/index.ts index d35cfc39d3..2812f30348 100644 --- a/src/state/purchases/subscriptions/index.ts +++ b/src/state/purchases/subscriptions/index.ts @@ -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({ + return useQuery({ 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) {}, + }) +} diff --git a/src/state/purchases/subscriptions/index.web.ts b/src/state/purchases/subscriptions/index.web.ts index 8a16ef1071..08ad57c481 100644 --- a/src/state/purchases/subscriptions/index.web.ts +++ b/src/state/purchases/subscriptions/index.web.ts @@ -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({ - queryKey: ['availableSubscriptions', currentAccount!.did], + return useQuery({ + 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), + }) + }, + }) +} diff --git a/src/state/purchases/subscriptions/types.ts b/src/state/purchases/subscriptions/types.ts index 46a7b8c7a0..af923b44ad 100644 --- a/src/state/purchases/subscriptions/types.ts +++ b/src/state/purchases/subscriptions/types.ts @@ -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 } diff --git a/src/state/purchases/subscriptions/util.ts b/src/state/purchases/subscriptions/util.ts index 9fff0a1bfe..a0e6cecabc 100644 --- a/src/state/purchases/subscriptions/util.ts +++ b/src/state/purchases/subscriptions/util.ts @@ -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 = {} 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) }