fix: bug
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
StatusBar as RNStatusBar,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
@@ -22,7 +23,7 @@ import { CheckMarkIcon } from '@/components/icon/checkMark'
|
||||
import PointsDrawer from '@/components/drawer/PointsDrawer'
|
||||
import Dropdown from '@/components/ui/dropdown'
|
||||
import GradientText from '@/components/GradientText'
|
||||
import { useUserBalance } from '@/hooks/use-user-balance'
|
||||
import { useMembership } from '@/hooks/use-membership'
|
||||
|
||||
// 使用唯一 id 的 PointsIcon,避免与其他页面的图标 id 冲突
|
||||
const MembershipPointsIcon = () => {
|
||||
@@ -65,63 +66,53 @@ export default function MembershipScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanType>('pro')
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 获取积分余额
|
||||
const { balance } = useUserBalance()
|
||||
|
||||
// 订阅计划数据(使用国际化)
|
||||
const plans: Plan[] = [
|
||||
{
|
||||
id: 'plus',
|
||||
name: 'Plus',
|
||||
price: 9,
|
||||
points: 750,
|
||||
features: [
|
||||
t('membership.features.points750'),
|
||||
t('membership.features.textToVideo'),
|
||||
t('membership.features.superClearImage'),
|
||||
t('membership.features.superDiscount'),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: 29,
|
||||
recommended: true,
|
||||
points: 1080,
|
||||
features: [
|
||||
t('membership.features.points1080'),
|
||||
t('membership.features.textToVideo'),
|
||||
t('membership.features.superClearImage'),
|
||||
t('membership.features.superDiscount'),
|
||||
t('membership.features.sora2ProTemplate'),
|
||||
t('membership.features.removeWatermark'),
|
||||
t('membership.features.allTemplates'),
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'plus-premium',
|
||||
name: 'Plus',
|
||||
price: 49,
|
||||
points: 1500,
|
||||
features: [
|
||||
t('membership.features.points1500'),
|
||||
t('membership.features.textToVideo'),
|
||||
t('membership.features.superClearImage'),
|
||||
t('membership.features.superDiscount'),
|
||||
// 使用 useMembership hook
|
||||
const {
|
||||
creditPlans,
|
||||
creditBalance,
|
||||
selectedPlanIndex,
|
||||
setSelectedPlanIndex,
|
||||
isLoadingSubscriptions,
|
||||
isStripePricingLoading,
|
||||
createSubscription,
|
||||
upgradeSubscription,
|
||||
restoreSubscription,
|
||||
rechargeToken,
|
||||
activeAuthSubscription,
|
||||
hasActiveSubscription,
|
||||
stripePricingData,
|
||||
} = useMembership()
|
||||
|
||||
// 映射 API 数据到 UI 格式
|
||||
const plans: Plan[] = creditPlans.map((plan, index) => ({
|
||||
id: `plan-${index}` as PlanType,
|
||||
name: index === 0 ? 'Plus' : index === 1 ? 'Pro' : 'Plus',
|
||||
price: plan.amountInCents / 100,
|
||||
recommended: plan.popular,
|
||||
points: plan.credits,
|
||||
features: [
|
||||
t('membership.features.points750'),
|
||||
t('membership.features.textToVideo'),
|
||||
t('membership.features.superClearImage'),
|
||||
t('membership.features.superDiscount'),
|
||||
...(index > 0 ? [
|
||||
t('membership.features.sora2ProTemplate'),
|
||||
t('membership.features.removeWatermark'),
|
||||
t('membership.features.allTemplates'),
|
||||
] : []),
|
||||
...(index > 1 ? [
|
||||
t('membership.features.higherQuality'),
|
||||
t('membership.features.prioritySupport'),
|
||||
]
|
||||
},
|
||||
]
|
||||
] : []),
|
||||
],
|
||||
}))
|
||||
|
||||
const selectedPlan = plans[selectedPlanIndex] || plans[0]
|
||||
|
||||
// 下拉菜单选项
|
||||
const menuOptions = [
|
||||
@@ -146,18 +137,52 @@ export default function MembershipScreen() {
|
||||
]
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||
|
||||
// 处理订阅按钮点击
|
||||
const handleSubscribe = () => {
|
||||
if (!agreed || !selectedPlan) return
|
||||
|
||||
const planData = creditPlans[selectedPlanIndex]
|
||||
if (!planData) return
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
upgradeSubscription({ credits: planData.credits })
|
||||
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
|
||||
restoreSubscription()
|
||||
} else {
|
||||
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
|
||||
if (pricingItem) {
|
||||
createSubscription({
|
||||
priceId: pricingItem.price_id,
|
||||
productId: pricingItem.product_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前选中计划的信息
|
||||
const currentPlan = plans.find(plan => plan.id === selectedPlan) || plans[1]
|
||||
|
||||
const currentPlan = selectedPlan
|
||||
|
||||
// 计算进度条百分比:当前计划积分 / 最高计划积分
|
||||
const maxPoints = Math.max(...plans.map(plan => plan.points))
|
||||
const progressPercentage = (currentPlan.points / maxPoints) * 100
|
||||
const progressPercentage = (currentPlan?.points / maxPoints) * 100
|
||||
|
||||
// 加载状态
|
||||
if (isStripePricingLoading || creditPlans.length === 0) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar style="light" />
|
||||
<View style={[styles.container, styles.loadingContainer]}>
|
||||
<ActivityIndicator size="large" color="#FF9966" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar
|
||||
barStyle="light-content"
|
||||
<RNStatusBar
|
||||
barStyle="light-content"
|
||||
backgroundColor="#090A0B"
|
||||
translucent={Platform.OS === 'android'}
|
||||
/>
|
||||
@@ -172,13 +197,13 @@ export default function MembershipScreen() {
|
||||
</Pressable>
|
||||
<View style={styles.headerSpacer} />
|
||||
<View style={styles.pointsContainer}>
|
||||
<Pressable
|
||||
<Pressable
|
||||
style={styles.pointsPill}
|
||||
onPress={() => setPointsDrawerVisible(true)}
|
||||
>
|
||||
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
||||
<MembershipPointsIcon />
|
||||
<Text style={styles.pointsValue}>{balance}</Text>
|
||||
<Text style={styles.pointsValue}>{creditBalance}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.settingsButtonContainer}>
|
||||
<Dropdown
|
||||
@@ -253,7 +278,7 @@ export default function MembershipScreen() {
|
||||
{/* 订阅计划卡片 */}
|
||||
<View style={styles.plansContainer}>
|
||||
{plans.map((plan, index) => {
|
||||
const isSelected = selectedPlan === plan.id
|
||||
const isSelected = selectedPlanIndex === index
|
||||
return (
|
||||
<Pressable
|
||||
key={plan.id}
|
||||
@@ -261,7 +286,7 @@ export default function MembershipScreen() {
|
||||
styles.planCardWrapper,
|
||||
index > 0 && styles.planCardSpacing,
|
||||
]}
|
||||
onPress={() => setSelectedPlan(plan.id)}
|
||||
onPress={() => setSelectedPlanIndex(index)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<LinearGradient
|
||||
@@ -365,15 +390,16 @@ export default function MembershipScreen() {
|
||||
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
||||
{/* 立即开通按钮 */}
|
||||
<Pressable
|
||||
disabled={!agreed}
|
||||
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading}
|
||||
style={styles.subscribeButtonPressable}
|
||||
onPress={handleSubscribe}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={currentPlan.recommended
|
||||
colors={currentPlan.recommended
|
||||
? ['#9966FF', '#FF6699', '#FF9966']
|
||||
: ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF']
|
||||
}
|
||||
locations={currentPlan.recommended
|
||||
locations={currentPlan.recommended
|
||||
? [0.0015, 0.4985, 0.9956]
|
||||
: [0, 0.33, 0.66, 1]
|
||||
}
|
||||
@@ -381,12 +407,14 @@ export default function MembershipScreen() {
|
||||
end={{ x: 0, y: 0 }}
|
||||
style={[
|
||||
styles.subscribeButton,
|
||||
!agreed && styles.subscribeButtonDisabled,
|
||||
(!agreed || isLoadingSubscriptions || isStripePricingLoading) && styles.subscribeButtonDisabled,
|
||||
]}
|
||||
>
|
||||
|
||||
|
||||
{isLoadingSubscriptions || isStripePricingLoading ? (
|
||||
<ActivityIndicator color="#F5F5F5" />
|
||||
) : (
|
||||
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
{/* 协议复选框 */}
|
||||
@@ -423,9 +451,10 @@ export default function MembershipScreen() {
|
||||
<PointsDrawer
|
||||
visible={pointsDrawerVisible}
|
||||
onClose={() => setPointsDrawerVisible(false)}
|
||||
totalPoints={balance}
|
||||
totalPoints={creditBalance}
|
||||
subscriptionPoints={0}
|
||||
topUpPoints={0}
|
||||
onRecharge={(amount) => rechargeToken(amount)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
@@ -770,5 +799,9 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '400',
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
404
hooks/use-membership.test.ts
Normal file
404
hooks/use-membership.test.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react-native'
|
||||
import { useMembership } from './use-membership'
|
||||
import { subscription, useSession } from '@/lib/auth'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { Linking, Alert } from 'react-native'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
subscription: {
|
||||
plans: jest.fn(),
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
upgrade: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
cancel: jest.fn(),
|
||||
credit: {
|
||||
topup: jest.fn(),
|
||||
},
|
||||
},
|
||||
useSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('expo-router', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('react-native', () => ({
|
||||
Linking: {
|
||||
openURL: jest.fn(),
|
||||
},
|
||||
Alert: {
|
||||
alert: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockUseQuery = jest.fn()
|
||||
const mockUseMutation = jest.fn()
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: (...args: any[]) => mockUseQuery(args[0]),
|
||||
useMutation: (...args: any[]) => mockUseMutation(args[0]),
|
||||
}))
|
||||
|
||||
describe('useMembership', () => {
|
||||
const mockRouter = { push: jest.fn() }
|
||||
const mockSession = {
|
||||
user: { id: 'user-123' },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(useRouter as jest.Mock).mockReturnValue(mockRouter)
|
||||
;(useSession as jest.Mock).mockReturnValue({ data: mockSession })
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
})
|
||||
|
||||
mockUseMutation.mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
describe('获取 Stripe 定价表数据', () => {
|
||||
it('应该成功获取定价表数据', async () => {
|
||||
const mockPricingData = {
|
||||
pricing_table_items: [
|
||||
{
|
||||
amount: '1000',
|
||||
recurring: { interval: 'month' },
|
||||
metadata: { grant_token: '1000' },
|
||||
is_highlight: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockPricingData,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMembership())
|
||||
|
||||
expect(result.current.stripePricingData).toEqual(mockPricingData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('获取用户订阅列表', () => {
|
||||
it('应该成功获取订阅列表', async () => {
|
||||
const mockSubscriptions = [
|
||||
{
|
||||
type: 'licenced',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: false,
|
||||
stripeSubscriptionId: 'sub-123',
|
||||
},
|
||||
]
|
||||
|
||||
let queryCallCount = 0
|
||||
mockUseQuery.mockImplementation((config) => {
|
||||
queryCallCount++
|
||||
if (queryCallCount === 2) {
|
||||
return {
|
||||
data: mockSubscriptions,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMembership())
|
||||
|
||||
expect(result.current.isLoadingSubscriptions).toBe(false)
|
||||
})
|
||||
|
||||
it('应该在没有用户会话时不获取订阅', () => {
|
||||
;(useSession as jest.Mock).mockReturnValue({ data: null })
|
||||
|
||||
mockUseQuery.mockImplementation((config) => {
|
||||
if (config.enabled === false) {
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
renderHook(() => useMembership())
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('创建订阅', () => {
|
||||
it('应该成功创建订阅并打开支付链接', async () => {
|
||||
const mockUrl = 'https://checkout.stripe.com/test'
|
||||
;(subscription.create as jest.Mock).mockResolvedValue({
|
||||
data: { url: mockUrl },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mutateFn = jest.fn(async (variables) => {
|
||||
const { data, error } = await subscription.create({
|
||||
priceId: variables.priceId,
|
||||
productId: variables.productId,
|
||||
successUrl: expect.stringContaining('/membership'),
|
||||
cancelUrl: expect.stringContaining('/membership?canceled=true'),
|
||||
})
|
||||
if (error) throw new Error(error.message)
|
||||
if (data?.url) await Linking.openURL(data.url)
|
||||
return data
|
||||
})
|
||||
|
||||
mockUseMutation.mockReturnValue({
|
||||
mutate: mutateFn,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMembership())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createSubscription({
|
||||
priceId: 'price-123',
|
||||
productId: 'prod-123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mutateFn).toHaveBeenCalled()
|
||||
expect(Linking.openURL).toHaveBeenCalledWith(mockUrl)
|
||||
})
|
||||
|
||||
it('应该处理创建订阅错误', async () => {
|
||||
;(subscription.create as jest.Mock).mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Failed to create subscription' },
|
||||
})
|
||||
|
||||
const mutateFn = jest.fn(async (variables) => {
|
||||
try {
|
||||
const { data, error } = await subscription.create(variables)
|
||||
if (error) throw new Error(error.message)
|
||||
return data
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '订阅创建失败')
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
mockUseMutation.mockReturnValue({
|
||||
mutate: mutateFn,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMembership())
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.createSubscription({
|
||||
priceId: 'price-123',
|
||||
productId: 'prod-123',
|
||||
})
|
||||
} catch (e) {
|
||||
// Expected error
|
||||
}
|
||||
})
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith('错误', '订阅创建失败')
|
||||
})
|
||||
})
|
||||
|
||||
describe('升级订阅', () => {
|
||||
it('应该成功升级订阅', async () => {
|
||||
const mockSubscriptions = [
|
||||
{
|
||||
type: 'licenced',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: false,
|
||||
stripeSubscriptionId: 'sub-123',
|
||||
},
|
||||
]
|
||||
|
||||
let queryCallCount = 0
|
||||
mockUseQuery.mockImplementation(() => {
|
||||
queryCallCount++
|
||||
if (queryCallCount === 2) {
|
||||
return {
|
||||
data: mockSubscriptions,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
;(subscription.upgrade as jest.Mock).mockResolvedValue({
|
||||
data: { success: true },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mutateFn = jest.fn()
|
||||
mockUseMutation.mockReturnValue({
|
||||
mutate: mutateFn,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMembership())
|
||||
|
||||
await act(async () => {
|
||||
result.current.upgradeSubscription({ credits: 1000 })
|
||||
})
|
||||
|
||||
expect(mutateFn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('恢复订阅', () => {
|
||||
it('应该成功恢复订阅', async () => {
|
||||
const mockSubscriptions = [
|
||||
{
|
||||
type: 'licenced',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: true,
|
||||
stripeSubscriptionId: 'sub-123',
|
||||
referenceId: 'user-123',
|
||||
},
|
||||
]
|
||||
|
||||
let queryCallCount = 0
|
||||
mockUseQuery.mockImplementation(() => {
|
||||
queryCallCount++
|
||||
if (queryCallCount === 2) {
|
||||
return {
|
||||
data: mockSubscriptions,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
;(subscription.restore as jest.Mock).mockResolvedValue({
|
||||
data: { success: true },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mutateFn = jest.fn()
|
||||
mockUseMutation.mockReturnValue({
|
||||
mutate: mutateFn,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMembership())
|
||||
|
||||
await act(async () => {
|
||||
result.current.restoreSubscription()
|
||||
})
|
||||
|
||||
expect(mutateFn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('充值积分', () => {
|
||||
it('应该成功充值积分并打开支付链接', async () => {
|
||||
const mockUrl = 'https://checkout.stripe.com/topup'
|
||||
const mockSubscriptions = [
|
||||
{
|
||||
type: 'metered',
|
||||
priceId: 'price-metered-123',
|
||||
},
|
||||
]
|
||||
|
||||
let queryCallCount = 0
|
||||
mockUseQuery.mockImplementation(() => {
|
||||
queryCallCount++
|
||||
if (queryCallCount === 2) {
|
||||
return {
|
||||
data: mockSubscriptions,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
;(subscription.credit.topup as jest.Mock).mockResolvedValue({
|
||||
data: { url: mockUrl },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mutateFn = jest.fn(async (amount) => {
|
||||
const { data, error } = await subscription.credit.topup({
|
||||
amount,
|
||||
priceId: 'price-metered-123',
|
||||
successUrl: expect.stringContaining('/membership'),
|
||||
cancelUrl: expect.stringContaining('/membership?canceled=true'),
|
||||
})
|
||||
if (error) throw new Error(error.message)
|
||||
if (data?.url) await Linking.openURL(data.url)
|
||||
return data
|
||||
})
|
||||
|
||||
mockUseMutation.mockReturnValue({
|
||||
mutate: mutateFn,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMembership())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.rechargeToken(1000)
|
||||
})
|
||||
|
||||
expect(mutateFn).toHaveBeenCalledWith(1000)
|
||||
expect(Linking.openURL).toHaveBeenCalledWith(mockUrl)
|
||||
})
|
||||
|
||||
it('应该处理无效充值金额', async () => {
|
||||
const mutateFn = jest.fn(async (amount) => {
|
||||
try {
|
||||
if (!amount || amount <= 0) throw new Error('充值金额无效')
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '充值失败')
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
mockUseMutation.mockReturnValue({
|
||||
mutate: mutateFn,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useMembership())
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.rechargeToken(0)
|
||||
} catch (e) {
|
||||
// Expected error
|
||||
}
|
||||
})
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith('错误', '充值失败')
|
||||
})
|
||||
})
|
||||
})
|
||||
188
hooks/use-membership.ts
Normal file
188
hooks/use-membership.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Linking, Alert } from 'react-native'
|
||||
|
||||
import { subscription, useSession } from '@/lib/auth'
|
||||
import type { SubscriptionListItem } from '@/lib/auth'
|
||||
|
||||
interface CreditPlan {
|
||||
amountInCents: number
|
||||
credits: number
|
||||
popular?: boolean
|
||||
}
|
||||
|
||||
export function useMembership() {
|
||||
const { data: session } = useSession()
|
||||
const [selectedPlanIndex, setSelectedPlanIndex] = useState(0)
|
||||
const [stripePricingData, setStripePricingData] = useState<any>(null)
|
||||
const [authSubscriptions, setAuthSubscriptions] = useState<any>(null)
|
||||
const [isStripePricingLoading, setIsStripePricingLoading] = useState(false)
|
||||
const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false)
|
||||
|
||||
const fetchStripePricing = async () => {
|
||||
setIsStripePricingLoading(true)
|
||||
const { data, error } = await subscription.plans({
|
||||
query: {
|
||||
id: process.env.EXPO_PUBLIC_STRIPE_PRICING_TABLE_ID || '',
|
||||
key: process.env.EXPO_PUBLIC_STRIPE_PRICING_TABLE_KEY || '',
|
||||
},
|
||||
})
|
||||
setIsStripePricingLoading(false)
|
||||
if (!error) setStripePricingData(data)
|
||||
}
|
||||
|
||||
const fetchAuthSubscriptions = async () => {
|
||||
if (!session?.user?.id) return
|
||||
setIsLoadingSubscriptions(true)
|
||||
const { data, error } = await subscription.list({
|
||||
query: { referenceId: session.user.id },
|
||||
})
|
||||
setIsLoadingSubscriptions(false)
|
||||
if (!error) setAuthSubscriptions(data)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStripePricing()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) fetchAuthSubscriptions()
|
||||
}, [session?.user?.id])
|
||||
|
||||
const licensedSubscriptions = authSubscriptions?.filter((sub: SubscriptionListItem) => sub.type === 'licenced') || []
|
||||
const meteredSubscriptions = authSubscriptions?.filter((sub: SubscriptionListItem) => sub.type === 'metered') || []
|
||||
|
||||
const activeAuthSubscription = licensedSubscriptions.find(
|
||||
(sub: any) => sub.status === 'active' || sub.status === 'trialing'
|
||||
)
|
||||
|
||||
const hasActiveSubscription = !!(
|
||||
activeAuthSubscription &&
|
||||
activeAuthSubscription.cancelAtPeriodEnd === false &&
|
||||
activeAuthSubscription.status === 'active'
|
||||
)
|
||||
|
||||
const creditBalance = meteredSubscriptions?.[0]?.creditBalance?.remainingTokenBalance || 0
|
||||
|
||||
const creditPlans = useMemo((): CreditPlan[] => {
|
||||
if (!stripePricingData?.pricing_table_items) return []
|
||||
return stripePricingData.pricing_table_items
|
||||
.filter((item: any) => item.recurring?.interval === 'month')
|
||||
.map((item: any) => ({
|
||||
amountInCents: parseInt(item.amount),
|
||||
credits: parseInt(item.metadata?.grant_token || item.amount),
|
||||
popular: item.is_highlight || item.highlight_text === 'most_popular',
|
||||
}))
|
||||
}, [stripePricingData?.pricing_table_items])
|
||||
|
||||
const selectedPlan = creditPlans[selectedPlanIndex] || creditPlans[0]
|
||||
|
||||
const createSubscription = async ({ priceId, productId }: { priceId: string; productId: string }) => {
|
||||
const { data, error } = await subscription.create({
|
||||
priceId,
|
||||
productId,
|
||||
successUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership`,
|
||||
cancelUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership?canceled=true`,
|
||||
})
|
||||
if (error) {
|
||||
Alert.alert('错误', '订阅创建失败')
|
||||
return
|
||||
}
|
||||
if (data?.url) await Linking.openURL(data.url)
|
||||
}
|
||||
|
||||
const upgradeSubscription = async ({ credits }: { credits: number }) => {
|
||||
if (!activeAuthSubscription?.stripeSubscriptionId) {
|
||||
Alert.alert('错误', '未找到活跃订阅')
|
||||
return
|
||||
}
|
||||
const planIndex = creditPlans.findIndex(plan => plan.credits === credits)
|
||||
const planNames = stripePricingData?.pricing_table_items?.map((item: any) => item.product_name) || []
|
||||
const planName = planNames[planIndex] || 'basic'
|
||||
const { data, error } = await subscription.upgrade({
|
||||
plan: planName,
|
||||
subscriptionId: activeAuthSubscription.stripeSubscriptionId,
|
||||
successUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership`,
|
||||
cancelUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership?canceled=true`,
|
||||
})
|
||||
if (error) {
|
||||
Alert.alert('错误', '订阅升级失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const restoreSubscription = async () => {
|
||||
if (!activeAuthSubscription) {
|
||||
Alert.alert('错误', '未找到可恢复的订阅')
|
||||
return
|
||||
}
|
||||
const { data, error } = await subscription.restore({
|
||||
subscriptionId: activeAuthSubscription.stripeSubscriptionId,
|
||||
referenceId: activeAuthSubscription.referenceId,
|
||||
})
|
||||
if (error) {
|
||||
Alert.alert('错误', '订阅恢复失败')
|
||||
return
|
||||
}
|
||||
Alert.alert('成功', '订阅已恢复')
|
||||
await fetchAuthSubscriptions()
|
||||
}
|
||||
|
||||
const cancelSubscription = async () => {
|
||||
if (!activeAuthSubscription) {
|
||||
Alert.alert('错误', '未找到活跃订阅')
|
||||
return
|
||||
}
|
||||
const { data, error } = await subscription.cancel({
|
||||
subscriptionId: activeAuthSubscription.stripeSubscriptionId,
|
||||
referenceId: activeAuthSubscription.referenceId,
|
||||
})
|
||||
if (error) {
|
||||
Alert.alert('错误', '订阅取消失败')
|
||||
return
|
||||
}
|
||||
Alert.alert('成功', '订阅已取消')
|
||||
await fetchAuthSubscriptions()
|
||||
}
|
||||
|
||||
const rechargeToken = async (amount: number) => {
|
||||
if (!amount || amount <= 0) {
|
||||
Alert.alert('错误', '充值金额无效')
|
||||
return
|
||||
}
|
||||
if (!meteredSubscriptions?.[0]?.priceId) {
|
||||
Alert.alert('错误', '定价数据不可用')
|
||||
return
|
||||
}
|
||||
const { data, error } = await subscription.credit.topup({
|
||||
amount,
|
||||
priceId: meteredSubscriptions[0].priceId,
|
||||
successUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership`,
|
||||
cancelUrl: `${process.env.EXPO_PUBLIC_APP_URL}/membership?canceled=true`,
|
||||
})
|
||||
if (error) {
|
||||
Alert.alert('错误', '充值失败')
|
||||
return
|
||||
}
|
||||
if (data?.url) await Linking.openURL(data.url)
|
||||
}
|
||||
|
||||
return {
|
||||
selectedPlanIndex,
|
||||
selectedPlan,
|
||||
creditPlans,
|
||||
stripePricingData,
|
||||
session,
|
||||
activeAuthSubscription,
|
||||
hasActiveSubscription,
|
||||
creditBalance,
|
||||
isLoadingSubscriptions,
|
||||
isStripePricingLoading,
|
||||
setSelectedPlanIndex,
|
||||
createSubscription,
|
||||
upgradeSubscription,
|
||||
restoreSubscription,
|
||||
cancelSubscription,
|
||||
rechargeToken,
|
||||
refetchAuthSubscriptions: fetchAuthSubscriptions,
|
||||
}
|
||||
}
|
||||
44
lib/auth.ts
44
lib/auth.ts
@@ -70,10 +70,54 @@ export interface BillingPortalResponse {
|
||||
redirect: boolean
|
||||
}
|
||||
|
||||
export interface PlansParams {
|
||||
query: {
|
||||
id: string
|
||||
key: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionParams {
|
||||
priceId: string
|
||||
productId: string
|
||||
successUrl: string
|
||||
cancelUrl: string
|
||||
}
|
||||
|
||||
export interface UpgradeSubscriptionParams {
|
||||
plan: string
|
||||
subscriptionId: string
|
||||
successUrl: string
|
||||
cancelUrl: string
|
||||
}
|
||||
|
||||
export interface CancelSubscriptionParams {
|
||||
subscriptionId: string
|
||||
referenceId: string
|
||||
}
|
||||
|
||||
export interface TopupParams {
|
||||
amount: number
|
||||
priceId: string
|
||||
successUrl: string
|
||||
cancelUrl: string
|
||||
}
|
||||
|
||||
export interface CheckoutResponse {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface ISubscription {
|
||||
list: (params?: SubscriptionListParams) => Promise<{ data?: SubscriptionListItem[]; error?: ApiError }>
|
||||
plans: (params: PlansParams) => Promise<{ data?: any; error?: ApiError }>
|
||||
create: (params: CreateSubscriptionParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }>
|
||||
upgrade: (params: UpgradeSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
|
||||
cancel: (params: CancelSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
|
||||
restore: (params?: SubscriptionRestoreParams) => Promise<{ data?: SubscriptionRestoreResponse; error?: ApiError }>
|
||||
billingPortal: (params?: BillingPortalParams) => Promise<{ data?: BillingPortalResponse; error?: ApiError }>
|
||||
credit: {
|
||||
topup: (params: TopupParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }>
|
||||
}
|
||||
}
|
||||
// 统一的 Token 存储键名(与 fetch-logger.ts 保持一致)
|
||||
export const getAuthToken = async () => (await storage.getItem(TOKEN_KEY)) || ''
|
||||
|
||||
Reference in New Issue
Block a user