expo-popcore-app/components/drawer/TopUpDrawer.tsx

515 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
},
})