diff --git a/app/exchange.tsx b/app/exchange.tsx index 11bf94f..803426e 100644 --- a/app/exchange.tsx +++ b/app/exchange.tsx @@ -2,14 +2,18 @@ import Ionicons from '@expo/vector-icons/Ionicons'; import { useRouter } from 'expo-router'; import React, { useCallback, useMemo, useState } from 'react'; import { + ActivityIndicator, Alert, ScrollView, StyleSheet, Text, + TextInput, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useBalance } from '@/hooks/use-balance'; +import { usePricing } from '@/hooks/use-pricing'; type PurchaseTab = 'subscription' | 'pack'; @@ -33,8 +37,6 @@ const screenPalette = { buttonText: '#101010', }; -const CURRENT_POINTS = 60; - const SUBSCRIPTION_TAGLINE = 'No active subscription plans'; const TABS: { key: PurchaseTab; label: string }[] = [ @@ -52,8 +54,29 @@ const POINT_BUNDLES: PointsBundle[] = [ export default function PointsExchangeScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); - const [activeTab, setActiveTab] = useState('pack'); + const [activeTab, setActiveTab] = useState('subscription'); const [selectedBundleId, setSelectedBundleId] = useState(null); + const [selectedSubscriptionIndex, setSelectedSubscriptionIndex] = useState(null); + const [customAmount, setCustomAmount] = useState('500'); + + const { balance, isLoading: isBalanceLoading, refresh } = useBalance(); + const { + stripePricingData, + isStripePricingLoading, + stripePricingError, + creditPlans, + hasActiveSubscription, + hasCanceledButActiveSubscription, + currentSubscriptionCredits, + activeAuthSubscription, + handleSubscriptionAction, + formatCredits, + createSubscriptionPending, + upgradeSubscriptionPending, + restoreSubscriptionPending, + rechargeToken, + rechargeTokenPending, + } = usePricing(); const visibleBundles = useMemo( () => (activeTab === 'pack' ? POINT_BUNDLES : []), @@ -79,19 +102,73 @@ export default function PointsExchangeScreen() { setSelectedBundleId(bundleId); }, []); + const handleSubscriptionSelect = useCallback((index: number) => { + setSelectedSubscriptionIndex(index); + }, []); + const handlePurchase = useCallback(() => { - const bundle = POINT_BUNDLES.find(item => item.id === selectedBundleId); + if (activeTab === 'subscription') { + // 订阅购买 + if (selectedSubscriptionIndex !== null) { + handleSubscriptionAction(selectedSubscriptionIndex); + } else { + Alert.alert('Select a plan', 'Please select a subscription plan first.'); + } + } else { + // 积分包购买 + if (selectedBundleId) { + const bundle = POINT_BUNDLES.find(item => item.id === selectedBundleId); - if (!bundle) { - Alert.alert('Select a bundle', 'Please pick the bundle you wish to purchase.'); - return; + if (!bundle) { + Alert.alert('Select a bundle', 'Please pick the bundle you wish to purchase.'); + return; + } + + Alert.alert( + 'Confirm purchase', + `You are purchasing ${bundle.points} points for ¥ ${bundle.price}.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Confirm', + onPress: () => { + // TODO: 调用充值API + rechargeToken(bundle.points); + }, + }, + ] + ); + } else { + // 自定义数量 + const amount = parseInt(customAmount, 10); + if (isNaN(amount) || amount < 500) { + Alert.alert('Invalid Amount', 'Please enter a valid amount (minimum 500 points).'); + return; + } + + Alert.alert( + 'Confirm purchase', + `You are purchasing ${amount} points.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Confirm', + onPress: () => { + rechargeToken(amount); + }, + }, + ] + ); + } } - - Alert.alert( - 'Confirm purchase', - `You are purchasing ${bundle.points} points for ¥ ${bundle.price}.`, - ); - }, [selectedBundleId]); + }, [ + activeTab, + selectedBundleId, + selectedSubscriptionIndex, + customAmount, + handleSubscriptionAction, + rechargeToken, + ]); return ( @@ -124,83 +201,216 @@ export default function PointsExchangeScreen() { ]} showsVerticalScrollIndicator={false} > - - - - - {CURRENT_POINTS} - {SUBSCRIPTION_TAGLINE} - - - - {TABS.map(tab => { - const isActive = tab.key === activeTab; - - return ( - changeTab(tab.key)} - accessibilityRole="tab" - accessibilityState={{ selected: isActive }} - activeOpacity={0.8} - > - - {tab.label} - - - - ); - })} - - - - - {activeTab === 'pack' ? ( - - {visibleBundles.map(bundle => { - const isSelected = bundle.id === selectedBundleId; - - return ( - handleBundleSelect(bundle.id)} - accessibilityRole="button" - accessibilityState={{ selected: isSelected }} - activeOpacity={0.88} - > - - - {bundle.points} - - ¥ {bundle.price} - - ); - })} + {isBalanceLoading ? ( + + ) : ( - - Subscription - - No subscription tiers are available at the moment. - - + <> + + + + + {balance.remainingTokenBalance} + {SUBSCRIPTION_TAGLINE} + + + + {TABS.map(tab => { + const isActive = tab.key === activeTab; + + return ( + changeTab(tab.key)} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + activeOpacity={0.8} + > + + {tab.label} + + + + ); + })} + + + + + {activeTab === 'subscription' ? ( + <> + {/* 订阅套餐列表 */} + {isStripePricingLoading ? ( + + + Loading plans... + + ) : stripePricingError ? ( + + Error + {stripePricingError} + + ) : stripePricingData?.pricing_table_items && stripePricingData.pricing_table_items.length > 0 ? ( + + {stripePricingData.pricing_table_items + .filter(item => item.recurring?.interval === 'month') + .map((item, index) => { + const plan = creditPlans[index]; + const isSelected = selectedSubscriptionIndex === index; + const priceInDollars = parseInt(item.amount) / 100; + const grantToken = item.metadata?.grant_token || '0'; + const isCurrentSubscription = + hasActiveSubscription && + activeAuthSubscription?.plan?.toLowerCase() === item.name?.toLowerCase(); + const isCanceledSubscription = + hasCanceledButActiveSubscription && + activeAuthSubscription?.plan?.toLowerCase() === item.name?.toLowerCase(); + const isHighlight = item.is_highlight || item.highlight_text === 'most_popular'; + + return ( + handleSubscriptionSelect(index)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + activeOpacity={0.88} + > + {/* 套餐头部 */} + + {item.name} + {isHighlight && ( + + Popular + + )} + + + {/* 价格 */} + + ${priceInDollars} + /mo + + + {/* 积分信息 */} + + + + + {formatCredits(Number(grantToken))} + + + credits per month + + + {/* 状态徽章 */} + {isCurrentSubscription && ( + + + Current Plan + + )} + + {isCanceledSubscription && ( + + + Canceled + + )} + + ); + })} + + ) : ( + + No Plans Available + + No subscription tiers are available at the moment. + + + )} + + ) : ( + <> + {/* 预设套餐 */} + + {visibleBundles.map(bundle => { + const isSelected = bundle.id === selectedBundleId; + + return ( + handleBundleSelect(bundle.id)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + activeOpacity={0.88} + > + + + {bundle.points} + + ¥ {bundle.price} + + ); + })} + + + {/* 自定义金额输入 */} + + Or enter custom amount: + + + + )} + )} - Purchase points + {createSubscriptionPending || + upgradeSubscriptionPending || + restoreSubscriptionPending || + rechargeTokenPending ? ( + + ) : ( + + {activeTab === 'subscription' ? 'Subscribe' : 'Purchase points'} + + )} @@ -368,6 +578,182 @@ const styles = StyleSheet.create({ textAlign: 'center', lineHeight: 20, }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + minHeight: 300, + }, + loadingText: { + marginTop: 12, + fontSize: 14, + color: screenPalette.secondaryText, + }, + subscriptionGrid: { + marginTop: 28, + gap: 16, + }, + subscriptionCard: { + backgroundColor: screenPalette.surfaceRaised, + borderRadius: 20, + padding: 24, + borderWidth: 2, + borderColor: 'transparent', + position: 'relative', + }, + subscriptionCardSelected: { + borderColor: screenPalette.accent, + backgroundColor: 'rgba(254, 184, 64, 0.05)', + }, + subscriptionCardHighlight: { + borderColor: screenPalette.button, + }, + subscriptionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + subscriptionName: { + fontSize: 24, + fontWeight: '700', + color: screenPalette.primaryText, + }, + subscriptionPriceContainer: { + flexDirection: 'row', + alignItems: 'baseline', + marginBottom: 20, + }, + subscriptionPrice: { + fontSize: 40, + fontWeight: '700', + color: screenPalette.accent, + fontVariant: ['tabular-nums'], + }, + subscriptionInterval: { + fontSize: 16, + color: screenPalette.secondaryText, + marginLeft: 4, + }, + subscriptionCreditsBox: { + backgroundColor: 'rgba(254, 184, 64, 0.1)', + borderRadius: 12, + padding: 16, + alignItems: 'center', + }, + creditsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + subscriptionCreditsValue: { + fontSize: 28, + fontWeight: '700', + color: screenPalette.accent, + fontVariant: ['tabular-nums'], + }, + creditsLabel: { + fontSize: 13, + color: screenPalette.secondaryText, + fontWeight: '500', + }, + subscriptionCreditsContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 16, + }, + subscriptionCredits: { + fontSize: 14, + fontWeight: '600', + color: screenPalette.primaryText, + }, + subscriptionFeatures: { + gap: 8, + }, + featureItem: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + }, + featureBullet: { + fontSize: 16, + color: screenPalette.accent, + lineHeight: 20, + }, + featureText: { + flex: 1, + fontSize: 13, + color: screenPalette.secondaryText, + lineHeight: 20, + }, + popularBadge: { + backgroundColor: screenPalette.button, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 12, + }, + popularBadgeText: { + fontSize: 12, + fontWeight: '700', + color: screenPalette.buttonText, + }, + currentBadge: { + marginTop: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: 'rgba(34, 197, 94, 0.15)', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + alignSelf: 'flex-start', + }, + currentBadgeText: { + fontSize: 13, + fontWeight: '600', + color: '#22c55e', + }, + canceledBadge: { + marginTop: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: 'rgba(251, 146, 60, 0.15)', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + alignSelf: 'flex-start', + }, + canceledBadgeText: { + fontSize: 13, + fontWeight: '600', + color: '#fb923c', + }, + customAmountContainer: { + marginTop: 24, + backgroundColor: screenPalette.surfaceRaised, + borderRadius: 22, + paddingVertical: 20, + paddingHorizontal: 24, + }, + customAmountLabel: { + fontSize: 14, + fontWeight: '600', + color: screenPalette.primaryText, + marginBottom: 12, + }, + customAmountInput: { + backgroundColor: screenPalette.surface, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 16, + color: screenPalette.primaryText, + borderWidth: 1, + borderColor: screenPalette.divider, + }, bottomBar: { paddingHorizontal: 24, }, @@ -378,6 +764,9 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + primaryButtonDisabled: { + opacity: 0.5, + }, primaryButtonLabel: { fontSize: 16, fontWeight: '700', diff --git a/app/points.tsx b/app/points.tsx index bf94159..3f40b03 100644 --- a/app/points.tsx +++ b/app/points.tsx @@ -3,7 +3,9 @@ import { useRouter } from 'expo-router'; import React, { useCallback, useMemo, useState } from 'react'; import type { ListRenderItem } from 'react-native'; import { + ActivityIndicator, FlatList, + RefreshControl, StatusBar, StyleSheet, Text, @@ -11,18 +13,11 @@ import { View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useBalance } from '@/hooks/use-balance'; +import { useTransactions } from '@/hooks/use-transactions'; +import type { Transaction, TransactionKind } from '@/lib/api/transactions'; -type LedgerKind = 'obtained' | 'consumed'; - -type LedgerEntry = { - id: string; - title: string; - happenedAt: string; - amount: number; - kind: LedgerKind; -}; - -type FilterKey = 'all' | LedgerKind; +type FilterKey = 'all' | TransactionKind; const screenPalette = { background: '#080808', @@ -43,70 +38,33 @@ const FILTERS: { key: FilterKey; label: string }[] = [ { key: 'obtained', label: 'Obtained' }, ]; -const LEDGER: LedgerEntry[] = [ - { - id: 'obtained-1', - title: 'Income Name', - happenedAt: '2025-10-22 11:11', - amount: 60, - kind: 'obtained', - }, - { - id: 'consumed-1', - title: 'Expenditure Name', - happenedAt: '2025-10-22 11:11', - amount: -60, - kind: 'consumed', - }, - { - id: 'consumed-2', - title: 'Expenditure Name', - happenedAt: '2025-10-22 11:11', - amount: -60, - kind: 'consumed', - }, - { - id: 'obtained-2', - title: 'Income Name', - happenedAt: '2025-10-22 11:11', - amount: 60, - kind: 'obtained', - }, - { - id: 'obtained-3', - title: 'Income Name', - happenedAt: '2025-10-22 11:11', - amount: 60, - kind: 'obtained', - }, - { - id: 'obtained-4', - title: 'Income Name', - happenedAt: '2025-10-22 11:11', - amount: 60, - kind: 'obtained', - }, - { - id: 'consumed-3', - title: 'Expenditure Name', - happenedAt: '2025-10-22 11:11', - amount: -60, - kind: 'consumed', - }, -]; - export default function PointsDetailsScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); const [activeFilter, setActiveFilter] = useState('all'); + const { balance, isLoading: isBalanceLoading, refresh: refreshBalance } = useBalance(); + const { + transactions, + isLoading: isTransactionsLoading, + refresh: refreshTransactions, + } = useTransactions(); + + const [refreshing, setRefreshing] = useState(false); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await Promise.all([refreshBalance(), refreshTransactions()]); + setRefreshing(false); + }, [refreshBalance, refreshTransactions]); + const filteredEntries = useMemo(() => { if (activeFilter === 'all') { - return LEDGER; + return transactions; } - return LEDGER.filter(entry => entry.kind === activeFilter); - }, [activeFilter]); + return transactions.filter(entry => entry.kind === activeFilter); + }, [activeFilter, transactions]); const listContentStyle = useMemo( () => ({ @@ -116,19 +74,26 @@ export default function PointsDetailsScreen() { [insets.bottom], ); - const keyExtractor = useCallback((entry: LedgerEntry) => entry.id, []); + const keyExtractor = useCallback((entry: Transaction) => entry.id, []); const renderSeparator = useCallback(() => , []); - const renderLedgerEntry: ListRenderItem = useCallback(({ item }) => { + const renderLedgerEntry: ListRenderItem = useCallback(({ item }) => { const amount = Math.abs(item.amount); const isPositive = item.amount > 0; + const displayDate = new Date(item.happenedAt).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); return ( {item.title} - {item.happenedAt} + {displayDate} - - - - + {isLoading && !refreshing ? ( + + - 60 - + ) : ( + <> + + + + + {balance.remainingTokenBalance} + - - {FILTERS.map(filter => { - const isActive = filter.key === activeFilter; + + {FILTERS.map(filter => { + const isActive = filter.key === activeFilter; - return ( - setActiveFilter(filter.key)} - accessibilityRole="button" - > - - {filter.label} - - - ); - })} - + return ( + setActiveFilter(filter.key)} + accessibilityRole="button" + > + + {filter.label} + + + ); + })} + - + + } + /> + + )} ); } @@ -192,6 +173,11 @@ const styles = StyleSheet.create({ backgroundColor: screenPalette.background, paddingHorizontal: 24, }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, headerRow: { flexDirection: 'row', alignItems: 'center', diff --git a/components/profile/profile-screen.tsx b/components/profile/profile-screen.tsx index 6d6fd37..6ee8055 100644 --- a/components/profile/profile-screen.tsx +++ b/components/profile/profile-screen.tsx @@ -3,8 +3,9 @@ import { View, useWindowDimensions, type ImageSourcePropType, StyleSheet } from import { PageLayout } from '@/components/bestai/layout'; import { useAuth } from '@/hooks/use-auth'; +import { useBalance } from '@/hooks/use-balance'; import { useProfileData } from '@/hooks/use-profile-data'; -import { createStats, deriveAvatarSource, deriveDisplayName, deriveNumericValue } from '@/utils/profile-data'; +import { createStats, deriveAvatarSource, deriveDisplayName } from '@/utils/profile-data'; import { PROFILE_THEME } from '@/theme/profile'; import { ContentGallery } from './content-gallery'; import { ContentTabs } from './content-tabs'; @@ -22,6 +23,7 @@ type BillingMode = 'monthly' | 'lifetime'; export function ProfileScreen() { const { user } = useAuth(); + const { balance } = useBalance(); const { width } = useWindowDimensions(); const [billingMode, setBillingMode] = useState('monthly'); const [activeTab, setActiveTab] = useState('all'); @@ -53,7 +55,7 @@ export function ProfileScreen() { const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the'; const avatarSource = deriveAvatarSource(user); - const creditBalance = deriveNumericValue((user as Record)?.credits); + const creditBalance = balance.remainingTokenBalance; const stats = createStats(user); const presentedDisplayName = editedIdentity?.name ?? displayNameFromUser; diff --git a/hooks/use-balance.ts b/hooks/use-balance.ts new file mode 100644 index 0000000..6e75ab7 --- /dev/null +++ b/hooks/use-balance.ts @@ -0,0 +1,38 @@ +import { useState, useEffect } from 'react'; +import { getUserBalance, type UserBalance } from '@/lib/api/balance'; + +export function useBalance() { + const [balance, setBalance] = useState({ + remainingTokenBalance: 0, + totalTokenBalance: 0, + usedTokenBalance: 0, + }); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchBalance = async () => { + setIsLoading(true); + setError(null); + + const response = await getUserBalance(); + + if (response.success) { + setBalance(response.data); + } else { + setError(response.message || '获取余额失败'); + } + + setIsLoading(false); + }; + + useEffect(() => { + fetchBalance(); + }, []); + + return { + balance, + isLoading, + error, + refresh: fetchBalance, + }; +} diff --git a/hooks/use-pricing.ts b/hooks/use-pricing.ts new file mode 100644 index 0000000..d8ee5a4 --- /dev/null +++ b/hooks/use-pricing.ts @@ -0,0 +1,385 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { Alert, Linking } from 'react-native'; +import { useRouter } from 'expo-router'; +import { authClient, useSession } from '@/lib/auth/client'; +import { getStripePlans, getPlanNames } from '@/lib/api/pricing'; +import type { + StripePricingTableResponse, + CreditPlan, +} from '@/lib/api/pricing'; + +export function usePricing() { + const router = useRouter(); + const { data: session, isPending: isAuthPending } = useSession(); + const [selectedPlanIndex, setSelectedPlanIndex] = useState(0); + + // Stripe 套餐数据 + const [stripePricingData, setStripePricingData] = useState(null); + const [isStripePricingLoading, setIsStripePricingLoading] = useState(true); + const [stripePricingError, setStripePricingError] = useState(null); + + // 订阅数据 + const [authSubscriptions, setAuthSubscriptions] = useState([]); + const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false); + const [isFetchingSubscriptions, setIsFetchingSubscriptions] = useState(false); + + // Mutation 状态 + const [createSubscriptionPending, setCreateSubscriptionPending] = useState(false); + const [upgradeSubscriptionPending, setUpgradeSubscriptionPending] = useState(false); + const [restoreSubscriptionPending, setRestoreSubscriptionPending] = useState(false); + const [rechargeTokenPending, setRechargeTokenPending] = useState(false); + + const isInitialized = useRef(false); + + // 获取 Stripe 套餐数据 + useEffect(() => { + const fetchStripePlans = async () => { + setIsStripePricingLoading(true); + const result = await getStripePlans(); + + if (result.success && result.data) { + setStripePricingData(result.data); + setStripePricingError(null); + } else { + setStripePricingError(result.message || 'Failed to load pricing'); + } + + setIsStripePricingLoading(false); + }; + + fetchStripePlans(); + }, []); + + // 获取订阅数据 + const fetchAuthSubscriptions = async () => { + if (!session?.user?.id) return; + + setIsFetchingSubscriptions(true); + try { + const { data, error } = await authClient.subscription.list({ + query: { + referenceId: session?.user?.id || '', + }, + }); + + if (!error && data) { + setAuthSubscriptions(data); + } + } catch (error) { + console.error('Failed to fetch subscriptions:', error); + } finally { + setIsFetchingSubscriptions(false); + } + }; + + useEffect(() => { + if (session?.user?.id) { + setIsLoadingSubscriptions(true); + fetchAuthSubscriptions().finally(() => { + setIsLoadingSubscriptions(false); + }); + } + }, [session?.user?.id]); + + // 订阅类型分类 + const licensedSubscriptions = authSubscriptions?.filter((sub: any) => sub.type === 'licenced') || []; + const meteredSubscriptions = authSubscriptions?.filter((sub: any) => 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 hasCanceledButActiveSubscription = !!( + activeAuthSubscription && + activeAuthSubscription.status === 'active' && + activeAuthSubscription.cancelAtPeriodEnd === true + ); + + // 获取价格计划 + const creditPlans = useMemo((): CreditPlan[] => { + if (stripePricingData?.pricing_table_items) { + return stripePricingData.pricing_table_items + .filter((item) => item.recurring?.interval === 'month') + .map((item) => { + const amountInCents = parseInt(item.amount); + const credits = item.metadata?.grant_token || amountInCents; + return { + amountInCents, + credits, + popular: item.is_highlight || item.highlight_text === 'most_popular', + }; + }); + } + return []; + }, [stripePricingData?.pricing_table_items]); + + // 当前订阅对应的积分 + const currentSubscriptionCredits = useMemo((): number | null => { + if (!activeAuthSubscription?.plan) return null; + + const planNameLower = activeAuthSubscription.plan.toLowerCase(); + const planNames = getPlanNames(stripePricingData); + const matchingPlan = creditPlans.find((_, index) => { + return planNames[index]?.toLowerCase() === planNameLower; + }); + return matchingPlan?.credits || null; + }, [activeAuthSubscription?.plan, creditPlans, stripePricingData]); + + // 积分余额 + const creditBalanceData = meteredSubscriptions?.[0]?.creditBalance; + const creditBalance = creditBalanceData?.remainingTokenBalance || 0; + + const selectedPlan = creditPlans[selectedPlanIndex] || creditPlans[0]; + + // 初始化选中的计划 + useEffect(() => { + const hasRequiredData = creditPlans.length > 0 && !isStripePricingLoading && !isLoadingSubscriptions; + + if (!isInitialized.current && hasRequiredData) { + let targetIndex = Math.floor(creditPlans.length / 2); + + if (currentSubscriptionCredits) { + const foundIndex = creditPlans.findIndex(plan => plan.credits.toString() === currentSubscriptionCredits.toString()); + if (foundIndex !== -1) { + targetIndex = foundIndex; + } + } + + setSelectedPlanIndex(targetIndex); + isInitialized.current = true; + } + }, [currentSubscriptionCredits, creditPlans, isStripePricingLoading, isLoadingSubscriptions]); + + // 创建订阅 + const createSubscription = async (priceId: string, productId: string) => { + setCreateSubscriptionPending(true); + try { + const { data, error } = await authClient.subscription.create({ + priceId, + productId, + successUrl: 'bestaibest://exchange', + cancelUrl: 'bestaibest://exchange?canceled=true', + }); + + if (error) { + Alert.alert('Error', error.message || 'Failed to create subscription'); + return null; + } + + if (data?.url) { + await Linking.openURL(data.url); + } + + return data; + } catch (error) { + Alert.alert('Error', 'Failed to create subscription'); + return null; + } finally { + setCreateSubscriptionPending(false); + } + }; + + // 升级订阅 + const upgradeSubscription = async (credits: number) => { + if (!activeAuthSubscription?.stripeSubscriptionId) { + Alert.alert('Error', 'No active subscription found'); + return null; + } + + setUpgradeSubscriptionPending(true); + try { + const planIndex = creditPlans.findIndex(plan => plan.credits === credits); + const planNames = getPlanNames(stripePricingData); + const planName = planNames[planIndex] || 'basic'; + + const { data, error } = await authClient.subscription.upgrade({ + plan: planName, + subscriptionId: activeAuthSubscription.stripeSubscriptionId, + successUrl: 'bestaibest://exchange', + cancelUrl: 'bestaibest://exchange?canceled=true', + }); + + if (error) { + Alert.alert('Error', error.message || 'Failed to upgrade subscription'); + return null; + } + + if (data?.url) { + await Linking.openURL(data.url); + } + + return data; + } catch (error) { + Alert.alert('Error', 'Failed to upgrade subscription'); + return null; + } finally { + setUpgradeSubscriptionPending(false); + } + }; + + // 恢复订阅 + const restoreSubscription = async () => { + if (!activeAuthSubscription) { + Alert.alert('Error', 'No subscription to restore'); + return null; + } + + setRestoreSubscriptionPending(true); + try { + const { data, error } = await authClient.subscription.restore({ + subscriptionId: activeAuthSubscription.stripeSubscriptionId, + referenceId: activeAuthSubscription.referenceId, + }); + + if (error) { + Alert.alert('Error', error.message || 'Failed to restore subscription'); + return null; + } + + Alert.alert('Success', 'Subscription restored successfully'); + await fetchAuthSubscriptions(); + return data; + } catch (error) { + Alert.alert('Error', 'Failed to restore subscription'); + return null; + } finally { + setRestoreSubscriptionPending(false); + } + }; + + // 充值 Token + const rechargeToken = async (amount: number) => { + if (!amount || amount <= 0) { + Alert.alert('Error', 'Invalid recharge amount'); + return null; + } + + if (!stripePricingData?.pricing_table_items?.[0]?.metered_price_id) { + Alert.alert('Error', 'Pricing data not available'); + return null; + } + + setRechargeTokenPending(true); + try { + const { data, error } = await authClient.subscription.credit.topup({ + amount: amount, + priceId: meteredSubscriptions[0].priceId, + successUrl: 'bestaibest://exchange', + cancelUrl: 'bestaibest://exchange?canceled=true', + }); + + if (error) { + Alert.alert('Error', error.message || 'Failed to recharge'); + return null; + } + + if (data?.url) { + await Linking.openURL(data.url); + } + + return data; + } catch (error) { + Alert.alert('Error', 'Failed to recharge'); + return null; + } finally { + setRechargeTokenPending(false); + } + }; + + // 处理订阅操作 + const handleSubscriptionAction = async (planIndexOverride?: number) => { + const targetPlanIndex = planIndexOverride !== undefined ? planIndexOverride : selectedPlanIndex; + const targetPlan = creditPlans[targetPlanIndex]; + + if (!targetPlan) { + Alert.alert('Error', 'Invalid plan selected'); + return; + } + + if (isStripePricingLoading) { + Alert.alert('Please wait', 'Loading pricing data...'); + return; + } + + // 如果选择的是当前订阅 + if (hasActiveSubscription && currentSubscriptionCredits === targetPlan.credits) { + return; + } + + // 如果是已取消但仍活跃的订阅 + if (hasCanceledButActiveSubscription && currentSubscriptionCredits === targetPlan.credits) { + await restoreSubscription(); + return; + } + + const pricingItem = stripePricingData?.pricing_table_items?.[targetPlanIndex]; + if (!pricingItem) { + Alert.alert('Error', 'Plan not found'); + return; + } + + // 判断创建还是升级 + if (hasActiveSubscription || hasCanceledButActiveSubscription) { + await upgradeSubscription(targetPlan.credits); + } else { + await createSubscription(pricingItem.price_id, pricingItem.product_id); + } + }; + + // 格式化积分 + const formatCredits = (credits: number) => { + return credits.toLocaleString(); + }; + + // 综合加载状态 + const isDataFullyLoaded = useMemo(() => { + if (isAuthPending) return false; + if (!session) return !isStripePricingLoading; + return !isStripePricingLoading && !isLoadingSubscriptions; + }, [session, isAuthPending, isStripePricingLoading, isLoadingSubscriptions]); + + return { + // 状态 + selectedPlanIndex, + selectedPlan, + creditPlans, + // 认证状态 + session, + isAuthPending, + isDataFullyLoaded, + // 订阅状态 + activeAuthSubscription, + hasActiveSubscription, + hasCanceledButActiveSubscription, + currentSubscriptionCredits, + isLoadingSubscriptions, + isFetchingSubscriptions, + meteredSubscriptions, + // Credits 余额 + creditBalance, + creditBalanceData, + // Stripe 数据 + stripePricingData, + isStripePricingLoading, + stripePricingError, + // 操作方法 + setSelectedPlanIndex, + handleSubscriptionAction, + fetchAuthSubscriptions, + rechargeToken, + // 工具方法 + formatCredits, + // Mutation 状态 + createSubscriptionPending, + upgradeSubscriptionPending, + restoreSubscriptionPending, + rechargeTokenPending, + }; +} diff --git a/hooks/use-transactions.ts b/hooks/use-transactions.ts new file mode 100644 index 0000000..7754841 --- /dev/null +++ b/hooks/use-transactions.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react'; +import { getUserTransactions, type Transaction } from '@/lib/api/transactions'; + +export function useTransactions() { + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchTransactions = async () => { + setIsLoading(true); + setError(null); + + const response = await getUserTransactions(); + + if (response.success) { + setTransactions(response.data); + } else { + setError(response.message || '获取交易记录失败'); + } + + setIsLoading(false); + }; + + useEffect(() => { + fetchTransactions(); + }, []); + + return { + transactions, + isLoading, + error, + refresh: fetchTransactions, + }; +} diff --git a/lib/api/pricing.ts b/lib/api/pricing.ts new file mode 100644 index 0000000..b6a5b8f --- /dev/null +++ b/lib/api/pricing.ts @@ -0,0 +1,78 @@ +import { apiRequest } from './client'; + +export interface StripePricingItem { + price_id: string; + product_id: string; + name: string; + amount: string; + recurring?: { + interval: string; + interval_count: number; + }; + metadata?: { + grant_token?: string; + [key: string]: any; + }; + feature_list?: string[]; + is_highlight?: boolean; + highlight_text?: string; + metered_price_id?: string; +} + +export interface StripePricingTableResponse { + pricing_table_items: StripePricingItem[]; +} + +export interface CreditPlan { + amountInCents: number; + credits: number; + popular?: boolean; +} + +/** + * 获取 Stripe 订阅套餐列表 + */ +export async function getStripePlans(): Promise<{ + success: boolean; + data?: StripePricingTableResponse; + message?: string; +}> { + try { + const response = await apiRequest<{ success: boolean; data: StripePricingTableResponse }>( + '/api/stripe/plans', + { + method: 'GET', + requiresAuth: false, + } + ); + + if (!response.success || !response.data) { + return { + success: false, + message: 'Failed to fetch pricing data', + }; + } + + return { + success: true, + data: response.data, + }; + } catch (error) { + console.error('Failed to fetch Stripe plans:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * 从 API 数据中提取计划名称列表 + */ +export function getPlanNames(pricingData?: StripePricingTableResponse | null): string[] { + if (!pricingData?.pricing_table_items) return []; + + return pricingData.pricing_table_items + .filter(item => item.recurring?.interval === 'month') + .map(item => item.name || 'basic'); +} diff --git a/lib/api/transactions.ts b/lib/api/transactions.ts new file mode 100644 index 0000000..8ad008b --- /dev/null +++ b/lib/api/transactions.ts @@ -0,0 +1,86 @@ +import { authClient } from '../auth/client'; + +export type TransactionKind = 'obtained' | 'consumed'; + +export interface Transaction { + id: string; + title: string; + happenedAt: string; + amount: number; + kind: TransactionKind; +} + +export interface TransactionsResponse { + success: boolean; + data: Transaction[]; + message?: string; +} + +/** + * 获取用户积分交易记录 + * 通过 subscription.meterEvent 获取消费记录,通过订阅历史获取充值记录 + */ +export async function getUserTransactions(): Promise { + try { + const { data: subscriptions, error } = await authClient.subscription.list({}); + + if (error) { + return { + success: false, + data: [], + message: error.message || '获取交易记录失败', + }; + } + + const transactions: Transaction[] = []; + + // 从订阅中提取 meter events (消费记录) + subscriptions?.forEach((sub: any) => { + if (sub.type === 'metered' && sub.meterEvents) { + sub.meterEvents.forEach((event: any) => { + const value = parseInt(event.payload?.value || '0'); + if (value > 0) { + transactions.push({ + id: event.id || `event-${Date.now()}-${Math.random()}`, + title: event.event_name === 'token_usage' ? 'Token消费' : '积分消费', + happenedAt: event.createdAt || new Date().toISOString(), + amount: -value, + kind: 'consumed', + }); + } + }); + } + + // 提取充值记录 (从订阅创建或续费事件) + if (sub.createdAt) { + const grantToken = parseInt(sub.plan?.metadata?.grant_token || '0'); + if (grantToken > 0) { + transactions.push({ + id: `sub-${sub.id}`, + title: `订阅充值 - ${sub.plan?.name || '未知套餐'}`, + happenedAt: sub.createdAt, + amount: grantToken, + kind: 'obtained', + }); + } + } + }); + + // 按时间倒序排序 + transactions.sort((a, b) => + new Date(b.happenedAt).getTime() - new Date(a.happenedAt).getTime() + ); + + return { + success: true, + data: transactions, + }; + } catch (error) { + console.error('Failed to get user transactions:', error); + return { + success: false, + data: [], + message: '网络错误,请稍后重试', + }; + } +} diff --git a/package.json b/package.json index 2ea9c2f..4f0a352 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dependencies": { "@better-auth/expo": "^1.3.27", "@better-auth/stripe": "^1.3.27", - "@bowong/better-auth-stripe": "1.3.27-g", + "@bowong/better-auth-stripe": "1.3.27-i", "@expo/vector-icons": "^15.0.3", "@microsoft/react-native-clarity": "^4.3.3", "@react-native-async-storage/async-storage": "^2.2.0",