fix: 付费订阅bug

This commit is contained in:
imeepos 2025-11-12 16:07:58 +08:00
parent 5e4f9b1292
commit daf9cca667
9 changed files with 1182 additions and 184 deletions

View File

@ -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<PurchaseTab>('pack');
const [activeTab, setActiveTab] = useState<PurchaseTab>('subscription');
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
const [selectedSubscriptionIndex, setSelectedSubscriptionIndex] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState<string>('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 (
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
@ -124,83 +201,216 @@ export default function PointsExchangeScreen() {
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={22} color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>{CURRENT_POINTS}</Text>
<Text style={styles.balanceSubtitle}>{SUBSCRIPTION_TAGLINE}</Text>
</View>
<View style={styles.tabBar}>
{TABS.map(tab => {
const isActive = tab.key === activeTab;
return (
<TouchableOpacity
key={tab.key}
style={styles.tabButton}
onPress={() => changeTab(tab.key)}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
activeOpacity={0.8}
>
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
{tab.label}
</Text>
<View
style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]}
/>
</TouchableOpacity>
);
})}
</View>
<View style={styles.tabDivider} />
{activeTab === 'pack' ? (
<View style={styles.packGrid}>
{visibleBundles.map(bundle => {
const isSelected = bundle.id === selectedBundleId;
return (
<TouchableOpacity
key={bundle.id}
style={[
styles.packCard,
isSelected && styles.packCardSelected,
]}
onPress={() => handleBundleSelect(bundle.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
activeOpacity={0.88}
>
<View style={styles.packHeader}>
<Ionicons name="flash" size={18} color={screenPalette.accent} />
<Text style={styles.packPoints}>{bundle.points}</Text>
</View>
<Text style={styles.packPrice}>¥ {bundle.price}</Text>
</TouchableOpacity>
);
})}
{isBalanceLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={screenPalette.accent} />
</View>
) : (
<View style={styles.subscriptionEmpty}>
<Text style={styles.emptyTitle}>Subscription</Text>
<Text style={styles.emptySubtitle}>
No subscription tiers are available at the moment.
</Text>
</View>
<>
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={22} color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>{balance.remainingTokenBalance}</Text>
<Text style={styles.balanceSubtitle}>{SUBSCRIPTION_TAGLINE}</Text>
</View>
<View style={styles.tabBar}>
{TABS.map(tab => {
const isActive = tab.key === activeTab;
return (
<TouchableOpacity
key={tab.key}
style={styles.tabButton}
onPress={() => changeTab(tab.key)}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
activeOpacity={0.8}
>
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
{tab.label}
</Text>
<View
style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]}
/>
</TouchableOpacity>
);
})}
</View>
<View style={styles.tabDivider} />
{activeTab === 'subscription' ? (
<>
{/* 订阅套餐列表 */}
{isStripePricingLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={screenPalette.accent} />
<Text style={styles.loadingText}>Loading plans...</Text>
</View>
) : stripePricingError ? (
<View style={styles.subscriptionEmpty}>
<Text style={styles.emptyTitle}>Error</Text>
<Text style={styles.emptySubtitle}>{stripePricingError}</Text>
</View>
) : stripePricingData?.pricing_table_items && stripePricingData.pricing_table_items.length > 0 ? (
<View style={styles.subscriptionGrid}>
{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 (
<TouchableOpacity
key={item.price_id}
style={[
styles.subscriptionCard,
isSelected && styles.subscriptionCardSelected,
isHighlight && styles.subscriptionCardHighlight,
]}
onPress={() => handleSubscriptionSelect(index)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
activeOpacity={0.88}
>
{/* 套餐头部 */}
<View style={styles.subscriptionHeader}>
<Text style={styles.subscriptionName}>{item.name}</Text>
{isHighlight && (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Popular</Text>
</View>
)}
</View>
{/* 价格 */}
<View style={styles.subscriptionPriceContainer}>
<Text style={styles.subscriptionPrice}>${priceInDollars}</Text>
<Text style={styles.subscriptionInterval}>/mo</Text>
</View>
{/* 积分信息 */}
<View style={styles.subscriptionCreditsBox}>
<View style={styles.creditsRow}>
<Ionicons name="flash" size={20} color={screenPalette.accent} />
<Text style={styles.subscriptionCreditsValue}>
{formatCredits(Number(grantToken))}
</Text>
</View>
<Text style={styles.creditsLabel}>credits per month</Text>
</View>
{/* 状态徽章 */}
{isCurrentSubscription && (
<View style={styles.currentBadge}>
<Ionicons name="checkmark-circle" size={14} color="#22c55e" />
<Text style={styles.currentBadgeText}>Current Plan</Text>
</View>
)}
{isCanceledSubscription && (
<View style={styles.canceledBadge}>
<Ionicons name="warning" size={14} color="#fb923c" />
<Text style={styles.canceledBadgeText}>Canceled</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
) : (
<View style={styles.subscriptionEmpty}>
<Text style={styles.emptyTitle}>No Plans Available</Text>
<Text style={styles.emptySubtitle}>
No subscription tiers are available at the moment.
</Text>
</View>
)}
</>
) : (
<>
{/* 预设套餐 */}
<View style={styles.packGrid}>
{visibleBundles.map(bundle => {
const isSelected = bundle.id === selectedBundleId;
return (
<TouchableOpacity
key={bundle.id}
style={[
styles.packCard,
isSelected && styles.packCardSelected,
]}
onPress={() => handleBundleSelect(bundle.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
activeOpacity={0.88}
>
<View style={styles.packHeader}>
<Ionicons name="flash" size={18} color={screenPalette.accent} />
<Text style={styles.packPoints}>{bundle.points}</Text>
</View>
<Text style={styles.packPrice}>¥ {bundle.price}</Text>
</TouchableOpacity>
);
})}
</View>
{/* 自定义金额输入 */}
<View style={styles.customAmountContainer}>
<Text style={styles.customAmountLabel}>Or enter custom amount:</Text>
<TextInput
style={styles.customAmountInput}
value={customAmount}
onChangeText={setCustomAmount}
keyboardType="numeric"
placeholder="Min. 500 points"
placeholderTextColor={screenPalette.mutedText}
/>
</View>
</>
)}
</>
)}
</ScrollView>
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
<TouchableOpacity
style={styles.primaryButton}
style={[
styles.primaryButton,
(createSubscriptionPending || upgradeSubscriptionPending || restoreSubscriptionPending || rechargeTokenPending) &&
styles.primaryButtonDisabled,
]}
onPress={handlePurchase}
disabled={
createSubscriptionPending ||
upgradeSubscriptionPending ||
restoreSubscriptionPending ||
rechargeTokenPending
}
activeOpacity={0.85}
>
<Text style={styles.primaryButtonLabel}>Purchase points</Text>
{createSubscriptionPending ||
upgradeSubscriptionPending ||
restoreSubscriptionPending ||
rechargeTokenPending ? (
<ActivityIndicator color={screenPalette.buttonText} />
) : (
<Text style={styles.primaryButtonLabel}>
{activeTab === 'subscription' ? 'Subscribe' : 'Purchase points'}
</Text>
)}
</TouchableOpacity>
</View>
</View>
@ -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',

View File

@ -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<FilterKey>('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(() => <View style={styles.listDivider} />, []);
const renderLedgerEntry: ListRenderItem<LedgerEntry> = useCallback(({ item }) => {
const renderLedgerEntry: ListRenderItem<Transaction> = 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 (
<View style={styles.recordRow}>
<View>
<Text style={styles.recordTitle}>{item.title}</Text>
<Text style={styles.recordTimestamp}>{item.happenedAt}</Text>
<Text style={styles.recordTimestamp}>{displayDate}</Text>
</View>
<Text
style={[
@ -142,46 +107,62 @@ export default function PointsDetailsScreen() {
);
}, []);
const isLoading = isBalanceLoading || isTransactionsLoading;
return (
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
<StatusBar barStyle="light-content" />
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={22} color={screenPalette.accent} />
{isLoading && !refreshing ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>60</Text>
</View>
) : (
<>
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={22} color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>{balance.remainingTokenBalance}</Text>
</View>
<View style={styles.segmentRail}>
{FILTERS.map(filter => {
const isActive = filter.key === activeFilter;
<View style={styles.segmentRail}>
{FILTERS.map(filter => {
const isActive = filter.key === activeFilter;
return (
<TouchableOpacity
key={filter.key}
style={[styles.segmentChip, isActive && styles.segmentChipActive]}
onPress={() => setActiveFilter(filter.key)}
accessibilityRole="button"
>
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
{filter.label}
</Text>
</TouchableOpacity>
);
})}
</View>
return (
<TouchableOpacity
key={filter.key}
style={[styles.segmentChip, isActive && styles.segmentChipActive]}
onPress={() => setActiveFilter(filter.key)}
accessibilityRole="button"
>
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
{filter.label}
</Text>
</TouchableOpacity>
);
})}
</View>
<FlatList
data={filteredEntries}
keyExtractor={keyExtractor}
style={styles.list}
contentContainerStyle={listContentStyle}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={renderSeparator}
renderItem={renderLedgerEntry}
/>
<FlatList
data={filteredEntries}
keyExtractor={keyExtractor}
style={styles.list}
contentContainerStyle={listContentStyle}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={renderSeparator}
renderItem={renderLedgerEntry}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={screenPalette.accent}
/>
}
/>
</>
)}
</View>
);
}
@ -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',

View File

@ -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<BillingMode>('monthly');
const [activeTab, setActiveTab] = useState<TabKey>('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<string, unknown>)?.credits);
const creditBalance = balance.remainingTokenBalance;
const stats = createStats(user);
const presentedDisplayName = editedIdentity?.name ?? displayNameFromUser;

38
hooks/use-balance.ts Normal file
View File

@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { getUserBalance, type UserBalance } from '@/lib/api/balance';
export function useBalance() {
const [balance, setBalance] = useState<UserBalance>({
remainingTokenBalance: 0,
totalTokenBalance: 0,
usedTokenBalance: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}

385
hooks/use-pricing.ts Normal file
View File

@ -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<StripePricingTableResponse | null>(null);
const [isStripePricingLoading, setIsStripePricingLoading] = useState(true);
const [stripePricingError, setStripePricingError] = useState<string | null>(null);
// 订阅数据
const [authSubscriptions, setAuthSubscriptions] = useState<any[]>([]);
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,
};
}

34
hooks/use-transactions.ts Normal file
View File

@ -0,0 +1,34 @@
import { useState, useEffect } from 'react';
import { getUserTransactions, type Transaction } from '@/lib/api/transactions';
export function useTransactions() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}

78
lib/api/pricing.ts Normal file
View File

@ -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');
}

86
lib/api/transactions.ts Normal file
View File

@ -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<TransactionsResponse> {
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: '网络错误,请稍后重试',
};
}
}

View File

@ -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",