Bunch of UI

This commit is contained in:
Eric Bailey
2024-10-31 10:31:59 -05:00
parent f6e8b2f2f0
commit 008cdf8d27
9 changed files with 513 additions and 189 deletions
+7 -7
View File
@@ -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`),
},
}
}
+326 -92
View File
@@ -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>
)
}
+41 -5
View File
@@ -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}&currency=${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'
+17 -11
View File
@@ -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) {},
})
}
+77 -45
View File
@@ -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 -8
View File
@@ -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
}
+10 -19
View File
@@ -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)
}