Initial commit: expo-popcore-app

This commit is contained in:
imeepos
2025-12-25 16:25:55 +08:00
commit 02d0c807cd
160 changed files with 39560 additions and 0 deletions

View File

@@ -0,0 +1,433 @@
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',
},
})