expo-popcore-app/app/membership.tsx

775 lines
28 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, useEffect, useRef } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
StatusBar as RNStatusBar,
Platform,
useWindowDimensions,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { Image } from 'expo-image'
import Carousel from 'react-native-reanimated-carousel'
import Svg, { Path, Defs, LinearGradient as SvgLinearGradient, Stop } from 'react-native-svg'
import { CheckIcon, LeftArrowIcon, OmitIcon, UncheckedIcon, TermsIcon, PrivacyIcon } from '@/components/icon'
import { CheckMarkIcon } from '@/components/icon/checkMark'
import PointsDrawer from '@/components/drawer/PointsDrawer'
import Dropdown from '@/components/ui/dropdown'
import GradientText from '@/components/GradientText'
import { useUserBalance } from '@/hooks/use-user-balance'
// 使用唯一 id 的 PointsIcon避免与其他页面的图标 id 冲突
const MembershipPointsIcon = () => {
const gradientId = 'paint0_linear_membership'
return (
<Svg width="14" height="14" viewBox="0 0 14 14" fill="none" preserveAspectRatio="xMidYMid meet">
<Defs>
<SvgLinearGradient
id={gradientId}
x1="7.00033"
y1="1.73615"
x2="7.00033"
y2="12.2639"
gradientUnits="userSpaceOnUse"
>
<Stop offset="0" stopColor="#FFC738" />
<Stop offset="1" stopColor="#FFA92D" />
</SvgLinearGradient>
</Defs>
<Path
d="M10.297 6.5548H8.38602C8.21382 6.5548 8.08222 6.3994 8.11022 6.23L8.79062 2.0622C8.83542 1.7892 8.49802 1.624 8.30902 1.8256L3.49862 6.9734C3.33202 7.1526 3.45802 7.4452 3.70302 7.4452H5.61402C5.78622 7.4452 5.91782 7.6006 5.88982 7.77L5.20942 11.9378C5.16462 12.2108 5.50202 12.376 5.69102 12.1744L10.5014 7.0266C10.6694 6.8474 10.542 6.5548 10.297 6.5548Z"
fill={`url(#${gradientId})`}
/>
</Svg>
)
}
type PlanType = 'plus' | 'pro' | 'plus-premium'
interface Plan {
id: PlanType
name: string
price: number
recommended?: boolean
points: number // 每月积分
features: string[] // 功能列表
}
export default function MembershipScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { width: screenWidth } = useWindowDimensions()
const [selectedPlan, setSelectedPlan] = useState<PlanType>('pro')
const [agreed, setAgreed] = useState(false)
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
const { t } = useTranslation()
// 获取积分余额
const { balance } = useUserBalance()
// 订阅计划数据(使用国际化)
const plans: Plan[] = [
{
id: 'plus',
name: 'Plus',
price: 9,
points: 750,
features: [
t('membership.features.points750'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
]
},
{
id: 'pro',
name: 'Pro',
price: 29,
recommended: true,
points: 1080,
features: [
t('membership.features.points1080'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
t('membership.features.sora2ProTemplate'),
t('membership.features.removeWatermark'),
t('membership.features.allTemplates'),
]
},
{
id: 'plus-premium',
name: 'Plus',
price: 49,
points: 1500,
features: [
t('membership.features.points1500'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
t('membership.features.sora2ProTemplate'),
t('membership.features.removeWatermark'),
t('membership.features.allTemplates'),
t('membership.features.higherQuality'),
t('membership.features.prioritySupport'),
]
},
]
// 下拉菜单选项
const menuOptions = [
{ label: t('membership.terms'), value: 'terms' },
{ label: t('membership.privacy'), value: 'privacy' },
]
// 处理菜单选择
const handleMenuSelect = (value: string) => {
if (value === 'terms') {
router.push('/terms')
} else if (value === 'privacy') {
router.push('/privacy')
}
}
// 轮播图相关
const carouselImages = [
require('@/assets/images/membership.png'),
require('@/assets/images/icon.png'),
require('@/assets/images/generate.png'),
]
const [currentImageIndex, setCurrentImageIndex] = useState(0)
// 获取当前选中计划的信息
const currentPlan = plans.find(plan => plan.id === selectedPlan) || plans[1]
// 计算进度条百分比:当前计划积分 / 最高计划积分
const maxPoints = Math.max(...plans.map(plan => plan.points))
const progressPercentage = (currentPlan.points / maxPoints) * 100
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar
barStyle="light-content"
backgroundColor="#090A0B"
translucent={Platform.OS === 'android'}
/>
<View style={styles.contentWrapper}>
{/* 顶部导航栏 - 固定在顶部 */}
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
>
<LeftArrowIcon className="w-4 h-4" />
</Pressable>
<View style={styles.headerSpacer} />
<View style={styles.pointsContainer}>
<Pressable
style={styles.pointsPill}
onPress={() => setPointsDrawerVisible(true)}
>
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
<MembershipPointsIcon />
<Text style={styles.pointsValue}>{balance}</Text>
</Pressable>
<View style={styles.settingsButtonContainer}>
<Dropdown
options={menuOptions}
onSelect={(value) => handleMenuSelect(value)}
offsetTop={10}
renderTrigger={(selectedOption, isOpen, toggle) => (
<Pressable style={styles.settingsButton} onPress={toggle}>
<OmitIcon />
</Pressable>
)}
dropdownStyle={styles.menuDropdown}
renderOption={(option, isSelected) => (
<View style={styles.menuOption}>
{option.value === 'terms' ? (
<TermsIcon />
) : (
<PrivacyIcon />
)}
<Text style={styles.menuOptionText}>{option.label}</Text>
</View>
)}
/>
</View>
</View>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.imageContainer}>
<Carousel
width={screenWidth}
height={screenWidth / 1.1}
data={carouselImages}
renderItem={({ item }) => (
<Image
source={item}
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
contentFit="cover"
/>
)}
autoPlay
autoPlayInterval={2000}
loop
onSnapToItem={(index) => setCurrentImageIndex(index)}
/>
<View style={styles.dotsContainer}>
{carouselImages.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentImageIndex && styles.dotActive,
]}
/>
))}
</View>
<LinearGradient
colors={['#090A0B00', '#090A0B']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.imageGradient}
pointerEvents="none"
/>
</View>
{/* 订阅计划标题 */}
<Text style={styles.sectionTitle}>{t('membership.subscriptionPlan')}</Text>
{/* 订阅计划卡片 */}
<View style={styles.plansContainer}>
{plans.map((plan, index) => {
const isSelected = selectedPlan === plan.id
return (
<Pressable
key={plan.id}
style={[
styles.planCardWrapper,
index > 0 && styles.planCardSpacing,
]}
onPress={() => setSelectedPlan(plan.id)}
>
{isSelected ? (
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.planCardGradient}
>
<View style={styles.planCard}>
{plan.recommended && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
</View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.priceContainer}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceSymbol}
>
$
</GradientText>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceValue}>{plan.price}</GradientText>
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
</View>
</View>
</LinearGradient>
) : (
<View style={styles.planCardWrapperUnselected}>
<View style={styles.planCard}>
{plan.recommended && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
</View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.priceContainer}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceSymbol}
>
$
</GradientText>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceValue}>{plan.price}</GradientText>
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
</View>
</View>
</View>
)}
</Pressable>
)
})}
</View>
{/* 卡片所属的信息 */}
<View style={styles.pointsMonthlyContainer}>
{/* 积分每月显示 */}
<View style={styles.pointsMonthlyCard}>
<View style={styles.pointsMonthlyHeader}>
<MembershipPointsIcon />
<Text style={styles.pointsMonthlyValue}>
{currentPlan.points.toLocaleString()}
</Text>
<Text style={styles.pointsMonthlyLabel}>{t('membership.pointsPerMonth')}</Text>
</View>
<View style={styles.progressBar}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
/>
</View>
<Text style={styles.pointsMonthlyNote}>{t('membership.pointsAutoRenew')}</Text>
</View>
{/* 功能列表 */}
<View >
{currentPlan.features.map((feature, index) => (
<View key={index} style={styles.featureItem}>
<CheckMarkIcon />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
</View>
</ScrollView>
{/* 固定在底部的订阅容器 */}
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
{/* 立即开通按钮 */}
<Pressable
disabled={!agreed}
style={styles.subscribeButtonPressable}
>
<LinearGradient
colors={currentPlan.recommended
? ['#9966FF', '#FF6699', '#FF9966']
: ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF']
}
locations={currentPlan.recommended
? [0.0015, 0.4985, 0.9956]
: [0, 0.33, 0.66, 1]
}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 0 }}
style={[
styles.subscribeButton,
!agreed && styles.subscribeButtonDisabled,
]}
>
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
</LinearGradient>
</Pressable>
{/* 协议复选框 */}
<View style={styles.agreementContainer}>
<Pressable
style={styles.checkbox}
onPress={() => setAgreed(!agreed)}
>
{agreed ? <CheckIcon /> : <UncheckedIcon />}
</Pressable>
<View style={styles.agreementTextWrapper}>
<Text style={styles.agreementText}>
{t('membership.agreementText')}{' '}
<Text
style={styles.agreementLink}
onPress={() => router.push('/terms')}
>
{t('membership.terms')}
</Text>
<Text style={styles.agreementText}> {t('membership.agreementAnd')} </Text>
<Text
style={styles.agreementLink}
onPress={() => router.push('/privacy')}
>
{t('membership.privacy')}
</Text>
</Text>
</View>
</View>
</View>
</View>
{/* 积分抽屉 */}
<PointsDrawer
visible={pointsDrawerVisible}
onClose={() => setPointsDrawerVisible(false)}
totalPoints={balance}
subscriptionPoints={0}
topUpPoints={0}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
contentWrapper: {
flex: 1,
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
flexGrow: 1,
paddingTop: 56, // 为固定的导航栏留出空间
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingTop: 7,
paddingBottom: 7,
zIndex: 100,
backgroundColor: 'transparent',
},
imageContainer: {
width: '100%',
overflow: 'hidden',
marginTop: -56,
position: 'relative',
},
membershipImage: {
width: '100%',
height: '100%',
},
imageGradient: {
position: 'absolute',
bottom: -20,
left: 0,
right: 0,
height: '30%',
zIndex: 10,
elevation: 10,
},
dotsContainer: {
position: 'absolute',
bottom: 40,
left: 16,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 4,
zIndex: 20,
elevation: 20,
},
dot: {
width: 4,
height: 4,
borderRadius: 100,
backgroundColor: '#FFFFFF80',
},
dotActive: {
width: 10,
height: 4,
borderRadius: 100,
backgroundColor: '#FFFFFF',
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 100,
},
headerSpacer: {
flex: 1,
},
pointsPill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 100,
backgroundColor: '#2A2A2A80',
},
pointsLabel: {
color: '#F5F5F5',
fontSize: 11,
fontWeight: '500',
},
pointsValue: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
},
sectionTitle: {
textAlign: 'center',
color: '#F5F5F5',
fontSize: 24,
fontWeight: '900',
lineHeight: 32,
marginBottom: 16,
marginTop: 9,
fontStyle: 'italic',
},
plansContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
marginBottom: 16,
},
planCardWrapper: {
flex: 1,
},
planCardGradient: {
flex: 1,
borderRadius: 12,
padding: 2,
},
planCardWrapperUnselected: {
flex: 1,
borderRadius: 12,
padding: 2,
backgroundColor: 'transparent',
},
planCard: {
flex: 1,
paddingTop: 12,
paddingBottom: 16,
paddingHorizontal: 12,
borderRadius: 12,
backgroundColor: '#16181B',
position: 'relative',
overflow: 'hidden',
},
planCardSpacing: {
marginLeft: 12,
},
recommendedBadge: {
position: 'absolute',
top: -3,
right: -2,
paddingHorizontal: 11,
paddingBottom: 1,
borderRadius: 4,
backgroundColor: '#6851EB',
borderTopRightRadius: 12,
borderBottomRightRadius: 0,
},
recommendedText: {
color: '#F5F5F5',
fontSize: 10,
fontWeight: '500',
},
planName: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
marginBottom: 14,
},
priceContainer: {
flexDirection: 'row',
alignItems: 'baseline',
},
priceSymbol: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
marginRight: 2,
},
priceValue: {
color: '#F5F5F5',
fontSize: 24,
fontWeight: '500',
marginRight: 4,
},
priceUnit: {
color: '#ABABAB',
fontSize: 12,
fontWeight: '500',
},
pointsMonthlyContainer: {
marginHorizontal: 16,
backgroundColor: '#191B1F',
paddingHorizontal: 12,
paddingVertical: 12,
borderRadius: 12,
marginBottom: 16,
},
pointsMonthlyCard: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#272A30',
marginBottom: 24,
},
pointsMonthlyHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
pointsMonthlyLabelWrapper: {
marginLeft: 8,
},
pointsMonthlyValue: {
color: '#F5F5F5',
fontSize: 13,
fontWeight: '500',
},
pointsMonthlyLabel: {
color: '#F5F5F5',
fontSize: 13,
fontWeight: '500',
},
progressBar: {
width: '100%',
height: 3,
backgroundColor: '#484F5B',
borderRadius: 10,
marginBottom: 10,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 2,
},
pointsMonthlyNote: {
color: '#CCCCCC',
fontSize: 11,
fontWeight: '400',
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginBottom: 12,
},
featureText: {
flex: 1,
color: '#CCCCCC',
fontSize: 12,
fontWeight: '400',
},
agreementContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
agreementTextWrapper: {
marginLeft: 4,
},
checkbox: {
width: 12,
height: 12,
alignItems: 'center',
justifyContent: 'center',
},
agreementText: {
color: '#CCCCCC',
fontSize: 11,
fontWeight: '400',
},
agreementLink: {
color: '#CCCCCC',
},
subscribeContainer: {
paddingTop: 8,
alignItems: 'center',
paddingHorizontal: 16,
},
subscribeButtonPressable: {
width: '100%',
height: 48,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
subscribeButton: {
width: '100%',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
subscribeButtonDisabled: {
opacity: 0.5,
},
subscribeButtonText: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '500',
},
subscribeButtonText1: {
color: '#0D0D0E',
fontSize: 16,
fontWeight: '500',
},
settingsButtonContainer: {
position: 'relative',
},
settingsButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#2A2A2A80',
borderRadius: 100,
},
pointsContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
menuDropdown: {
width: 180,
minWidth: 180,
borderRadius: 12,
backgroundColor: '#2A2A2A80',
paddingVertical: 4,
right: 12,
},
menuOption: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 2,
gap: 8,
},
menuOptionText: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '400',
flex: 1,
},
})