fix: 付费订阅bug

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

View File

@@ -2,14 +2,18 @@ import Ionicons from '@expo/vector-icons/Ionicons';
import { useRouter } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useBalance } from '@/hooks/use-balance';
import { usePricing } from '@/hooks/use-pricing';
type PurchaseTab = 'subscription' | 'pack';
@@ -33,8 +37,6 @@ const screenPalette = {
buttonText: '#101010',
};
const CURRENT_POINTS = 60;
const SUBSCRIPTION_TAGLINE = 'No active subscription plans';
const TABS: { key: PurchaseTab; label: string }[] = [
@@ -52,8 +54,29 @@ const POINT_BUNDLES: PointsBundle[] = [
export default function PointsExchangeScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<PurchaseTab>('pack');
const [activeTab, setActiveTab] = useState<PurchaseTab>('subscription');
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
const [selectedSubscriptionIndex, setSelectedSubscriptionIndex] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState<string>('500');
const { balance, isLoading: isBalanceLoading, refresh } = useBalance();
const {
stripePricingData,
isStripePricingLoading,
stripePricingError,
creditPlans,
hasActiveSubscription,
hasCanceledButActiveSubscription,
currentSubscriptionCredits,
activeAuthSubscription,
handleSubscriptionAction,
formatCredits,
createSubscriptionPending,
upgradeSubscriptionPending,
restoreSubscriptionPending,
rechargeToken,
rechargeTokenPending,
} = usePricing();
const visibleBundles = useMemo(
() => (activeTab === 'pack' ? POINT_BUNDLES : []),
@@ -79,19 +102,73 @@ export default function PointsExchangeScreen() {
setSelectedBundleId(bundleId);
}, []);
const handleSubscriptionSelect = useCallback((index: number) => {
setSelectedSubscriptionIndex(index);
}, []);
const handlePurchase = useCallback(() => {
const bundle = POINT_BUNDLES.find(item => item.id === selectedBundleId);
if (activeTab === 'subscription') {
// 订阅购买
if (selectedSubscriptionIndex !== null) {
handleSubscriptionAction(selectedSubscriptionIndex);
} else {
Alert.alert('Select a plan', 'Please select a subscription plan first.');
}
} else {
// 积分包购买
if (selectedBundleId) {
const bundle = POINT_BUNDLES.find(item => item.id === selectedBundleId);
if (!bundle) {
Alert.alert('Select a bundle', 'Please pick the bundle you wish to purchase.');
return;
if (!bundle) {
Alert.alert('Select a bundle', 'Please pick the bundle you wish to purchase.');
return;
}
Alert.alert(
'Confirm purchase',
`You are purchasing ${bundle.points} points for ¥ ${bundle.price}.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Confirm',
onPress: () => {
// TODO: 调用充值API
rechargeToken(bundle.points);
},
},
]
);
} else {
// 自定义数量
const amount = parseInt(customAmount, 10);
if (isNaN(amount) || amount < 500) {
Alert.alert('Invalid Amount', 'Please enter a valid amount (minimum 500 points).');
return;
}
Alert.alert(
'Confirm purchase',
`You are purchasing ${amount} points.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Confirm',
onPress: () => {
rechargeToken(amount);
},
},
]
);
}
}
Alert.alert(
'Confirm purchase',
`You are purchasing ${bundle.points} points for ¥ ${bundle.price}.`,
);
}, [selectedBundleId]);
}, [
activeTab,
selectedBundleId,
selectedSubscriptionIndex,
customAmount,
handleSubscriptionAction,
rechargeToken,
]);
return (
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
@@ -124,83 +201,216 @@ export default function PointsExchangeScreen() {
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={22} color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>{CURRENT_POINTS}</Text>
<Text style={styles.balanceSubtitle}>{SUBSCRIPTION_TAGLINE}</Text>
</View>
<View style={styles.tabBar}>
{TABS.map(tab => {
const isActive = tab.key === activeTab;
return (
<TouchableOpacity
key={tab.key}
style={styles.tabButton}
onPress={() => changeTab(tab.key)}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
activeOpacity={0.8}
>
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
{tab.label}
</Text>
<View
style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]}
/>
</TouchableOpacity>
);
})}
</View>
<View style={styles.tabDivider} />
{activeTab === 'pack' ? (
<View style={styles.packGrid}>
{visibleBundles.map(bundle => {
const isSelected = bundle.id === selectedBundleId;
return (
<TouchableOpacity
key={bundle.id}
style={[
styles.packCard,
isSelected && styles.packCardSelected,
]}
onPress={() => handleBundleSelect(bundle.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
activeOpacity={0.88}
>
<View style={styles.packHeader}>
<Ionicons name="flash" size={18} color={screenPalette.accent} />
<Text style={styles.packPoints}>{bundle.points}</Text>
</View>
<Text style={styles.packPrice}>¥ {bundle.price}</Text>
</TouchableOpacity>
);
})}
{isBalanceLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={screenPalette.accent} />
</View>
) : (
<View style={styles.subscriptionEmpty}>
<Text style={styles.emptyTitle}>Subscription</Text>
<Text style={styles.emptySubtitle}>
No subscription tiers are available at the moment.
</Text>
</View>
<>
<View style={styles.balanceCluster}>
<View style={styles.energyOrb}>
<Ionicons name="flash" size={22} color={screenPalette.accent} />
</View>
<Text style={styles.balanceValue}>{balance.remainingTokenBalance}</Text>
<Text style={styles.balanceSubtitle}>{SUBSCRIPTION_TAGLINE}</Text>
</View>
<View style={styles.tabBar}>
{TABS.map(tab => {
const isActive = tab.key === activeTab;
return (
<TouchableOpacity
key={tab.key}
style={styles.tabButton}
onPress={() => changeTab(tab.key)}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
activeOpacity={0.8}
>
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
{tab.label}
</Text>
<View
style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]}
/>
</TouchableOpacity>
);
})}
</View>
<View style={styles.tabDivider} />
{activeTab === 'subscription' ? (
<>
{/* 订阅套餐列表 */}
{isStripePricingLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={screenPalette.accent} />
<Text style={styles.loadingText}>Loading plans...</Text>
</View>
) : stripePricingError ? (
<View style={styles.subscriptionEmpty}>
<Text style={styles.emptyTitle}>Error</Text>
<Text style={styles.emptySubtitle}>{stripePricingError}</Text>
</View>
) : stripePricingData?.pricing_table_items && stripePricingData.pricing_table_items.length > 0 ? (
<View style={styles.subscriptionGrid}>
{stripePricingData.pricing_table_items
.filter(item => item.recurring?.interval === 'month')
.map((item, index) => {
const plan = creditPlans[index];
const isSelected = selectedSubscriptionIndex === index;
const priceInDollars = parseInt(item.amount) / 100;
const grantToken = item.metadata?.grant_token || '0';
const isCurrentSubscription =
hasActiveSubscription &&
activeAuthSubscription?.plan?.toLowerCase() === item.name?.toLowerCase();
const isCanceledSubscription =
hasCanceledButActiveSubscription &&
activeAuthSubscription?.plan?.toLowerCase() === item.name?.toLowerCase();
const isHighlight = item.is_highlight || item.highlight_text === 'most_popular';
return (
<TouchableOpacity
key={item.price_id}
style={[
styles.subscriptionCard,
isSelected && styles.subscriptionCardSelected,
isHighlight && styles.subscriptionCardHighlight,
]}
onPress={() => handleSubscriptionSelect(index)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
activeOpacity={0.88}
>
{/* 套餐头部 */}
<View style={styles.subscriptionHeader}>
<Text style={styles.subscriptionName}>{item.name}</Text>
{isHighlight && (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Popular</Text>
</View>
)}
</View>
{/* 价格 */}
<View style={styles.subscriptionPriceContainer}>
<Text style={styles.subscriptionPrice}>${priceInDollars}</Text>
<Text style={styles.subscriptionInterval}>/mo</Text>
</View>
{/* 积分信息 */}
<View style={styles.subscriptionCreditsBox}>
<View style={styles.creditsRow}>
<Ionicons name="flash" size={20} color={screenPalette.accent} />
<Text style={styles.subscriptionCreditsValue}>
{formatCredits(Number(grantToken))}
</Text>
</View>
<Text style={styles.creditsLabel}>credits per month</Text>
</View>
{/* 状态徽章 */}
{isCurrentSubscription && (
<View style={styles.currentBadge}>
<Ionicons name="checkmark-circle" size={14} color="#22c55e" />
<Text style={styles.currentBadgeText}>Current Plan</Text>
</View>
)}
{isCanceledSubscription && (
<View style={styles.canceledBadge}>
<Ionicons name="warning" size={14} color="#fb923c" />
<Text style={styles.canceledBadgeText}>Canceled</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
) : (
<View style={styles.subscriptionEmpty}>
<Text style={styles.emptyTitle}>No Plans Available</Text>
<Text style={styles.emptySubtitle}>
No subscription tiers are available at the moment.
</Text>
</View>
)}
</>
) : (
<>
{/* 预设套餐 */}
<View style={styles.packGrid}>
{visibleBundles.map(bundle => {
const isSelected = bundle.id === selectedBundleId;
return (
<TouchableOpacity
key={bundle.id}
style={[
styles.packCard,
isSelected && styles.packCardSelected,
]}
onPress={() => handleBundleSelect(bundle.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
activeOpacity={0.88}
>
<View style={styles.packHeader}>
<Ionicons name="flash" size={18} color={screenPalette.accent} />
<Text style={styles.packPoints}>{bundle.points}</Text>
</View>
<Text style={styles.packPrice}>¥ {bundle.price}</Text>
</TouchableOpacity>
);
})}
</View>
{/* 自定义金额输入 */}
<View style={styles.customAmountContainer}>
<Text style={styles.customAmountLabel}>Or enter custom amount:</Text>
<TextInput
style={styles.customAmountInput}
value={customAmount}
onChangeText={setCustomAmount}
keyboardType="numeric"
placeholder="Min. 500 points"
placeholderTextColor={screenPalette.mutedText}
/>
</View>
</>
)}
</>
)}
</ScrollView>
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
<TouchableOpacity
style={styles.primaryButton}
style={[
styles.primaryButton,
(createSubscriptionPending || upgradeSubscriptionPending || restoreSubscriptionPending || rechargeTokenPending) &&
styles.primaryButtonDisabled,
]}
onPress={handlePurchase}
disabled={
createSubscriptionPending ||
upgradeSubscriptionPending ||
restoreSubscriptionPending ||
rechargeTokenPending
}
activeOpacity={0.85}
>
<Text style={styles.primaryButtonLabel}>Purchase points</Text>
{createSubscriptionPending ||
upgradeSubscriptionPending ||
restoreSubscriptionPending ||
rechargeTokenPending ? (
<ActivityIndicator color={screenPalette.buttonText} />
) : (
<Text style={styles.primaryButtonLabel}>
{activeTab === 'subscription' ? 'Subscribe' : 'Purchase points'}
</Text>
)}
</TouchableOpacity>
</View>
</View>
@@ -368,6 +578,182 @@ const styles = StyleSheet.create({
textAlign: 'center',
lineHeight: 20,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
minHeight: 300,
},
loadingText: {
marginTop: 12,
fontSize: 14,
color: screenPalette.secondaryText,
},
subscriptionGrid: {
marginTop: 28,
gap: 16,
},
subscriptionCard: {
backgroundColor: screenPalette.surfaceRaised,
borderRadius: 20,
padding: 24,
borderWidth: 2,
borderColor: 'transparent',
position: 'relative',
},
subscriptionCardSelected: {
borderColor: screenPalette.accent,
backgroundColor: 'rgba(254, 184, 64, 0.05)',
},
subscriptionCardHighlight: {
borderColor: screenPalette.button,
},
subscriptionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
subscriptionName: {
fontSize: 24,
fontWeight: '700',
color: screenPalette.primaryText,
},
subscriptionPriceContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 20,
},
subscriptionPrice: {
fontSize: 40,
fontWeight: '700',
color: screenPalette.accent,
fontVariant: ['tabular-nums'],
},
subscriptionInterval: {
fontSize: 16,
color: screenPalette.secondaryText,
marginLeft: 4,
},
subscriptionCreditsBox: {
backgroundColor: 'rgba(254, 184, 64, 0.1)',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
creditsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 4,
},
subscriptionCreditsValue: {
fontSize: 28,
fontWeight: '700',
color: screenPalette.accent,
fontVariant: ['tabular-nums'],
},
creditsLabel: {
fontSize: 13,
color: screenPalette.secondaryText,
fontWeight: '500',
},
subscriptionCreditsContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 16,
},
subscriptionCredits: {
fontSize: 14,
fontWeight: '600',
color: screenPalette.primaryText,
},
subscriptionFeatures: {
gap: 8,
},
featureItem: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
},
featureBullet: {
fontSize: 16,
color: screenPalette.accent,
lineHeight: 20,
},
featureText: {
flex: 1,
fontSize: 13,
color: screenPalette.secondaryText,
lineHeight: 20,
},
popularBadge: {
backgroundColor: screenPalette.button,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
popularBadgeText: {
fontSize: 12,
fontWeight: '700',
color: screenPalette.buttonText,
},
currentBadge: {
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(34, 197, 94, 0.15)',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
alignSelf: 'flex-start',
},
currentBadgeText: {
fontSize: 13,
fontWeight: '600',
color: '#22c55e',
},
canceledBadge: {
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(251, 146, 60, 0.15)',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
alignSelf: 'flex-start',
},
canceledBadgeText: {
fontSize: 13,
fontWeight: '600',
color: '#fb923c',
},
customAmountContainer: {
marginTop: 24,
backgroundColor: screenPalette.surfaceRaised,
borderRadius: 22,
paddingVertical: 20,
paddingHorizontal: 24,
},
customAmountLabel: {
fontSize: 14,
fontWeight: '600',
color: screenPalette.primaryText,
marginBottom: 12,
},
customAmountInput: {
backgroundColor: screenPalette.surface,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 16,
color: screenPalette.primaryText,
borderWidth: 1,
borderColor: screenPalette.divider,
},
bottomBar: {
paddingHorizontal: 24,
},
@@ -378,6 +764,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonDisabled: {
opacity: 0.5,
},
primaryButtonLabel: {
fontSize: 16,
fontWeight: '700',