Clean up subs hooks and state
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import {RawSubscriptionObject} from '#/state/purchases/subscriptions/api/types'
|
||||
|
||||
export async function getSubscriptions({
|
||||
did,
|
||||
platform,
|
||||
}: {
|
||||
did: string
|
||||
platform: 'web' | 'ios' | 'android'
|
||||
}) {
|
||||
const res = await fetch(
|
||||
`https://bsky-purchases.ngrok.io/subscriptions?user=${did}&platform=${platform}`,
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('error fetching subscriptions', res.status, await res.text())
|
||||
return []
|
||||
}
|
||||
|
||||
const {subscriptions} = await res.json()
|
||||
|
||||
return subscriptions as RawSubscriptionObject[]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {SubscriptionId} from '#/state/purchases/subscriptions/types'
|
||||
|
||||
export type RawSubscriptionObjectBase<T> = T & {
|
||||
id: SubscriptionId
|
||||
storeId: string
|
||||
lookupKey: string
|
||||
checkoutId: string
|
||||
interval: 'monthly' | 'annual'
|
||||
autoRenew: boolean
|
||||
status:
|
||||
| 'trialing'
|
||||
| 'active'
|
||||
| 'expired'
|
||||
| 'in_grace_period'
|
||||
| 'in_billing_retry'
|
||||
| 'paused'
|
||||
| 'unknown'
|
||||
| 'incomplete'
|
||||
| null
|
||||
entitlements: {
|
||||
created_at: number
|
||||
display_name: string
|
||||
id: string
|
||||
lookup_key: string
|
||||
object: 'entitlement'
|
||||
project_id: string
|
||||
}[]
|
||||
startedAt: number | null
|
||||
periodStart: number | null
|
||||
periodEnd: number | null
|
||||
}
|
||||
|
||||
export type RawSubscriptionObject =
|
||||
| RawSubscriptionObjectBase<{
|
||||
platform: 'android'
|
||||
provider: 'play_store'
|
||||
}>
|
||||
| RawSubscriptionObjectBase<{
|
||||
platform: 'ios'
|
||||
provider: 'app_store'
|
||||
}>
|
||||
| RawSubscriptionObjectBase<{
|
||||
platform: 'web'
|
||||
provider: 'stripe'
|
||||
price: number
|
||||
}>
|
||||
@@ -1,14 +1,13 @@
|
||||
import Purchases, {PurchasesPackage} from 'react-native-purchases'
|
||||
import Purchases from 'react-native-purchases'
|
||||
import {useMutation, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {getSubscriptions} from '#/state/purchases/subscriptions/api'
|
||||
import {
|
||||
Subscription,
|
||||
Subscriptions,
|
||||
} from '#/state/purchases/subscriptions/types'
|
||||
import {
|
||||
identifierToSubscriptionInfo,
|
||||
organizeSubscriptionsByTier,
|
||||
} from '#/state/purchases/subscriptions/util'
|
||||
import {organizeSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export function useAvailableSubscriptions() {
|
||||
@@ -19,40 +18,51 @@ export function useAvailableSubscriptions() {
|
||||
queryKey: ['availableSubscriptions', did],
|
||||
async queryFn() {
|
||||
Purchases.logIn(did)
|
||||
const offerings = await Purchases.getOfferings()
|
||||
const tierOfferings = Object.values(offerings.all).filter(offering => {
|
||||
return offering.identifier.includes('bsky_tier')
|
||||
Purchases.setEmail(currentAccount!.email!)
|
||||
|
||||
const platform = isAndroid ? 'android' : ('ios' as const)
|
||||
const rawSubscriptions = await getSubscriptions({
|
||||
did: currentAccount!.did,
|
||||
platform,
|
||||
})
|
||||
const packages = tierOfferings.flatMap(
|
||||
offering => offering.availablePackages,
|
||||
const platformSubscriptions = rawSubscriptions
|
||||
.filter(s => s.platform !== 'web')
|
||||
.filter(s => s.platform === platform)
|
||||
const lookupKeys = Array.from(
|
||||
new Set(platformSubscriptions.map(s => s.lookupKey)),
|
||||
)
|
||||
return organizeSubscriptionsByTier(normalizePackages(packages))
|
||||
const products = await Purchases.getProducts(lookupKeys)
|
||||
const subscriptions: Subscription[] = platformSubscriptions
|
||||
.map(sub => {
|
||||
const productData = products.find(p => p.identifier === sub.storeId)
|
||||
if (!productData) return undefined
|
||||
const subscription = {
|
||||
...sub,
|
||||
price: {
|
||||
value: productData.price * 100, // convert to cents
|
||||
formatted: productData.priceString,
|
||||
},
|
||||
product: {
|
||||
platform,
|
||||
data: productData,
|
||||
},
|
||||
}
|
||||
return subscription
|
||||
})
|
||||
.filter(Boolean) as Subscription[]
|
||||
|
||||
return organizeSubscriptionsByTier(subscriptions)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePurchaseSubscription() {
|
||||
return useMutation({
|
||||
async mutationFn(pkg: PurchasesPackage) {
|
||||
return Purchases.purchasePackage(pkg)
|
||||
async mutationFn(product: Subscription['product']) {
|
||||
if (product.platform === 'web') {
|
||||
throw new Error('Cannot purchase web subscription on native')
|
||||
}
|
||||
return Purchases.purchaseStoreProduct(product.data)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function normalizePackages(pkgs: PurchasesPackage[]): Subscription[] {
|
||||
return pkgs
|
||||
.map(p => {
|
||||
const info = identifierToSubscriptionInfo(p.product.identifier)
|
||||
if (!info) return
|
||||
const subscription: Subscription = {
|
||||
info,
|
||||
price: {
|
||||
formatted: p.product.priceString,
|
||||
value: p.product.price,
|
||||
},
|
||||
raw: p,
|
||||
}
|
||||
return subscription
|
||||
})
|
||||
.filter(Boolean) as Subscription[]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,12 @@ import {Linking} from 'react-native'
|
||||
import {useMutation, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {useCurrencyFormatter} from '#/lib/currency'
|
||||
import {getSubscriptions} from '#/state/purchases/subscriptions/api'
|
||||
import {
|
||||
Subscription,
|
||||
Subscriptions,
|
||||
} from '#/state/purchases/subscriptions/types'
|
||||
import {
|
||||
StripePrice,
|
||||
StripeProduct,
|
||||
} from '#/state/purchases/subscriptions/types/stripe'
|
||||
import {
|
||||
identifierToSubscriptionInfo,
|
||||
organizeSubscriptionsByTier,
|
||||
} from '#/state/purchases/subscriptions/util'
|
||||
import {organizeSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {BSKY_PURCHASES_API} from '#/env'
|
||||
|
||||
@@ -24,55 +18,60 @@ export function useAvailableSubscriptions() {
|
||||
return useQuery<Subscriptions>({
|
||||
queryKey: ['availableSubscriptions', currentAccount!.did],
|
||||
async queryFn() {
|
||||
const res = await fetch(`${BSKY_PURCHASES_API}/getWebOffers`).then(res =>
|
||||
res.json(),
|
||||
)
|
||||
return organizeSubscriptionsByTier(
|
||||
normalizeProducts(res, {currencyFormatter}),
|
||||
const rawSubscriptions = await getSubscriptions({
|
||||
did: currentAccount!.did,
|
||||
platform: 'web',
|
||||
})
|
||||
const platformSubscriptions = rawSubscriptions.filter(
|
||||
s => s.platform === 'web',
|
||||
)
|
||||
const subscriptions = platformSubscriptions.map(sub => {
|
||||
const subscription: Subscription = {
|
||||
...sub,
|
||||
price: {
|
||||
value: sub.price,
|
||||
formatted: currencyFormatter.format(sub.price / 100),
|
||||
},
|
||||
product: {
|
||||
platform: 'web',
|
||||
data: sub.checkoutId,
|
||||
},
|
||||
}
|
||||
return subscription
|
||||
})
|
||||
|
||||
return organizeSubscriptionsByTier(subscriptions)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePurchaseSubscription() {
|
||||
const {currentAccount} = useSession()
|
||||
return useMutation({
|
||||
async mutationFn(priceObject: any) {
|
||||
Linking.openURL(
|
||||
`${BSKY_PURCHASES_API}/initCheckout/${priceObject.price_id}`,
|
||||
)
|
||||
async mutationFn(product: Subscription['product']) {
|
||||
if (product.platform !== 'web') {
|
||||
throw new Error('Cannot purchase native subscription on web')
|
||||
}
|
||||
if (!currentAccount || !currentAccount.email) {
|
||||
throw new Error('No account or email')
|
||||
}
|
||||
|
||||
const {checkoutUrl} = await fetch(
|
||||
`${BSKY_PURCHASES_API}/createCheckout`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
price: product.data,
|
||||
// TODO should NOT use query, use auth and our db state
|
||||
user: currentAccount.did,
|
||||
email: currentAccount.email,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
).then(res => res.json())
|
||||
Linking.openURL(checkoutUrl)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeProducts(
|
||||
products: {product: StripeProduct; price: StripePrice}[],
|
||||
options: {
|
||||
currencyFormatter: ReturnType<typeof useCurrencyFormatter>
|
||||
},
|
||||
): Subscription[] {
|
||||
return products
|
||||
.map(({product, price}) => {
|
||||
const info = identifierToSubscriptionInfo(product.id)
|
||||
|
||||
if (!info) return
|
||||
|
||||
const priceObj =
|
||||
price.currency_options[options.currencyFormatter.currency]
|
||||
const value = priceObj.unit_amount
|
||||
const formatted = options.currencyFormatter.format(value / 100)
|
||||
|
||||
const subscription: Subscription = {
|
||||
info,
|
||||
price: {
|
||||
value,
|
||||
formatted,
|
||||
},
|
||||
raw: {
|
||||
price_id: price.id,
|
||||
},
|
||||
}
|
||||
|
||||
return subscription
|
||||
})
|
||||
.filter(Boolean) as Subscription[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {PurchasesStoreProduct} from 'react-native-purchases'
|
||||
|
||||
import {RawSubscriptionObjectBase} from '#/state/purchases/subscriptions/api/types'
|
||||
|
||||
export enum SubscriptionId {
|
||||
Tier0MonthlyAuto = '0:monthly:auto',
|
||||
Tier0AnnualAuto = '0:annual:auto',
|
||||
Tier1MonthlyAuto = '1:monthly:auto',
|
||||
Tier1AnnualAuto = '1:annual:auto',
|
||||
Tier2MonthlyAuto = '2:monthly:auto',
|
||||
Tier2AnnualAuto = '2:annual:auto',
|
||||
}
|
||||
|
||||
export type Subscription =
|
||||
| RawSubscriptionObjectBase<{
|
||||
platform: 'android'
|
||||
provider: 'play_store'
|
||||
price: {
|
||||
value: number
|
||||
formatted: string
|
||||
}
|
||||
product: {
|
||||
platform: 'android'
|
||||
data: PurchasesStoreProduct
|
||||
}
|
||||
}>
|
||||
| RawSubscriptionObjectBase<{
|
||||
platform: 'ios'
|
||||
provider: 'app_store'
|
||||
price: {
|
||||
value: number
|
||||
formatted: string
|
||||
}
|
||||
product: {
|
||||
platform: 'ios'
|
||||
data: PurchasesStoreProduct
|
||||
}
|
||||
}>
|
||||
| RawSubscriptionObjectBase<{
|
||||
platform: 'web'
|
||||
provider: 'stripe'
|
||||
price: {
|
||||
value: number
|
||||
formatted: string
|
||||
}
|
||||
product: {
|
||||
platform: 'web'
|
||||
data: string
|
||||
}
|
||||
}>
|
||||
|
||||
export type Subscriptions = {
|
||||
monthly: Subscription[]
|
||||
annual: Subscription[]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export enum SubscriptionIdentifier {
|
||||
Tier0MonthlyAuto = '0:monthly:auto',
|
||||
Tier0AnnualAuto = '0:annual:auto',
|
||||
Tier1MonthlyAuto = '1:monthly:auto',
|
||||
Tier1AnnualAuto = '1:annual:auto',
|
||||
Tier2MonthlyAuto = '2:monthly:auto',
|
||||
Tier2AnnualAuto = '2:annual:auto',
|
||||
}
|
||||
|
||||
export type SubscriptionInfo = {
|
||||
identifier: SubscriptionIdentifier
|
||||
interval: 'monthly' | 'annual'
|
||||
autoRenew: boolean
|
||||
}
|
||||
|
||||
export type Subscription = {
|
||||
info: SubscriptionInfo
|
||||
price: {
|
||||
formatted: string
|
||||
value: number
|
||||
}
|
||||
raw: any
|
||||
}
|
||||
|
||||
export type Subscriptions = {
|
||||
monthly: Subscription[]
|
||||
annual: Subscription[]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
export type StripeProduct = {
|
||||
// main stuff
|
||||
id: string
|
||||
default_price: string // ID of Price obj
|
||||
name: string
|
||||
active: boolean
|
||||
// other stuff
|
||||
object: string
|
||||
attributes: unknown[]
|
||||
created: number
|
||||
description: string | null
|
||||
features: unknown[]
|
||||
images: unknown[]
|
||||
livemode: boolean
|
||||
marketing_features: unknown[]
|
||||
metadata: unknown
|
||||
package_dimensions: unknown | null
|
||||
shippable: unknown | null
|
||||
statement_descriptor: unknown | null
|
||||
tax_code: string
|
||||
type: 'service' | string
|
||||
unit_label: unknown | null
|
||||
updated: number
|
||||
url: string | null
|
||||
}
|
||||
|
||||
export type StripePrice = {
|
||||
// main stuff
|
||||
id: string
|
||||
product: string // Product obj associated
|
||||
currency_options: {
|
||||
[key: string]: {
|
||||
custom_unit_amount: number | null
|
||||
tax_behavior: 'inclusive' | string
|
||||
unit_amount: number
|
||||
unit_amount_decimal: string
|
||||
}
|
||||
}
|
||||
active: boolean
|
||||
recurring: {
|
||||
interval: 'month' | 'year' | string
|
||||
interval_count: number
|
||||
usage_type: 'licensed' | string
|
||||
}
|
||||
object: 'price' | string
|
||||
type: 'recurring' | string
|
||||
// other stuff
|
||||
metadata: unknown
|
||||
unit_amount: number // default Price in cents
|
||||
currency: string // default currency
|
||||
tax_behavior: 'inclusive' | string // default tax behavior
|
||||
billing_scheme: 'per_unit' | string
|
||||
created: number
|
||||
custom_unit_amount: number | null
|
||||
livemode: boolean
|
||||
lookup_key: string | null
|
||||
nickname: string | null
|
||||
tiers_mode: string | null
|
||||
transform_quantity: string | null
|
||||
}
|
||||
@@ -1,114 +1,4 @@
|
||||
import {
|
||||
Subscription,
|
||||
SubscriptionIdentifier,
|
||||
SubscriptionInfo,
|
||||
Subscriptions,
|
||||
} from '#/state/purchases/subscriptions/types'
|
||||
|
||||
export function identifierToSubscriptionInfo(
|
||||
identifier: string,
|
||||
): SubscriptionInfo | undefined {
|
||||
switch (identifier) {
|
||||
/*
|
||||
* Android
|
||||
*/
|
||||
case 'bsky_tier_0:monthly-auto': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier0MonthlyAuto,
|
||||
interval: 'monthly',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'bsky_tier_0:annual-auto': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier0AnnualAuto,
|
||||
interval: 'annual',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'bsky_tier_1:monthly-auto': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier1MonthlyAuto,
|
||||
interval: 'monthly',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'bsky_tier_1:annual-auto': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier1AnnualAuto,
|
||||
interval: 'annual',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'bsky_tier_2:monthly-auto': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier2MonthlyAuto,
|
||||
interval: 'monthly',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'bsky_tier_2:annual-auto': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier2AnnualAuto,
|
||||
interval: 'annual',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Stripe
|
||||
*/
|
||||
case 'prod_R2eNjNa6mB1Jlu': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier0MonthlyAuto,
|
||||
interval: 'monthly',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'prod_R37Zg28EeQ9XAz': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier0AnnualAuto,
|
||||
interval: 'annual',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'prod_R67eiEMuIf59w7': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier1MonthlyAuto,
|
||||
interval: 'monthly',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'prod_R67fjfexQ7THQ0': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier1AnnualAuto,
|
||||
interval: 'annual',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'prod_R67ftsSVu2U1D6': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier2MonthlyAuto,
|
||||
interval: 'monthly',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
case 'prod_R67gClEZy1cry2': {
|
||||
return {
|
||||
identifier: SubscriptionIdentifier.Tier2AnnualAuto,
|
||||
interval: 'annual',
|
||||
autoRenew: true,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Fallback
|
||||
*/
|
||||
default: {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
import {Subscription, Subscriptions} from './types'
|
||||
|
||||
export function organizeSubscriptionsByTier(
|
||||
subscriptions: Subscription[],
|
||||
@@ -119,18 +9,18 @@ export function organizeSubscriptionsByTier(
|
||||
}
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const {info} = subscription
|
||||
result[info.interval].push(subscription)
|
||||
const {interval} = subscription
|
||||
result[interval].push(subscription)
|
||||
}
|
||||
|
||||
result.monthly = result.monthly.sort((a, b) => {
|
||||
const _a = parseInt(a.info.identifier.slice(0, 1))
|
||||
const _b = parseInt(b.info.identifier.slice(0, 1))
|
||||
const _a = parseInt(a.id.slice(0, 1))
|
||||
const _b = parseInt(b.id.slice(0, 1))
|
||||
return _a - _b
|
||||
})
|
||||
result.annual = result.annual.sort((a, b) => {
|
||||
const _a = parseInt(a.info.identifier.slice(0, 1))
|
||||
const _b = parseInt(b.info.identifier.slice(0, 1))
|
||||
const _a = parseInt(a.id.slice(0, 1))
|
||||
const _b = parseInt(b.id.slice(0, 1))
|
||||
return _a - _b
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user