diff --git a/src/components/dialogs/BlueskyPlus/index.tsx b/src/components/dialogs/BlueskyPlus/index.tsx new file mode 100644 index 0000000000..8695b7432a --- /dev/null +++ b/src/components/dialogs/BlueskyPlus/index.tsx @@ -0,0 +1,269 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as Toggle from '#/components/forms/Toggle' +import {GradientFill} from '#/components/GradientFill' +import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' +import {InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {useSubscriptionGroup, usePurchaseOffering} from '#/state/purchases/subscriptions/useSubscriptionGroup' +import {OfferingId, SubscriptionGroupId} from '#/state/purchases/subscriptions/types' +import {parseOfferingId} from '#/state/purchases/subscriptions/util' + +export function BlueskyPlus({ + control, +}: { + control: Dialog.DialogControlProps +}) { + return ( + + + + ) +} + +function DialogInner() { + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const {currentAccount} = useSession() + const t = useTheme() + + const [offeringId, setOfferingId] = React.useState(OfferingId.CoreMonthly) + const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core) + const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering() + + const onPressSubscribe = async () => { + try { + if (!currentAccount) return + + const offering = coreOffering?.offerings.find(o => o.id === offeringId) + if (!offering) { + throw new Error('No offering') + } + + await purchaseOffering({ + did: currentAccount.did, + email: currentAccount.email!, + offering, + }) + } catch (e: any) { + Toast.show(_(msg`Could not take you to checkout`), 'xmark') + } + } + + return ( + + + + + + + + + + + Early bird discount! + + + Support Bluesky now and get a 25% discount for a year + + setOfferingId(parseOfferingId(values[0]))} + style={[a.w_full, a.gap_md, a.my_xl]}> + + {({selected}) => ( + + + + + + Monthly plan + + + $6/month + + + + + Billed monthly, cancel anytime. $8/month after 12 months + + + + + )} + + + {({selected}) => ( + + + + + + Annual plan + + + $60/year + + + + Billed annually, renews at $80/year + + + + )} + + + + + + Terms and Conditions + {' '} + ·{' '} + + Privacy Policy + {' '} + ·{' '} + + EULA + + + + + + + ) +} + +function MarketingBlurb() { + return ( + <> + + + + + Here's the thing: we don't have any premium features yet because all + our servers are on fire. Support us now so we can put the fires out + and get working on those features! + + + + + What you'll get now: + + + + {' '} + · A supporter badge on your profile + + + {' '} + · A warm fuzzy feeling knowing you helped us out + + + + + What you'll get soon: + + + + {' '} + · Custom profile colors + + + {' '} + · Longer and higher quality videos + + + {' '} + · Custom app icons + + + + ) +} diff --git a/src/env.ts b/src/env.ts index fbcc15f576..0261ef01ba 100644 --- a/src/env.ts +++ b/src/env.ts @@ -7,3 +7,5 @@ export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as | 'info' | 'warn' | 'error' + +export const PLUS_SERVICE_URL = process.env.EXPO_PUBLIC_PLUS_SERVICE_URL diff --git a/src/screens/Subscriptions/index.tsx b/src/screens/Subscriptions/index.tsx index bac2692f1c..95442f2c5f 100644 --- a/src/screens/Subscriptions/index.tsx +++ b/src/screens/Subscriptions/index.tsx @@ -9,7 +9,7 @@ import {CenteredView} from '#/view/com/util/Views' import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' -import {SubscriptionsDialog} from '#/components/dialogs/SubscriptionsDialog' +import {BlueskyPlus} from '#/components/dialogs/BlueskyPlus' import {Logotype} from '#/components/icons/BlueskyPlus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import * as Layout from '#/components/Layout' @@ -54,7 +54,7 @@ export function Subscriptions(_props: ScreenProps) { Subscribe - + )} diff --git a/src/state/purchases/subscriptions/api.ts b/src/state/purchases/subscriptions/api.ts index 9d12b03357..00d2f6fe46 100644 --- a/src/state/purchases/subscriptions/api.ts +++ b/src/state/purchases/subscriptions/api.ts @@ -1,5 +1,7 @@ import {create} from 'gretchen' +import {PLUS_SERVICE_URL} from '#/env' + export const api = create({ - baseURL: process.env.PLUS_SERVICE_URL, + baseURL: PLUS_SERVICE_URL, }) diff --git a/src/state/purchases/subscriptions/types.ts b/src/state/purchases/subscriptions/types.ts new file mode 100644 index 0000000000..9d6e60a27b --- /dev/null +++ b/src/state/purchases/subscriptions/types.ts @@ -0,0 +1,34 @@ +export enum EntitlementId { + Core = 'core', +} + +export enum PlatformId { + Android = 'android', + Ios = 'ios', + Web = 'web', +} + +export enum SubscriptionGroupId { + Core = 'core', +} + +export enum OfferingId { + CoreMonthly = 'coreMonthly', + CoreAnnual = 'coreAnnual', +} + +export type Offering = + | { + id: OfferingId + platform: PlatformId.Ios | PlatformId.Android + price: number + package: undefined + } + | { + id: OfferingId + platform: PlatformId.Web + price: number + package: { + priceId: string + } + } diff --git a/src/state/purchases/subscriptions/useEntitlements.ts b/src/state/purchases/subscriptions/useEntitlements.ts index 35c3178fce..2f53749cea 100644 --- a/src/state/purchases/subscriptions/useEntitlements.ts +++ b/src/state/purchases/subscriptions/useEntitlements.ts @@ -13,7 +13,7 @@ export function useEntitlements() { async queryFn() { const params = new URLSearchParams('/entitlements') params.set('did', currentAccount!.did) - const url = `/entitlements?${params.toString()}` + const url = `/account?${params.toString()}` const {data, error} = await api<{ entitlements: {id: 'core'; platform: 'web'}[] }>(url).json() diff --git a/src/state/purchases/subscriptions/useSubscriptionGroup/index.ts b/src/state/purchases/subscriptions/useSubscriptionGroup/index.ts new file mode 100644 index 0000000000..0720720df6 --- /dev/null +++ b/src/state/purchases/subscriptions/useSubscriptionGroup/index.ts @@ -0,0 +1,40 @@ +import React from 'react' +import {useQuery, useMutation} from '@tanstack/react-query' + +import {isIOS} from '#/platform/detection' +import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types' + +export function useSubscriptionGroup(group: SubscriptionGroupId) { + return useQuery<{ offerings: Offering[] }>({ + queryKey: ['subscriptions', 'core'], + queryFn() { + return { + offerings: [ + { + id: OfferingId.CoreMonthly, + platform: isIOS ? PlatformId.Ios : PlatformId.Android, + price: 800, + package: undefined, + }, + { + id: OfferingId.CoreAnnual, + platform: isIOS ? PlatformId.Ios : PlatformId.Android, + price: 8000, + package: undefined, + }, + ] + } + } + }) +} + +export function usePurchaseOffering() { + return useMutation({ + async mutationFn({ + did, + email, + offering, + }: { did: string, email: string, offering: Offering }) { + } + }) +} diff --git a/src/state/purchases/subscriptions/useSubscriptionGroup/index.web.ts b/src/state/purchases/subscriptions/useSubscriptionGroup/index.web.ts new file mode 100644 index 0000000000..0a712b8be4 --- /dev/null +++ b/src/state/purchases/subscriptions/useSubscriptionGroup/index.web.ts @@ -0,0 +1,64 @@ +import {Linking} from 'react-native' +import {useQuery, useMutation} from '@tanstack/react-query' + +import {IS_DEV} from '#/env' +import {api} from '#/state/purchases/subscriptions/api' +import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types' + +export function useSubscriptionGroup(group: SubscriptionGroupId) { + return useQuery<{ offerings: Offering[] }>({ + queryKey: ['subscription-group', group], + async queryFn() { + const { data, error } = await api(`/subscriptions/${group}?platform=web`).json() + if (error || !data) { + throw new Error('Failed to fetch subscription group') + } + + const { offerings } = data + + return { + offerings: offerings.map((o: any) => ({ + id: o.id as OfferingId, + platform: PlatformId.Web, + package: { + priceId: o.productId, + }, + })), + } + } + }) +} + +export function usePurchaseOffering() { + return useMutation({ + async mutationFn({ + did, + email, + offering, + }: { did: string, email: string, offering: Offering }) { + if (offering.platform !== PlatformId.Web) { + throw new Error('Unsupported platform') + } + + const {data, error} = await api<{ + checkoutUrl: string + }>('/checkout/create', { + method: 'POST', + json: { + did, + email, + price: offering.package.priceId, + redirectUrl: IS_DEV + ? `http://localhost:19006/subscriptions` + : `https://bsky.app/subscriptions`, + }, + }).json() + + if (error || !data) { + throw error + } + + Linking.openURL(data.checkoutUrl) + } + }) +} diff --git a/src/state/purchases/subscriptions/util.ts b/src/state/purchases/subscriptions/util.ts new file mode 100644 index 0000000000..27ee4789b1 --- /dev/null +++ b/src/state/purchases/subscriptions/util.ts @@ -0,0 +1,12 @@ +import {OfferingId} from '#/state/purchases/subscriptions/types'; + +export function parseOfferingId(id: string): OfferingId { + switch (id) { + case 'coreMonthly': + return OfferingId.CoreMonthly; + case 'coreAnnual': + return OfferingId.CoreAnnual; + default: + throw new Error(`Unknown offering id: ${id}`); + } +}