expo-popcore-app/app/membership.tsx

845 lines
31 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, useEffect, useRef } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
StatusBar as RNStatusBar,
Platform,
useWindowDimensions,
ActivityIndicator,
} 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 { useMembership } from '@/hooks/use-membership'
import { useActivates } from '@/hooks/use-activates'
// 使用唯一 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 // 每月积分
currency: string // 货币类型
featureList: string[] // 功能列表(翻译 key
}
// 货币符号转换函数
function toUnit(currency: string): string {
switch (currency.toLowerCase()) {
case 'hkd':
return 'HK$'
case 'cny':
return '¥'
default:
return '$'
}
}
export default function MembershipScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { width: screenWidth } = useWindowDimensions()
const [agreed, setAgreed] = useState(true)
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
const [isSubscribing, setIsSubscribing] = useState(false)
const { t } = useTranslation()
// 使用 useMembership hook
const {
creditPlans,
creditBalance,
selectedPlanIndex,
setSelectedPlanIndex,
isLoadingSubscriptions,
isStripePricingLoading,
createSubscription,
upgradeSubscription,
restoreSubscription,
rechargeToken,
activeAuthSubscription,
hasActiveSubscription,
stripePricingData,
} = useMembership()
// 使用 useActivates hook 获取广告数据
const { load: loadActivates, data: activatesData } = useActivates()
// 映射 API 数据到 UI 格式
const plans: Plan[] = creditPlans.map((plan, index) => ({
id: `plan-${index}` as PlanType,
name: plan.name,
price: plan.amountInCents / 100,
recommended: plan.popular,
points: plan.credits,
currency: plan.currency,
featureList: plan.featureList,
}))
const selectedPlan = plans[selectedPlanIndex] || plans[0]
// 下拉菜单选项
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 activities = activatesData?.activities || []
const [currentImageIndex, setCurrentImageIndex] = useState(0)
// 加载广告数据
useEffect(() => {
loadActivates()
}, [])
// 处理订阅按钮点击
const handleSubscribe = async () => {
if (!agreed || !selectedPlan) return
const planData = creditPlans[selectedPlanIndex]
if (!planData) return
setIsSubscribing(true)
try {
if (hasActiveSubscription) {
await upgradeSubscription({ credits: planData.credits })
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
await restoreSubscription()
} else {
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
if (pricingItem) {
await createSubscription({
priceId: pricingItem.price_id,
productId: pricingItem.product_id,
})
}
}
} finally {
setIsSubscribing(false)
}
}
// 获取当前选中计划的信息
const currentPlan = selectedPlan
// 计算进度条百分比:当前计划积分 / 最高计划积分
const maxPoints = plans.length > 0 ? Math.max(...plans.map(plan => plan.points)) : 1
const progressPercentage = currentPlan?.points ? (currentPlan.points / maxPoints) * 100 : 0
// 加载状态
if (isStripePricingLoading || creditPlans.length === 0) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<View style={[styles.container, styles.loadingContainer]}>
<ActivityIndicator size="large" color="#FF9966" />
</View>
</SafeAreaView>
)
}
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}>{creditBalance}</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}>
{/* 只在有广告数据时显示轮播图 */}
{activities.length > 0 && (
<>
<Carousel
testID="carousel"
width={screenWidth}
height={screenWidth / 1.1}
data={activities}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(item.link as any)}
style={{ width: screenWidth, height: screenWidth / 1.1 }}
>
<Image
source={{ uri: item.coverUrl }}
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
contentFit="cover"
/>
</Pressable>
)}
autoPlay
autoPlayInterval={2000}
loop
onSnapToItem={(index) => setCurrentImageIndex(index)}
enabled={false}
windowSize={1}
mode="parallax"
/>
<View style={styles.dotsContainer}>
{activities.map((_, index) => (
<View
key={index}
testID={`dot-${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 = selectedPlanIndex === index
return (
<Pressable
key={plan.id}
style={[
styles.planCardWrapper,
index > 0 && styles.planCardSpacing,
]}
onPress={() => setSelectedPlanIndex(index)}
>
{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}
>
{toUnit(plan.currency)}
</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}
>
{toUnit(plan.currency)}
</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.featureList.map((featureKey, index) => {
const translatedText = t(featureKey)
// 如果没有对应的翻译键(翻译结果等于键本身),不展示该项
if (translatedText === featureKey) {
return null
}
return (
<View key={index} style={styles.featureItem}>
<CheckMarkIcon />
<Text style={styles.featureText}>{translatedText}</Text>
</View>
)
})}
</View>
</View>
</ScrollView>
{/* 固定在底部的订阅容器 */}
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
{/* 立即开通按钮 */}
<Pressable
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
style={styles.subscribeButtonPressable}
onPress={handleSubscribe}
>
<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 || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled,
]}
>
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? (
<ActivityIndicator color="#F5F5F5" />
) : (
<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={creditBalance}
subscriptionPoints={0}
topUpPoints={0}
onRecharge={(amount) => rechargeToken(amount)}
/>
</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,
marginBottom: 16,
paddingVertical: 8,
},
agreementTextWrapper: {
marginLeft: 4,
},
checkbox: {
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
padding: 4,
},
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,
},
loadingContainer: {
justifyContent: 'center',
alignItems: 'center',
},
})