515 lines
16 KiB
TypeScript
515 lines
16 KiB
TypeScript
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
||
import {
|
||
View,
|
||
Text,
|
||
StyleSheet,
|
||
Pressable,
|
||
ActivityIndicator,
|
||
Platform,
|
||
} from 'react-native'
|
||
import { LinearGradient } from 'expo-linear-gradient'
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||
import { useRouter } from 'expo-router'
|
||
import { useTranslation } from 'react-i18next'
|
||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'
|
||
import { root } from '@repo/core'
|
||
import { AlipayController } from '@repo/sdk'
|
||
import { CloseIcon, CheckIcon, UncheckedIcon, PointsIcon } from '@/components/icon'
|
||
import Toast from '@/components/ui/Toast'
|
||
import { useUserBalanceStore } from '@/stores/userBalanceStore'
|
||
|
||
// 动态导入支付宝 SDK(需要安装 expo-native-alipay)
|
||
let Alipay: any = null
|
||
try {
|
||
Alipay = require('expo-native-alipay').default
|
||
} catch (e) {
|
||
console.warn('expo-native-alipay not installed, payment will not work')
|
||
}
|
||
|
||
export interface TopUpOption {
|
||
id: string
|
||
points: number
|
||
price: number
|
||
}
|
||
|
||
export interface TopUpDrawerProps {
|
||
/**
|
||
* 是否显示抽屉
|
||
*/
|
||
visible: boolean
|
||
/**
|
||
* 关闭回调
|
||
*/
|
||
onClose: () => void
|
||
/**
|
||
* 需要消耗的积分
|
||
*/
|
||
requiredPoints?: number
|
||
/**
|
||
* 当前剩余积分
|
||
*/
|
||
remainingPoints?: number
|
||
/**
|
||
* 充值选项列表
|
||
*/
|
||
options?: TopUpOption[]
|
||
/**
|
||
* 确认充值回调
|
||
*/
|
||
onConfirm?: (option: TopUpOption) => void
|
||
/**
|
||
* 充值标题
|
||
*/
|
||
topUpTitle?: string
|
||
/**
|
||
* 充值描述
|
||
*/
|
||
topUpDescription?: string
|
||
/**
|
||
* 导航回调,用于在导航时关闭父级抽屉(如 PointsDrawer)
|
||
*/
|
||
onNavigate?: () => void
|
||
}
|
||
|
||
const defaultOptions: TopUpOption[] = [
|
||
{ id: '1', points: 1000, price: 20 },
|
||
{ id: '2', points: 2500, price: 20 },
|
||
{ id: '3', points: 5000, price: 20 },
|
||
{ id: '4', points: 10000, price: 20 },
|
||
]
|
||
|
||
// 支付宝回调 scheme(需要与 app.json 中配置一致)
|
||
const ALIPAY_SCHEME = 'popcore'
|
||
|
||
export default function TopUpDrawer({
|
||
visible,
|
||
onClose,
|
||
options = defaultOptions,
|
||
onConfirm,
|
||
topUpTitle,
|
||
topUpDescription,
|
||
onNavigate,
|
||
}: TopUpDrawerProps) {
|
||
const { t } = useTranslation()
|
||
const router = useRouter()
|
||
const insets = useSafeAreaInsets()
|
||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||
const [selectedOption, setSelectedOption] = useState<TopUpOption | null>(
|
||
options[0] || null
|
||
)
|
||
const [agreed, setAgreed] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
// 获取余额 store 的方法
|
||
const { load: loadBalance, restartPolling } = useUserBalanceStore()
|
||
|
||
const snapPoints = useMemo(() => [500], [])
|
||
|
||
useEffect(() => {
|
||
if (visible) {
|
||
bottomSheetRef.current?.expand()
|
||
} else {
|
||
bottomSheetRef.current?.close()
|
||
}
|
||
}, [visible])
|
||
|
||
const handleSheetChanges = useCallback((index: number) => {
|
||
if (index === -1) {
|
||
onClose()
|
||
}
|
||
}, [onClose])
|
||
|
||
const renderBackdrop = useCallback(
|
||
(props: any) => (
|
||
<BottomSheetBackdrop
|
||
{...props}
|
||
disappearsOnIndex={-1}
|
||
appearsOnIndex={0}
|
||
opacity={0.5}
|
||
/>
|
||
),
|
||
[]
|
||
)
|
||
|
||
const handleClose = useCallback(() => {
|
||
bottomSheetRef.current?.close()
|
||
onClose()
|
||
}, [onClose])
|
||
|
||
// 如果没有传入标题,使用默认翻译
|
||
const displayTitle = topUpTitle || t('topUp.title')
|
||
|
||
// 初始化支付宝 SDK
|
||
useEffect(() => {
|
||
if (Alipay && Platform.OS === 'ios') {
|
||
Alipay.setAlipayScheme(ALIPAY_SCHEME)
|
||
}
|
||
}, [])
|
||
|
||
const handleConfirm = async () => {
|
||
if (!selectedOption || !agreed) return
|
||
|
||
// 检查支付宝 SDK 是否可用
|
||
if (!Alipay) {
|
||
Toast.show(t('topUp.alipayNotInstalled') || '支付宝 SDK 未安装')
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
|
||
try {
|
||
// 1. 调用后端 API 创建订单
|
||
const alipay = root.get(AlipayController)
|
||
const response = await alipay.preRecharge({
|
||
credits: selectedOption.points,
|
||
})
|
||
|
||
if (!response?.orderStr) {
|
||
Toast.show(t('topUp.createOrderFailed') || '创建订单失败')
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
// 2. 调用支付宝 SDK 发起支付
|
||
const result = await Alipay.pay(response.orderStr)
|
||
console.log('Alipay payment result:', result)
|
||
|
||
// 3. 处理支付结果
|
||
if (result.resultStatus === '9000') {
|
||
// 支付成功
|
||
Toast.show(t('topUp.paymentSuccess') || '支付成功!积分正在到账中...')
|
||
// 刷新余额
|
||
loadBalance(true)
|
||
restartPolling()
|
||
// 关闭抽屉
|
||
onClose()
|
||
// 调用外部回调
|
||
onConfirm?.(selectedOption)
|
||
} else if (result.resultStatus === '6001') {
|
||
// 用户取消
|
||
Toast.show(t('topUp.paymentCancelled') || '支付已取消')
|
||
} else {
|
||
// 支付失败
|
||
Toast.show(t('topUp.paymentFailed') || '支付失败,请重试')
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Payment error:', err)
|
||
Toast.show(err?.message || t('topUp.paymentError') || '支付出错')
|
||
} finally {
|
||
setLoading(false)
|
||
// 无论成功失败都刷新余额
|
||
loadBalance(true)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<BottomSheet
|
||
ref={bottomSheetRef}
|
||
index={visible ? 0 : -1}
|
||
snapPoints={snapPoints}
|
||
onChange={handleSheetChanges}
|
||
enablePanDownToClose
|
||
backgroundStyle={styles.bottomSheetBackground}
|
||
handleIndicatorStyle={styles.handleIndicator}
|
||
handleComponent={null}
|
||
backdropComponent={renderBackdrop}
|
||
>
|
||
<BottomSheetView style={styles.container}>
|
||
{displayTitle && (
|
||
// 这个绝对定位的标题层会盖在右上角关闭按钮上,必须允许触摸事件“穿透”
|
||
<View style={styles.titleContainer} pointerEvents="none">
|
||
{/* 主文字层 */}
|
||
<Text style={[styles.titleText, styles.titleFill]}>{displayTitle}</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.header}>
|
||
<Text></Text>
|
||
<Pressable
|
||
style={styles.closeButton}
|
||
onPress={handleClose}
|
||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||
>
|
||
<CloseIcon />
|
||
</Pressable>
|
||
</View>
|
||
|
||
{topUpDescription && (
|
||
<View style={styles.infoSection}>
|
||
<Text style={styles.infoText}>{topUpDescription}</Text>
|
||
</View>
|
||
)}
|
||
|
||
|
||
{/* 充值选项网格 */}
|
||
<View style={styles.optionsGrid}>
|
||
{options.map((option) => {
|
||
const isSelected = selectedOption?.id === option.id
|
||
return (
|
||
<Pressable
|
||
key={option.id}
|
||
style={styles.optionCardWrapper}
|
||
onPress={() => setSelectedOption(option)}
|
||
>
|
||
{isSelected ? (
|
||
<LinearGradient
|
||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 0 }}
|
||
style={styles.optionCardGradient}
|
||
>
|
||
<View style={styles.optionCard}>
|
||
<View style={styles.optionContent}>
|
||
<PointsIcon width={16} height={16} />
|
||
<Text style={styles.optionPoints}>
|
||
{option.points.toLocaleString()}
|
||
</Text>
|
||
</View>
|
||
<Text style={styles.optionPrice}>${option.price}</Text>
|
||
|
||
</View>
|
||
</LinearGradient>
|
||
) : (
|
||
<View style={styles.optionCard}>
|
||
<View style={styles.optionContent}>
|
||
<PointsIcon width={16} height={16} />
|
||
<Text style={styles.optionPoints}>
|
||
{option.points.toLocaleString()}
|
||
</Text>
|
||
</View>
|
||
<Text style={styles.optionPrice}>${option.price}</Text>
|
||
</View>
|
||
)}
|
||
</Pressable>
|
||
)
|
||
})}
|
||
</View>
|
||
|
||
{/* 底部按钮和协议 */}
|
||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
||
<Pressable
|
||
style={styles.confirmButton}
|
||
onPress={handleConfirm}
|
||
disabled={!agreed || !selectedOption || loading}
|
||
>
|
||
<LinearGradient
|
||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 0 }}
|
||
style={[
|
||
styles.confirmButtonGradient,
|
||
(!agreed || !selectedOption || loading) && styles.confirmButtonDisabled,
|
||
]}
|
||
>
|
||
{loading ? (
|
||
<ActivityIndicator color="#F5F5F5" size="small" />
|
||
) : (
|
||
<Text style={styles.confirmButtonText}>{t('topUp.confirm')}</Text>
|
||
)}
|
||
</LinearGradient>
|
||
</Pressable>
|
||
<View style={styles.agreementContainer}>
|
||
<Pressable
|
||
style={styles.checkbox}
|
||
onPress={() => setAgreed(!agreed)}
|
||
>
|
||
{agreed ? <CheckIcon /> : <UncheckedIcon />}
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={() => setAgreed(!agreed)}
|
||
>
|
||
<Text style={styles.agreementText}>
|
||
{t('topUp.agreementText')}{' '}
|
||
<Text
|
||
style={styles.agreementLink}
|
||
onPress={(e) => {
|
||
e.stopPropagation()
|
||
onClose()
|
||
onNavigate?.()
|
||
router.push('/terms')
|
||
}}
|
||
>
|
||
{t('topUp.terms')}
|
||
</Text>
|
||
<Text style={styles.agreementText}> {t('topUp.agreementAnd')} </Text>
|
||
<Text
|
||
style={styles.agreementLink}
|
||
onPress={(e) => {
|
||
e.stopPropagation()
|
||
onClose()
|
||
onNavigate?.()
|
||
router.push('/privacy')
|
||
}}
|
||
>
|
||
{t('topUp.privacy')}
|
||
</Text>
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
</BottomSheetView>
|
||
</BottomSheet>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
bottomSheetBackground: {
|
||
backgroundColor: '#090A0B',
|
||
},
|
||
handleIndicator: {
|
||
backgroundColor: '#666666',
|
||
},
|
||
container: {
|
||
backgroundColor: '#090A0B',
|
||
paddingHorizontal: 12,
|
||
overflow: 'visible',
|
||
},
|
||
titleContainer: {
|
||
position: 'absolute',
|
||
top:10,
|
||
left: 0,
|
||
right: 0,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 10,
|
||
height: 40,
|
||
overflow: 'visible',
|
||
},
|
||
strokeTextWrapper: {
|
||
position: 'absolute',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
titleText: {
|
||
fontSize: 24,
|
||
fontWeight: '600',
|
||
textAlign: 'center',
|
||
zIndex: 1,
|
||
includeFontPadding: false,
|
||
textAlignVertical: 'center',
|
||
lineHeight: 28,
|
||
},
|
||
titleStroke: {
|
||
color: '#000000',
|
||
},
|
||
titleFill: {
|
||
color: '#F5F5F5',
|
||
position: 'relative',
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingTop: 12,
|
||
marginTop: 20,
|
||
},
|
||
closeButton: {
|
||
width: 24,
|
||
height: 24,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
infoSection: {
|
||
marginBottom: 24,
|
||
},
|
||
infoText: {
|
||
color: '#F5F5F5',
|
||
fontSize: 14,
|
||
fontWeight: '400',
|
||
marginBottom: 8,
|
||
},
|
||
subtitle: {
|
||
color: '#ABABAB',
|
||
fontSize: 12,
|
||
fontWeight: '400',
|
||
},
|
||
optionsGrid: {
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
marginTop: 12,
|
||
},
|
||
optionCardWrapper: {
|
||
width: '47%',
|
||
aspectRatio: 2.3,
|
||
},
|
||
optionCardGradient: {
|
||
width: '100%',
|
||
height: '100%',
|
||
borderRadius: 12,
|
||
padding: 2,
|
||
},
|
||
optionCard: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderRadius: 12,
|
||
backgroundColor: '#16181B',
|
||
overflow: 'hidden',
|
||
gap: 4,
|
||
},
|
||
optionContent: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 2,
|
||
},
|
||
optionPoints: {
|
||
color: '#F5F5F5',
|
||
fontSize: 20,
|
||
fontWeight: '500',
|
||
},
|
||
optionPrice: {
|
||
color: '#ABABAB',
|
||
fontSize: 12,
|
||
fontWeight: '400',
|
||
},
|
||
footer: {
|
||
paddingTop: 20,
|
||
gap: 16,
|
||
},
|
||
confirmButton: {
|
||
width: '100%',
|
||
height: 48,
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
},
|
||
confirmButtonGradient: {
|
||
width: '100%',
|
||
height: 48,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderRadius: 12,
|
||
},
|
||
confirmButtonDisabled: {
|
||
opacity: 0.5,
|
||
},
|
||
confirmButtonText: {
|
||
color: '#F5F5F5',
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
},
|
||
agreementContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
checkbox: {
|
||
width: 12,
|
||
height: 12,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 4,
|
||
},
|
||
agreementText: {
|
||
color: '#8A8A8A',
|
||
fontSize: 10,
|
||
fontWeight: '400',
|
||
},
|
||
agreementLink: {
|
||
color: '#ABABAB',
|
||
textDecorationLine: 'underline',
|
||
},
|
||
})
|
||
|