feat(membership): 实现会员系统和购买流程

- 创建 MembershipModalContext 统一管理会员弹窗
- 优化 MembershipModal 产品套餐展示和购买流程
- 集成 RevenueCat SDK 并初始化内购功能
- 在个人中心添加会员 Banner,引导非会员用户订阅
- 修复日志工具的循环引用问题,确保错误信息正确记录
- 版本更新至 1.0.20

新增了完整的会员购买流程,包括套餐选择、购买确认、购买恢复等功能。会员 Banner 仅对非会员用户展示,已是会员的用户不会看到。同时优化了错误日志记录,避免循环引用导致的序列化失败。
This commit is contained in:
richarjiang
2025-10-24 09:16:04 +08:00
parent b75a8991ac
commit 2e11f694f8
9 changed files with 437 additions and 186 deletions

View File

@@ -2,6 +2,7 @@ import ActivityHeatMap from '@/components/ActivityHeatMap';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useNotifications } from '@/hooks/useNotifications';
@@ -14,7 +15,7 @@ import { useFocusEffect } from '@react-navigation/native';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -22,7 +23,8 @@ const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.
export default function PersonalScreen() {
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
const insets = useSafeAreaInsets();
const isLgAvaliable = isLiquidGlassAvailable()
@@ -40,6 +42,14 @@ export default function PersonalScreen() {
const clickTimestamps = useRef<number[]>([]);
const clickTimeoutRef = useRef<number | null>(null);
const handleMembershipPress = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) {
return;
}
openMembershipModal();
}, [ensureLoggedIn, openMembershipModal]);
// 计算底部间距
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
@@ -241,6 +251,25 @@ export default function PersonalScreen() {
</View>
);
const MembershipBanner = () => (
<View style={styles.sectionContainer}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
void handleMembershipPress();
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
style={styles.membershipBannerImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</TouchableOpacity>
</View>
);
// 数据统计部分
const StatsSection = () => (
<View style={styles.sectionContainer}>
@@ -398,6 +427,7 @@ export default function PersonalScreen() {
showsVerticalScrollIndicator={false}
>
<UserHeader />
{userProfile.isVip ? null : <MembershipBanner />}
<StatsSection />
<View style={styles.fishRecordContainer}>
{/* <Image
@@ -474,6 +504,11 @@ const styles = StyleSheet.create({
elevation: 2,
overflow: 'hidden',
},
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
// 用户信息区域
userInfoContainer: {
flexDirection: 'row',
@@ -609,4 +644,3 @@ const styles = StyleSheet.create({
marginLeft: 4,
},
});

View File

@@ -32,6 +32,7 @@ import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { fetchChallenges } from '@/store/challengesSlice';
import AsyncStorage from '@/utils/kvStore';
import { Provider } from 'react-redux';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
function Bootstrapper({ children }: { children: React.ReactNode }) {
@@ -207,12 +208,14 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
return (
<DialogProvider>
{children}
<PrivacyConsentModal
visible={showPrivacyModal}
onAgree={handlePrivacyAgree}
onDisagree={handlePrivacyDisagree}
/>
<MembershipModalProvider>
{children}
<PrivacyConsentModal
visible={showPrivacyModal}
onAgree={handlePrivacyAgree}
onDisagree={handlePrivacyDisagree}
/>
</MembershipModalProvider>
</DialogProvider>
);
}