Update queries
This commit is contained in:
@@ -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 (
|
||||
<Dialog.Outer control={control}>
|
||||
<DialogInner />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner() {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
const t = useTheme()
|
||||
|
||||
const [offeringId, setOfferingId] = React.useState<OfferingId>(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 (
|
||||
<Dialog.Inner label={_(msg`Bluesky Plus`)} style={[web({maxWidth: 1000})]}>
|
||||
<View style={[a.flex_1, a.gap_xl, gtMobile && a.flex_row]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.gap_md,
|
||||
a.rounded_xs,
|
||||
a.relative,
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<GradientFill gradient={tokens.gradients.nordic} rotate="270deg" />
|
||||
<View style={[a.px_xl, a.py_4xl, a.gap_md]}>
|
||||
<MarketingBlurb />
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.flex_1, a.justify_end, a.gap_md]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>Early bird discount!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_lg, a.leading_snug]}>
|
||||
<Trans>Support Bluesky now and get a 25% discount for a year</Trans>
|
||||
</Text>
|
||||
<Toggle.Group
|
||||
type="radio"
|
||||
label={_(msg`Choose plan`)}
|
||||
values={[offeringId]}
|
||||
onChange={values => setOfferingId(parseOfferingId(values[0]))}
|
||||
style={[a.w_full, a.gap_md, a.my_xl]}>
|
||||
<Toggle.Item name={OfferingId.CoreMonthly} label={_(msg`Monthly plan`)}>
|
||||
{({selected}) => (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_sm,
|
||||
a.rounded_sm,
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.border,
|
||||
selected
|
||||
? {borderColor: t.palette.primary_500}
|
||||
: t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Text style={[a.text_lg, a.leading_normal, a.font_bold]}>
|
||||
<Trans>Monthly plan</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_lg, a.leading_normal, a.font_bold]}>
|
||||
<Trans>$6/month</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_normal,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Billed monthly, cancel anytime. $8/month after 12 months
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
<Toggle.Item name={OfferingId.CoreAnnual} label={_(msg`Annual plan`)}>
|
||||
{({selected}) => (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_sm,
|
||||
a.rounded_sm,
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.border,
|
||||
selected
|
||||
? {borderColor: t.palette.primary_500}
|
||||
: t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Text style={[a.text_lg, a.leading_normal, a.font_bold]}>
|
||||
<Trans>Annual plan</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_lg, a.leading_normal, a.font_bold]}>
|
||||
<Trans>$60/year</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_normal,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Billed annually, renews at $80/year</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
</Toggle.Group>
|
||||
<Button
|
||||
label={_(msg`Subscribe`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={onPressSubscribe}
|
||||
disabled={isPending}>
|
||||
<ButtonText>
|
||||
<Trans>Subscribe</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : ChevronRightIcon} />
|
||||
</Button>
|
||||
<Text>
|
||||
<InlineLinkText to="#" label="TODO REPLACE">
|
||||
Terms and Conditions
|
||||
</InlineLinkText>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText to="#" label="TODO REPLACE">
|
||||
Privacy Policy
|
||||
</InlineLinkText>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText to="#" label="TODO REPLACE">
|
||||
EULA
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.Inner>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketingBlurb() {
|
||||
return (
|
||||
<>
|
||||
<BlueskyPlusLogo width={200} fill="white" style={[a.mt_5xl]} />
|
||||
|
||||
<Text style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
<Trans>
|
||||
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!
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={[a.text_lg, a.leading_normal, a.font_bold, {color: 'white'}]}>
|
||||
<Trans>What you'll get now:</Trans>
|
||||
</Text>
|
||||
<View role="list" style={[a.gap_xs]}>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>A supporter badge on your profile</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>A warm fuzzy feeling knowing you helped us out</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[a.text_lg, a.leading_normal, a.font_bold, {color: 'white'}]}>
|
||||
<Trans>What you'll get soon: </Trans>
|
||||
</Text>
|
||||
<View role="list" style={[a.gap_xs]}>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>Custom profile colors</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>Longer and higher quality videos</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>Custom app icons</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
<ButtonText>Subscribe</ButtonText>
|
||||
<ButtonIcon icon={Plus} />
|
||||
</Button>
|
||||
<SubscriptionsDialog control={control} />
|
||||
<BlueskyPlus control={control} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }) {
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user