bw-expo-app/app/exchange.tsx

729 lines
22 KiB
TypeScript

import { useBalance } from '@/hooks/use-balance';
import { usePricing } from '@/hooks/use-pricing';
import Ionicons from '@expo/vector-icons/Ionicons';
import { useRouter } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Alert } from '@/utils/alert';
type PurchaseTab = 'subscription' | 'pack';
type PointsBundle = {
id: string;
points: number;
price: number;
};
const screenPalette = {
background: '#050505',
surface: '#101010',
surfaceRaised: '#1A1B1E',
primaryText: '#F5F5F5',
secondaryText: '#7F7F7F',
mutedText: '#4C4C4C',
accent: '#FEB840',
energyHalo: 'rgba(254, 184, 64, 0.22)',
divider: '#1C1C1C',
button: '#D1FE17',
buttonText: '#101010',
};
const SUBSCRIPTION_TAGLINE = 'No active subscription plans';
const TABS: { key: PurchaseTab; label: string }[] = [
{ key: 'subscription', label: 'Subscription' },
{ key: 'pack', label: 'points pack' },
];
const POINT_BUNDLES: PointsBundle[] = [];
export default function PointsExchangeScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<PurchaseTab>('subscription');
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
const [selectedSubscriptionIndex, setSelectedSubscriptionIndex] = useState<number | null>(0);
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 : []),
[activeTab],
);
const changeTab = useCallback((nextTab: PurchaseTab) => {
setActiveTab(nextTab);
if (nextTab !== 'pack') {
setSelectedBundleId(null);
}
}, []);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const openPointsDetails = useCallback(() => {
router.push('/points');
}, [router]);
const handleBundleSelect = useCallback((bundleId: string) => {
setSelectedBundleId(bundleId);
}, []);
const handleSubscriptionSelect = useCallback((index: number) => {
setSelectedSubscriptionIndex(index);
}, []);
const handlePurchase = useCallback(() => {
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;
}
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 {
console.log(`自定义数量`, customAmount)
// 自定义数量
const amount = parseInt(customAmount, 10);
if (isNaN(amount) || amount < 0) {
Alert.alert('Invalid Amount', 'Please enter a valid amount (minimum 500 points).');
return;
}
rechargeToken(amount);
}
}
}, [
activeTab,
selectedBundleId,
selectedSubscriptionIndex,
customAmount,
handleSubscriptionAction,
rechargeToken,
]);
return (
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
<View style={styles.headerRow}>
<TouchableOpacity
style={styles.iconButton}
accessibilityRole="button"
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={22} color={screenPalette.primaryText} />
</TouchableOpacity>
<TouchableOpacity
style={styles.secondaryPill}
accessibilityRole="button"
onPress={openPointsDetails}
activeOpacity={0.85}
>
<Text style={styles.secondaryPillLabel}>Points Details</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
contentContainerStyle={[
styles.contentContainer,
{ paddingBottom: Math.max(insets.bottom + 96, 160) },
]}
showsVerticalScrollIndicator={false}
>
{isBalanceLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={screenPalette.accent} />
</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}>
<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,
(createSubscriptionPending || upgradeSubscriptionPending || restoreSubscriptionPending || rechargeTokenPending) &&
styles.primaryButtonDisabled,
]}
onPress={handlePurchase}
disabled={
createSubscriptionPending ||
upgradeSubscriptionPending ||
restoreSubscriptionPending ||
rechargeTokenPending
}
activeOpacity={0.85}
>
{createSubscriptionPending ||
upgradeSubscriptionPending ||
restoreSubscriptionPending ||
rechargeTokenPending ? (
<ActivityIndicator color={screenPalette.buttonText} />
) : (
<Text style={styles.primaryButtonLabel}>
{activeTab === 'subscription' ? 'Subscribe' : 'Purchase points'}
</Text>
)}
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: screenPalette.background,
},
headerRow: {
paddingHorizontal: 24,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
secondaryPill: {
paddingHorizontal: 16,
height: 34,
borderRadius: 17,
backgroundColor: screenPalette.surface,
borderWidth: StyleSheet.hairlineWidth,
borderColor: screenPalette.divider,
justifyContent: 'center',
alignItems: 'center',
},
secondaryPillLabel: {
fontSize: 14,
fontWeight: '600',
color: screenPalette.primaryText,
letterSpacing: 0.2,
},
content: {
flex: 1,
},
contentContainer: {
paddingHorizontal: 24,
},
balanceCluster: {
alignItems: 'center',
marginTop: 32,
marginBottom: 36,
gap: 10,
},
energyOrb: {
width: 58,
height: 58,
borderRadius: 29,
backgroundColor: screenPalette.energyHalo,
alignItems: 'center',
justifyContent: 'center',
shadowColor: screenPalette.accent,
shadowOpacity: 0.28,
shadowRadius: 14,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
balanceValue: {
fontSize: 44,
fontWeight: '700',
color: screenPalette.primaryText,
letterSpacing: 0.5,
fontVariant: ['tabular-nums'],
},
balanceSubtitle: {
fontSize: 13,
color: screenPalette.secondaryText,
letterSpacing: 0.2,
},
tabBar: {
flexDirection: 'row',
gap: 32,
paddingHorizontal: 4,
},
tabButton: {
flex: 1,
alignItems: 'center',
paddingBottom: 12,
},
tabLabel: {
fontSize: 14,
color: screenPalette.mutedText,
fontWeight: '500',
letterSpacing: 0.2,
textTransform: 'capitalize',
},
tabLabelActive: {
color: screenPalette.primaryText,
},
tabIndicator: {
marginTop: 10,
height: 2,
width: '100%',
backgroundColor: 'transparent',
borderRadius: 1,
},
tabIndicatorActive: {
backgroundColor: screenPalette.accent,
},
tabDivider: {
marginTop: 12,
height: StyleSheet.hairlineWidth,
backgroundColor: screenPalette.divider,
},
packGrid: {
marginTop: 28,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 18,
},
packCard: {
width: '47%',
backgroundColor: screenPalette.surfaceRaised,
borderRadius: 24,
paddingHorizontal: 18,
paddingVertical: 24,
justifyContent: 'space-between',
borderWidth: 1,
borderColor: screenPalette.surfaceRaised,
},
packCardSelected: {
borderColor: screenPalette.accent,
},
packHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
packPoints: {
fontSize: 18,
fontWeight: '700',
color: screenPalette.primaryText,
fontVariant: ['tabular-nums'],
},
packPrice: {
marginTop: 18,
fontSize: 14,
color: screenPalette.secondaryText,
},
subscriptionEmpty: {
marginTop: 64,
backgroundColor: screenPalette.surface,
borderRadius: 22,
paddingVertical: 40,
paddingHorizontal: 32,
alignItems: 'center',
gap: 12,
},
emptyTitle: {
fontSize: 16,
fontWeight: '600',
color: screenPalette.primaryText,
},
emptySubtitle: {
fontSize: 13,
color: screenPalette.secondaryText,
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',
},
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,
},
primaryButton: {
height: 54,
borderRadius: 27,
backgroundColor: screenPalette.button,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonDisabled: {
opacity: 0.5,
},
primaryButtonLabel: {
fontSize: 16,
fontWeight: '700',
color: screenPalette.buttonText,
letterSpacing: 0.3,
},
});