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

434 lines
13 KiB
TypeScript
Raw 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,
useWindowDimensions,
} 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 { CloseIcon, CheckIcon, UncheckedIcon, PointsIcon } from '@/components/icon'
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 },
]
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 snapPoints = useMemo(() => [420], [])
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')
const handleConfirm = () => {
if (selectedOption && agreed) {
onConfirm?.(selectedOption)
}
}
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}
>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.confirmButtonGradient,
(!agreed || !selectedOption) && styles.confirmButtonDisabled,
]}
>
<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',
},
})