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}`);
+ }
+}