fix: 付费订阅bug
This commit is contained in:
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 { useRouter } from 'expo-router';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useBalance } from '@/hooks/use-balance';
|
||||||
|
import { usePricing } from '@/hooks/use-pricing';
|
||||||
|
|
||||||
type PurchaseTab = 'subscription' | 'pack';
|
type PurchaseTab = 'subscription' | 'pack';
|
||||||
|
|
||||||
@@ -33,8 +37,6 @@ const screenPalette = {
|
|||||||
buttonText: '#101010',
|
buttonText: '#101010',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CURRENT_POINTS = 60;
|
|
||||||
|
|
||||||
const SUBSCRIPTION_TAGLINE = 'No active subscription plans';
|
const SUBSCRIPTION_TAGLINE = 'No active subscription plans';
|
||||||
|
|
||||||
const TABS: { key: PurchaseTab; label: string }[] = [
|
const TABS: { key: PurchaseTab; label: string }[] = [
|
||||||
@@ -52,8 +54,29 @@ const POINT_BUNDLES: PointsBundle[] = [
|
|||||||
export default function PointsExchangeScreen() {
|
export default function PointsExchangeScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [activeTab, setActiveTab] = useState<PurchaseTab>('pack');
|
const [activeTab, setActiveTab] = useState<PurchaseTab>('subscription');
|
||||||
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
|
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(
|
const visibleBundles = useMemo(
|
||||||
() => (activeTab === 'pack' ? POINT_BUNDLES : []),
|
() => (activeTab === 'pack' ? POINT_BUNDLES : []),
|
||||||
@@ -79,19 +102,73 @@ export default function PointsExchangeScreen() {
|
|||||||
setSelectedBundleId(bundleId);
|
setSelectedBundleId(bundleId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSubscriptionSelect = useCallback((index: number) => {
|
||||||
|
setSelectedSubscriptionIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePurchase = useCallback(() => {
|
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) {
|
if (!bundle) {
|
||||||
Alert.alert('Select a bundle', 'Please pick the bundle you wish to purchase.');
|
Alert.alert('Select a bundle', 'Please pick the bundle you wish to purchase.');
|
||||||
return;
|
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(
|
activeTab,
|
||||||
'Confirm purchase',
|
selectedBundleId,
|
||||||
`You are purchasing ${bundle.points} points for ¥ ${bundle.price}.`,
|
selectedSubscriptionIndex,
|
||||||
);
|
customAmount,
|
||||||
}, [selectedBundleId]);
|
handleSubscriptionAction,
|
||||||
|
rechargeToken,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
|
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
|
||||||
@@ -124,83 +201,216 @@ export default function PointsExchangeScreen() {
|
|||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={styles.balanceCluster}>
|
{isBalanceLoading ? (
|
||||||
<View style={styles.energyOrb}>
|
<View style={styles.loadingContainer}>
|
||||||
<Ionicons name="flash" size={22} color={screenPalette.accent} />
|
<ActivityIndicator size="large" 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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.subscriptionEmpty}>
|
<>
|
||||||
<Text style={styles.emptyTitle}>Subscription</Text>
|
<View style={styles.balanceCluster}>
|
||||||
<Text style={styles.emptySubtitle}>
|
<View style={styles.energyOrb}>
|
||||||
No subscription tiers are available at the moment.
|
<Ionicons name="flash" size={22} color={screenPalette.accent} />
|
||||||
</Text>
|
</View>
|
||||||
</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>
|
</ScrollView>
|
||||||
|
|
||||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.primaryButton}
|
style={[
|
||||||
|
styles.primaryButton,
|
||||||
|
(createSubscriptionPending || upgradeSubscriptionPending || restoreSubscriptionPending || rechargeTokenPending) &&
|
||||||
|
styles.primaryButtonDisabled,
|
||||||
|
]}
|
||||||
onPress={handlePurchase}
|
onPress={handlePurchase}
|
||||||
|
disabled={
|
||||||
|
createSubscriptionPending ||
|
||||||
|
upgradeSubscriptionPending ||
|
||||||
|
restoreSubscriptionPending ||
|
||||||
|
rechargeTokenPending
|
||||||
|
}
|
||||||
activeOpacity={0.85}
|
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>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -368,6 +578,182 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 20,
|
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: {
|
bottomBar: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
},
|
},
|
||||||
@@ -378,6 +764,9 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
primaryButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
primaryButtonLabel: {
|
primaryButtonLabel: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
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 React, { useCallback, useMemo, useState } from 'react';
|
||||||
import type { ListRenderItem } from 'react-native';
|
import type { ListRenderItem } from 'react-native';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
|
RefreshControl,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -11,18 +13,11 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 FilterKey = 'all' | TransactionKind;
|
||||||
|
|
||||||
type LedgerEntry = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
happenedAt: string;
|
|
||||||
amount: number;
|
|
||||||
kind: LedgerKind;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FilterKey = 'all' | LedgerKind;
|
|
||||||
|
|
||||||
const screenPalette = {
|
const screenPalette = {
|
||||||
background: '#080808',
|
background: '#080808',
|
||||||
@@ -43,70 +38,33 @@ const FILTERS: { key: FilterKey; label: string }[] = [
|
|||||||
{ key: 'obtained', label: 'Obtained' },
|
{ 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() {
|
export default function PointsDetailsScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
|
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(() => {
|
const filteredEntries = useMemo(() => {
|
||||||
if (activeFilter === 'all') {
|
if (activeFilter === 'all') {
|
||||||
return LEDGER;
|
return transactions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return LEDGER.filter(entry => entry.kind === activeFilter);
|
return transactions.filter(entry => entry.kind === activeFilter);
|
||||||
}, [activeFilter]);
|
}, [activeFilter, transactions]);
|
||||||
|
|
||||||
const listContentStyle = useMemo(
|
const listContentStyle = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -116,19 +74,26 @@ export default function PointsDetailsScreen() {
|
|||||||
[insets.bottom],
|
[insets.bottom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((entry: LedgerEntry) => entry.id, []);
|
const keyExtractor = useCallback((entry: Transaction) => entry.id, []);
|
||||||
|
|
||||||
const renderSeparator = useCallback(() => <View style={styles.listDivider} />, []);
|
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 amount = Math.abs(item.amount);
|
||||||
const isPositive = item.amount > 0;
|
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 (
|
return (
|
||||||
<View style={styles.recordRow}>
|
<View style={styles.recordRow}>
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.recordTitle}>{item.title}</Text>
|
<Text style={styles.recordTitle}>{item.title}</Text>
|
||||||
<Text style={styles.recordTimestamp}>{item.happenedAt}</Text>
|
<Text style={styles.recordTimestamp}>{displayDate}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -142,46 +107,62 @@ export default function PointsDetailsScreen() {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isLoading = isBalanceLoading || isTransactionsLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
|
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
|
||||||
<StatusBar barStyle="light-content" />
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
||||||
|
{isLoading && !refreshing ? (
|
||||||
<View style={styles.balanceCluster}>
|
<View style={styles.loadingContainer}>
|
||||||
<View style={styles.energyOrb}>
|
<ActivityIndicator size="large" color={screenPalette.accent} />
|
||||||
<Ionicons name="flash" size={22} color={screenPalette.accent} />
|
|
||||||
</View>
|
</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}>
|
<View style={styles.segmentRail}>
|
||||||
{FILTERS.map(filter => {
|
{FILTERS.map(filter => {
|
||||||
const isActive = filter.key === activeFilter;
|
const isActive = filter.key === activeFilter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={filter.key}
|
key={filter.key}
|
||||||
style={[styles.segmentChip, isActive && styles.segmentChipActive]}
|
style={[styles.segmentChip, isActive && styles.segmentChipActive]}
|
||||||
onPress={() => setActiveFilter(filter.key)}
|
onPress={() => setActiveFilter(filter.key)}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
>
|
>
|
||||||
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
|
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={filteredEntries}
|
data={filteredEntries}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
style={styles.list}
|
style={styles.list}
|
||||||
contentContainerStyle={listContentStyle}
|
contentContainerStyle={listContentStyle}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ItemSeparatorComponent={renderSeparator}
|
ItemSeparatorComponent={renderSeparator}
|
||||||
renderItem={renderLedgerEntry}
|
renderItem={renderLedgerEntry}
|
||||||
/>
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={screenPalette.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,6 +173,11 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: screenPalette.background,
|
backgroundColor: screenPalette.background,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
},
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
headerRow: {
|
headerRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { View, useWindowDimensions, type ImageSourcePropType, StyleSheet } from
|
|||||||
|
|
||||||
import { PageLayout } from '@/components/bestai/layout';
|
import { PageLayout } from '@/components/bestai/layout';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { useBalance } from '@/hooks/use-balance';
|
||||||
import { useProfileData } from '@/hooks/use-profile-data';
|
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 { PROFILE_THEME } from '@/theme/profile';
|
||||||
import { ContentGallery } from './content-gallery';
|
import { ContentGallery } from './content-gallery';
|
||||||
import { ContentTabs } from './content-tabs';
|
import { ContentTabs } from './content-tabs';
|
||||||
@@ -22,6 +23,7 @@ type BillingMode = 'monthly' | 'lifetime';
|
|||||||
|
|
||||||
export function ProfileScreen() {
|
export function ProfileScreen() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { balance } = useBalance();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
|
const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||||
@@ -53,7 +55,7 @@ export function ProfileScreen() {
|
|||||||
|
|
||||||
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
|
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
|
||||||
const avatarSource = deriveAvatarSource(user);
|
const avatarSource = deriveAvatarSource(user);
|
||||||
const creditBalance = deriveNumericValue((user as Record<string, unknown>)?.credits);
|
const creditBalance = balance.remainingTokenBalance;
|
||||||
const stats = createStats(user);
|
const stats = createStats(user);
|
||||||
|
|
||||||
const presentedDisplayName = editedIdentity?.name ?? displayNameFromUser;
|
const presentedDisplayName = editedIdentity?.name ?? displayNameFromUser;
|
||||||
|
|||||||
38
hooks/use-balance.ts
Normal file
38
hooks/use-balance.ts
Normal 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
385
hooks/use-pricing.ts
Normal 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
34
hooks/use-transactions.ts
Normal 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
78
lib/api/pricing.ts
Normal 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
86
lib/api/transactions.ts
Normal 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: '网络错误,请稍后重试',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/expo": "^1.3.27",
|
"@better-auth/expo": "^1.3.27",
|
||||||
"@better-auth/stripe": "^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",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@microsoft/react-native-clarity": "^4.3.3",
|
"@microsoft/react-native-clarity": "^4.3.3",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user