fix: 付费订阅bug
This commit is contained in:
parent
5e4f9b1292
commit
daf9cca667
549
app/exchange.tsx
549
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<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',
|
||||
|
|
|
|||
188
app/points.tsx
188
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<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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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: '网络错误,请稍后重试',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue