feat(membership): 重构会员系统架构并优化VIP卡片显示

- 创建独立的会员服务模块 services/membership.ts,统一管理会员计划元数据和工具函数
- 新增 membershipSlice Redux状态管理,集中处理会员数据和状态
- 重构个人中心VIP会员卡片,支持动态显示会员计划和有效期
- 优化会员购买弹窗,使用统一的会员计划配置
- 改进会员数据获取流程,确保状态同步和一致性
This commit is contained in:
richarjiang
2025-10-29 16:08:58 +08:00
parent fcf1be211f
commit 7cd290d341
7 changed files with 569 additions and 114 deletions

View File

@@ -6,12 +6,14 @@ import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { getItem, setItem } from '@/utils/kvStore'; import { getItem, setItem } from '@/utils/kvStore';
import { log } from '@/utils/logger'; import { log } from '@/utils/logger';
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences'; import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@@ -57,6 +59,7 @@ export default function PersonalScreen() {
// 直接使用 Redux 中的用户信息,避免重复状态管理 // 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile); const userProfile = useAppSelector((state) => state.user.profile);
const activeMembershipPlanName = useAppSelector(selectActiveMembershipPlanName);
// 页面聚焦时获取最新用户信息 // 页面聚焦时获取最新用户信息
@@ -270,6 +273,72 @@ export default function PersonalScreen() {
</View> </View>
); );
const VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
.map((key) => fallbackProfile[key])
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
const rawExpireDate = userProfile.membershipExpiration
let formattedExpire = '长期有效';
if (typeof rawExpireDate === 'string' && rawExpireDate.trim().length > 0) {
const parsed = dayjs(rawExpireDate);
formattedExpire = parsed.isValid() ? parsed.format('YYYY年MM月DD日') : rawExpireDate;
}
const planName =
activeMembershipPlanName?.trim() ||
userProfile.vipPlanName?.trim() ||
'VIP 会员';
return (
<View style={styles.sectionContainer}>
<LinearGradient
colors={['#5B4CFF', '#8D5BEA']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.vipCard}
>
<View style={styles.vipCardDecorationLarge} />
<View style={styles.vipCardDecorationSmall} />
<View style={styles.vipCardHeader}>
<View style={styles.vipCardHeaderLeft}>
<View style={styles.vipBadge}>
<Ionicons name="sparkles-outline" size={16} color="#FFD361" />
<Text style={styles.vipBadgeText}></Text>
</View>
<Text style={styles.vipCardTitle}>{planName}</Text>
</View>
<View style={styles.vipCardIllustration}>
<Ionicons name="ribbon" size={32} color="rgba(255,255,255,0.88)" />
</View>
</View>
<View style={styles.vipCardFooter}>
<View style={styles.vipExpiryInfo}>
<Text style={styles.vipExpiryLabel}></Text>
<View style={styles.vipExpiryRow}>
<Ionicons name="time-outline" size={16} color="rgba(255,255,255,0.85)" />
<Text style={styles.vipExpiryValue}>{formattedExpire}</Text>
</View>
</View>
<TouchableOpacity
style={styles.vipChangeButton}
activeOpacity={0.85}
onPress={() => {
void handleMembershipPress();
}}
>
<Ionicons name="swap-horizontal-outline" size={16} color="#2F1767" />
<Text style={styles.vipChangeButtonText}></Text>
</TouchableOpacity>
</View>
</LinearGradient>
</View>
);
};
// 数据统计部分 // 数据统计部分
const StatsSection = () => ( const StatsSection = () => (
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
@@ -427,7 +496,7 @@ export default function PersonalScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<UserHeader /> <UserHeader />
{userProfile.isVip ? null : <MembershipBanner />} {userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
<StatsSection /> <StatsSection />
<View style={styles.fishRecordContainer}> <View style={styles.fishRecordContainer}>
{/* <Image {/* <Image
@@ -509,6 +578,116 @@ const styles = StyleSheet.create({
height: 180, height: 180,
borderRadius: 16, borderRadius: 16,
}, },
vipCard: {
borderRadius: 20,
padding: 20,
overflow: 'hidden',
shadowColor: '#4C3AFF',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 6,
},
vipCardDecorationLarge: {
position: 'absolute',
right: -40,
top: -30,
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: 'rgba(255,255,255,0.12)',
},
vipCardDecorationSmall: {
position: 'absolute',
left: -30,
bottom: -30,
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: 'rgba(255,255,255,0.08)',
},
vipCardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
vipCardHeaderLeft: {
flex: 1,
paddingRight: 16,
},
vipBadge: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
backgroundColor: 'rgba(255,255,255,0.18)',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 14,
},
vipBadgeText: {
color: '#FFD361',
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
},
vipCardTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
marginTop: 12,
},
vipCardSubtitle: {
color: 'rgba(255,255,255,0.88)',
fontSize: 12,
lineHeight: 18,
marginTop: 6,
},
vipCardIllustration: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(255,255,255,0.18)',
alignItems: 'center',
justifyContent: 'center',
},
vipCardFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
},
vipExpiryInfo: {
flex: 1,
},
vipExpiryLabel: {
color: 'rgba(255,255,255,0.72)',
fontSize: 12,
marginBottom: 6,
},
vipExpiryRow: {
flexDirection: 'row',
alignItems: 'center',
},
vipExpiryValue: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '600',
marginLeft: 6,
},
vipChangeButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
vipChangeButtonText: {
color: '#2F1767',
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
// 用户信息区域 // 用户信息区域
userInfoContainer: { userInfoContainer: {
flexDirection: 'row', flexDirection: 'row',

View File

@@ -1,6 +1,16 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import CustomCheckBox from '@/components/ui/CheckBox'; import CustomCheckBox from '@/components/ui/CheckBox';
import { USER_AGREEMENT_URL } from '@/constants/Agree'; import { USER_AGREEMENT_URL } from '@/constants/Agree';
import { useAppDispatch } from '@/hooks/redux';
import {
MEMBERSHIP_PLAN_META,
extractMembershipProductsFromOfferings,
getActiveSubscriptionIds,
getPlanMetaById,
resolvePlanDisplayName,
type MembershipPlanType,
} from '@/services/membership';
import { fetchMembershipData } from '@/store/membershipSlice';
import { log, logger } from '@/utils/logger'; import { log, logger } from '@/utils/logger';
import { import {
captureMessage, captureMessage,
@@ -36,59 +46,7 @@ interface MembershipModalProps {
onPurchaseSuccess?: () => void; onPurchaseSuccess?: () => void;
} }
interface MembershipPlan {
id: string;
fallbackTitle: string;
subtitle: string;
type: 'weekly' | 'quarterly' | 'lifetime';
recommended?: boolean;
tag?: string;
originalPrice?: string;
}
const DEFAULT_PLANS: MembershipPlan[] = [
{
id: 'com.anonymous.digitalpilates.membership.lifetime',
fallbackTitle: '终身会员',
subtitle: '一次投入,终身健康陪伴',
type: 'lifetime',
recommended: true,
tag: '限时特价',
originalPrice: '¥898',
},
{
id: 'com.anonymous.digitalpilates.membership.quarter',
fallbackTitle: '季度会员',
subtitle: '3个月蜕变计划见证身材变化',
type: 'quarterly',
originalPrice: '¥598',
},
{
id: 'com.anonymous.digitalpilates.membership.weekly',
fallbackTitle: '周会员',
subtitle: '7天体验开启健康第一步',
type: 'weekly',
originalPrice: '¥128',
},
];
// RevenueCat 在 JS SDK 中以 string[] 返回 activeSubscriptions但保持健壮性以防类型变化 // RevenueCat 在 JS SDK 中以 string[] 返回 activeSubscriptions但保持健壮性以防类型变化
const getActiveSubscriptionIds = (customerInfo: CustomerInfo): string[] => {
const activeSubscriptions = customerInfo.activeSubscriptions as unknown;
if (Array.isArray(activeSubscriptions)) {
return activeSubscriptions;
}
if (activeSubscriptions && typeof activeSubscriptions === 'object') {
return Object.values(activeSubscriptions as Record<string, string>).filter(
(value): value is string => typeof value === 'string'
);
}
return [];
};
// 权限类型枚举 // 权限类型枚举
type PermissionType = 'exclusive' | 'limited' | 'unlimited'; type PermissionType = 'exclusive' | 'limited' | 'unlimited';
@@ -151,37 +109,9 @@ const BENEFIT_COMPARISON: BenefitItem[] = [
vipText: '基础提醒' vipText: '基础提醒'
} }
}, },
{
title: 'AI教练对话',
description: '与AI健康教练进行个性化对话咨询',
vip: {
type: 'unlimited',
text: '无限次对话',
vipText: '深度分析'
},
regular: {
type: 'limited',
text: '有限次对话',
vipText: '每日10次'
}
},
{
title: '体态评估',
description: '通过照片分析体态问题并提供改善建议',
vip: {
type: 'exclusive',
text: '完全支持',
vipText: '专业评估'
},
regular: {
type: 'exclusive',
text: '不可使用',
vipText: '不可使用'
}
}
]; ];
const PLAN_STYLE_CONFIG: Record<MembershipPlan['type'], { gradient: readonly [string, string]; accent: string }> = { const PLAN_STYLE_CONFIG: Record<MembershipPlanType, { gradient: readonly [string, string]; accent: string }> = {
lifetime: { lifetime: {
gradient: ['#FFF1DD', '#FFE8FA'] as const, gradient: ['#FFF1DD', '#FFE8FA'] as const,
accent: '#7B2CBF', accent: '#7B2CBF',
@@ -221,6 +151,7 @@ const getPermissionIcon = (type: PermissionType, isVip: boolean) => {
}; };
export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) { export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) {
const dispatch = useAppDispatch();
const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null); const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [restoring, setRestoring] = useState(false); const [restoring, setRestoring] = useState(false);
@@ -238,7 +169,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
const getTipsContent = (product: PurchasesStoreProduct | null): string => { const getTipsContent = (product: PurchasesStoreProduct | null): string => {
if (!product) return ''; if (!product) return '';
const plan = DEFAULT_PLANS.find(item => item.id === product.identifier); const plan = MEMBERSHIP_PLAN_META.find(item => item.id === product.identifier);
if (!plan) { if (!plan) {
return ''; return '';
} }
@@ -299,9 +230,9 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
allOfferingsCount: Object.keys(offerings.all).length, allOfferingsCount: Object.keys(offerings.all).length,
}); });
const packages = offerings.current?.availablePackages ?? []; const productsToUse = extractMembershipProductsFromOfferings(offerings);
if (packages.length === 0) { if (productsToUse.length === 0) {
log.warn('没有找到可用的产品套餐', { log.warn('没有找到可用的产品套餐', {
hasCurrentOffering: offerings.current !== null, hasCurrentOffering: offerings.current !== null,
packagesLength: offerings.current?.availablePackages.length || 0, packagesLength: offerings.current?.availablePackages.length || 0,
@@ -315,17 +246,6 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
return; return;
} }
const matchedProducts = packages
.map(pkg => pkg.product)
.filter(product => DEFAULT_PLANS.some(plan => plan.id === product.identifier));
const orderedProducts = DEFAULT_PLANS
.map(plan => matchedProducts.find(product => product.identifier === plan.id))
.filter((product): product is PurchasesStoreProduct => Boolean(product));
const fallbackProducts = packages.map(pkg => pkg.product);
const productsToUse = orderedProducts.length > 0 ? orderedProducts : fallbackProducts;
log.info('productsToUse', productsToUse) log.info('productsToUse', productsToUse)
setProducts(productsToUse); setProducts(productsToUse);
@@ -394,6 +314,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 延迟一点时间,确保购买流程完全完成 // 延迟一点时间,确保购买流程完全完成
setTimeout(async () => { setTimeout(async () => {
void dispatch(fetchMembershipData());
// 刷新用户信息 // 刷新用户信息
// await refreshUserInfo(); // await refreshUserInfo();
@@ -489,7 +410,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
let selectedProduct: PurchasesStoreProduct | null = null; let selectedProduct: PurchasesStoreProduct | null = null;
// 优先级:终身会员 > 季度会员 > 周会员 // 优先级:终身会员 > 季度会员 > 周会员
const priorityOrder = DEFAULT_PLANS.map(plan => plan.id); const priorityOrder = MEMBERSHIP_PLAN_META.map(plan => plan.id);
// 按照优先级查找 // 按照优先级查找
for (const priorityProductId of priorityOrder) { for (const priorityProductId of priorityOrder) {
@@ -747,6 +668,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
restoredProducts, restoredProducts,
restoredProductsCount: restoredProducts.length restoredProductsCount: restoredProducts.length
}); });
void dispatch(fetchMembershipData());
try { try {
// 调用后台服务接口进行票据匹配 // 调用后台服务接口进行票据匹配
@@ -856,16 +778,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
const renderPlanCard = (product: PurchasesStoreProduct) => { const renderPlanCard = (product: PurchasesStoreProduct) => {
const plan = DEFAULT_PLANS.find(p => p.id === product.identifier); const planMeta = getPlanMetaById(product.identifier);
if (!plan) {
return null;
}
const isSelected = selectedProduct === product; const isSelected = selectedProduct === product;
const displayTitle = product.title || plan.fallbackTitle; const displayTitle = resolvePlanDisplayName(product, planMeta);
const priceLabel = product.priceString || ''; const priceLabel = product.priceString || '';
const styleConfig = PLAN_STYLE_CONFIG[plan.type]; const styleConfig = planMeta ? PLAN_STYLE_CONFIG[planMeta.type] : undefined;
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -890,9 +807,9 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
style={styles.planCardGradient} style={styles.planCardGradient}
> >
<View style={styles.planCardTopSection}> <View style={styles.planCardTopSection}>
{plan.tag && ( {planMeta?.tag && (
<View style={styles.planTag}> <View style={styles.planTag}>
<Text style={styles.planTagText}>{plan.tag}</Text> <Text style={styles.planTagText}>{planMeta.tag}</Text>
</View> </View>
)} )}
<Text style={styles.planCardTitle}>{displayTitle}</Text> <Text style={styles.planCardTitle}>{displayTitle}</Text>
@@ -902,13 +819,13 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<Text style={[styles.planCardPrice, styleConfig && { color: styleConfig.accent }]}> <Text style={[styles.planCardPrice, styleConfig && { color: styleConfig.accent }]}>
{priceLabel || '--'} {priceLabel || '--'}
</Text> </Text>
{plan.originalPrice && ( {planMeta?.originalPrice && (
<Text style={styles.planCardOriginalPrice}>{plan.originalPrice}</Text> <Text style={styles.planCardOriginalPrice}>{planMeta.originalPrice}</Text>
)} )}
</View> </View>
<View style={styles.planCardBottomSection}> <View style={styles.planCardBottomSection}>
<Text style={styles.planCardDescription}>{plan.subtitle}</Text> <Text style={styles.planCardDescription}>{planMeta?.subtitle ?? ''}</Text>
</View> </View>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -2,7 +2,8 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
import Purchases from 'react-native-purchases'; import Purchases from 'react-native-purchases';
import { MembershipModal } from '@/components/model/MembershipModal'; import { MembershipModal } from '@/components/model/MembershipModal';
import { useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { fetchMembershipData } from '@/store/membershipSlice';
import { selectUserProfile } from '@/store/userSlice'; import { selectUserProfile } from '@/store/userSlice';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
@@ -18,6 +19,7 @@ interface MembershipModalContextValue {
const MembershipModalContext = createContext<MembershipModalContextValue | null>(null); const MembershipModalContext = createContext<MembershipModalContextValue | null>(null);
export function MembershipModalProvider({ children }: { children: React.ReactNode }) { export function MembershipModalProvider({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [pendingSuccessCallback, setPendingSuccessCallback] = useState<(() => void) | undefined>(); const [pendingSuccessCallback, setPendingSuccessCallback] = useState<(() => void) | undefined>();
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
@@ -46,6 +48,7 @@ export function MembershipModalProvider({ children }: { children: React.ReactNod
await Purchases.configure(configOptions); await Purchases.configure(configOptions);
setIsInitialized(true); setIsInitialized(true);
logger.info('[MembershipModalProvider] RevenueCat SDK 初始化成功'); logger.info('[MembershipModalProvider] RevenueCat SDK 初始化成功');
dispatch(fetchMembershipData());
} }
} catch (error) { } catch (error) {
logger.error('[MembershipModalProvider] RevenueCat SDK 初始化失败:', error); logger.error('[MembershipModalProvider] RevenueCat SDK 初始化失败:', error);
@@ -54,7 +57,7 @@ export function MembershipModalProvider({ children }: { children: React.ReactNod
}; };
initializeRevenueCat(); initializeRevenueCat();
}, [isInitialized, userProfile?.id]); }, [dispatch, isInitialized, userProfile?.id]);
// 监听用户登录状态变化在用户登录后更新RevenueCat的用户标识 // 监听用户登录状态变化在用户登录后更新RevenueCat的用户标识
useEffect(() => { useEffect(() => {
@@ -70,14 +73,17 @@ export function MembershipModalProvider({ children }: { children: React.ReactNod
console.log('[MembershipModalProvider] 更新RevenueCat用户标识:', userProfile.id); console.log('[MembershipModalProvider] 更新RevenueCat用户标识:', userProfile.id);
await Purchases.logIn(userProfile.id); await Purchases.logIn(userProfile.id);
} }
dispatch(fetchMembershipData());
} catch (error) { } catch (error) {
console.error('[MembershipModalProvider] 更新RevenueCat用户标识失败:', error); console.error('[MembershipModalProvider] 更新RevenueCat用户标识失败:', error);
} }
} else if (isInitialized && !userProfile?.id) {
dispatch(fetchMembershipData());
} }
}; };
updateRevenueCatUser(); updateRevenueCatUser();
}, [userProfile?.id, isInitialized]); }, [dispatch, userProfile?.id, isInitialized]);
const openMembershipModal = useCallback((options?: MembershipModalOptions) => { const openMembershipModal = useCallback((options?: MembershipModalOptions) => {
setPendingSuccessCallback(() => options?.onPurchaseSuccess); setPendingSuccessCallback(() => options?.onPurchaseSuccess);

211
services/membership.ts Normal file
View File

@@ -0,0 +1,211 @@
import type {
CustomerInfo,
PurchasesOfferings,
PurchasesStoreProduct,
} from 'react-native-purchases';
export type MembershipPlanType = 'weekly' | 'quarterly' | 'lifetime';
export interface MembershipPlanMeta {
id: string;
fallbackTitle: string;
subtitle: string;
type: MembershipPlanType;
recommended?: boolean;
tag?: string;
originalPrice?: string;
}
export const MEMBERSHIP_PLAN_META: MembershipPlanMeta[] = [
{
id: 'com.anonymous.digitalpilates.membership.lifetime',
fallbackTitle: '终身会员',
subtitle: '一次投入,终身健康陪伴',
type: 'lifetime',
recommended: true,
tag: '限时特价',
originalPrice: '¥898',
},
{
id: 'com.anonymous.digitalpilates.membership.quarter',
fallbackTitle: '季度会员',
subtitle: '3个月蜕变计划见证身材变化',
type: 'quarterly',
originalPrice: '¥598',
},
{
id: 'com.anonymous.digitalpilates.membership.weekly',
fallbackTitle: '周会员',
subtitle: '7天体验开启健康第一步',
type: 'weekly',
originalPrice: '¥128',
},
];
const MEMBERSHIP_PLAN_META_MAP = new Map(
MEMBERSHIP_PLAN_META.map((plan) => [plan.id, plan]),
);
export interface MembershipPlanSummary {
id: string;
title: string;
description: string;
price: number;
priceString: string;
type: MembershipPlanType | 'unknown';
fallbackTitle?: string;
subtitle?: string;
originalPrice?: string;
tag?: string;
recommended?: boolean;
}
export const getPlanMetaById = (id: string): MembershipPlanMeta | undefined =>
MEMBERSHIP_PLAN_META_MAP.get(id);
export const resolvePlanDisplayName = (
product: PurchasesStoreProduct | null | undefined,
fallbackPlan?: MembershipPlanMeta,
defaultName = 'VIP 会员',
): string => {
const productTitle = product?.title?.trim();
if (productTitle) {
return productTitle;
}
if (fallbackPlan?.fallbackTitle) {
return fallbackPlan.fallbackTitle;
}
return defaultName;
};
export const extractMembershipProductsFromOfferings = (
offerings: PurchasesOfferings,
): PurchasesStoreProduct[] => {
const packages = offerings.current?.availablePackages ?? [];
const matchedProducts = packages
.map((pkg) => pkg.product)
.filter((product) => MEMBERSHIP_PLAN_META_MAP.has(product.identifier));
if (matchedProducts.length > 0) {
return MEMBERSHIP_PLAN_META
.map((plan) =>
matchedProducts.find((product) => product.identifier === plan.id),
)
.filter((product): product is PurchasesStoreProduct => Boolean(product));
}
return packages.map((pkg) => pkg.product);
};
export const summarizeProducts = (
products: PurchasesStoreProduct[],
): MembershipPlanSummary[] =>
products.map((product) => {
const meta = getPlanMetaById(product.identifier);
return {
id: product.identifier,
title: product.title,
description: product.description,
price: product.price,
priceString: product.priceString,
type: meta?.type ?? 'unknown',
fallbackTitle: meta?.fallbackTitle,
subtitle: meta?.subtitle,
originalPrice: meta?.originalPrice,
tag: meta?.tag,
recommended: meta?.recommended ?? false,
};
});
export const getActiveSubscriptionIds = (
customerInfo: CustomerInfo,
): string[] => {
const activeSubscriptions = customerInfo.activeSubscriptions as unknown;
if (Array.isArray(activeSubscriptions)) {
return activeSubscriptions;
}
if (activeSubscriptions && typeof activeSubscriptions === 'object') {
return Object.values(activeSubscriptions as Record<string, string>).filter(
(value): value is string => typeof value === 'string',
);
}
return [];
};
export const collectActiveProductIdentifiers = (
customerInfo: CustomerInfo,
): string[] => {
const collected = new Set<string>();
Object.values(customerInfo.entitlements.active).forEach((entitlement) => {
if (entitlement?.productIdentifier) {
collected.add(entitlement.productIdentifier);
}
});
customerInfo.nonSubscriptionTransactions.forEach((transaction) => {
if (transaction?.productIdentifier) {
collected.add(transaction.productIdentifier);
}
});
getActiveSubscriptionIds(customerInfo).forEach((identifier) => {
if (identifier) {
collected.add(identifier);
}
});
return Array.from(collected);
};
export const hasActiveMembership = (customerInfo: CustomerInfo): boolean => {
if (Object.keys(customerInfo.entitlements.active).length > 0) {
return true;
}
if (customerInfo.nonSubscriptionTransactions.length > 0) {
return true;
}
return getActiveSubscriptionIds(customerInfo).length > 0;
};
export const pickActiveProductId = (
customerInfo: CustomerInfo,
availableProducts: PurchasesStoreProduct[],
): {
activeProductId: string | null;
activeProductIds: string[];
} => {
const activeProductIds = collectActiveProductIdentifiers(customerInfo);
if (activeProductIds.length === 0) {
return { activeProductId: null, activeProductIds };
}
const availableIds = new Set(
availableProducts.map((product) => product.identifier),
);
for (const plan of MEMBERSHIP_PLAN_META) {
if (
activeProductIds.includes(plan.id) &&
availableIds.has(plan.id)
) {
return { activeProductId: plan.id, activeProductIds };
}
}
for (const identifier of activeProductIds) {
if (availableIds.has(identifier)) {
return { activeProductId: identifier, activeProductIds };
}
}
return { activeProductId: null, activeProductIds };
};

View File

@@ -5,6 +5,7 @@ import circumferenceReducer from './circumferenceSlice';
import exerciseLibraryReducer from './exerciseLibrarySlice'; import exerciseLibraryReducer from './exerciseLibrarySlice';
import foodLibraryReducer from './foodLibrarySlice'; import foodLibraryReducer from './foodLibrarySlice';
import foodRecognitionReducer from './foodRecognitionSlice'; import foodRecognitionReducer from './foodRecognitionSlice';
import membershipReducer from './membershipSlice';
import goalsReducer from './goalsSlice'; import goalsReducer from './goalsSlice';
import healthReducer from './healthSlice'; import healthReducer from './healthSlice';
import fastingReducer, { import fastingReducer, {
@@ -108,6 +109,7 @@ export const store = configureStore({
exerciseLibrary: exerciseLibraryReducer, exerciseLibrary: exerciseLibraryReducer,
foodLibrary: foodLibraryReducer, foodLibrary: foodLibraryReducer,
foodRecognition: foodRecognitionReducer, foodRecognition: foodRecognitionReducer,
membership: membershipReducer,
workout: workoutReducer, workout: workoutReducer,
water: waterReducer, water: waterReducer,
fasting: fastingReducer, fasting: fastingReducer,

138
store/membershipSlice.ts Normal file
View File

@@ -0,0 +1,138 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import Purchases from 'react-native-purchases';
import {
extractMembershipProductsFromOfferings,
getPlanMetaById,
hasActiveMembership,
pickActiveProductId,
resolvePlanDisplayName,
summarizeProducts,
type MembershipPlanSummary,
} from '@/services/membership';
import type { RootState } from './index';
import { logout, updateProfile } from './userSlice';
export interface MembershipState {
plans: MembershipPlanSummary[];
activePlanId: string | null;
activePlanName: string | null;
hasActiveMembership: boolean;
loading: boolean;
error: string | null;
lastUpdated: number | null;
activeProductIds: string[];
}
const initialState: MembershipState = {
plans: [],
activePlanId: null,
activePlanName: null,
hasActiveMembership: false,
loading: false,
error: null,
lastUpdated: null,
activeProductIds: [],
};
export const fetchMembershipData = createAsyncThunk<
{
plans: MembershipPlanSummary[];
activePlanId: string | null;
activePlanName: string | null;
hasActiveMembership: boolean;
activeProductIds: string[];
lastUpdated: number;
},
void,
{ rejectValue: string }
>('membership/fetchMembershipData', async (_, { rejectWithValue, dispatch }) => {
try {
const offerings = await Purchases.getOfferings();
const products = extractMembershipProductsFromOfferings(offerings);
const plans = summarizeProducts(products);
const customerInfo = await Purchases.getCustomerInfo();
const { activeProductId, activeProductIds } = pickActiveProductId(
customerInfo,
products,
);
const activePlanMeta = activeProductId
? getPlanMetaById(activeProductId)
: undefined;
const activeProduct = activeProductId
? products.find((product) => product.identifier === activeProductId) ?? null
: null;
const hasActive = hasActiveMembership(customerInfo);
const activePlanName = hasActive
? resolvePlanDisplayName(activeProduct, activePlanMeta)
: null;
dispatch(
updateProfile({
vipPlanName: activePlanName ?? undefined,
isVip: hasActive,
}),
);
return {
plans,
activePlanId: activeProductId,
activePlanName,
hasActiveMembership: hasActive,
activeProductIds,
lastUpdated: Date.now(),
};
} catch (error: any) {
const message =
error?.message ??
(typeof error === 'string' ? error : '获取会员信息失败');
return rejectWithValue(message);
}
});
const membershipSlice = createSlice({
name: 'membership',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchMembershipData.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchMembershipData.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
state.plans = action.payload.plans;
state.activePlanId = action.payload.activePlanId;
state.activePlanName = action.payload.activePlanName;
state.hasActiveMembership = action.payload.hasActiveMembership;
state.activeProductIds = action.payload.activeProductIds;
state.lastUpdated = action.payload.lastUpdated;
})
.addCase(fetchMembershipData.rejected, (state, action) => {
state.loading = false;
state.error =
(action.payload as string) ?? '获取会员信息失败,请稍后重试';
})
.addCase(logout.fulfilled, () => ({
...initialState,
plans: [],
activeProductIds: [],
}));
},
});
export const selectMembershipState = (state: RootState): MembershipState =>
state.membership;
export const selectMembershipPlans = (state: RootState) =>
state.membership.plans;
export const selectActiveMembershipPlanName = (state: RootState) =>
state.membership.activePlanName;
export default membershipSlice.reducer;

View File

@@ -78,6 +78,8 @@ export type UserProfile = {
isVip?: boolean; isVip?: boolean;
freeUsageCount?: number; freeUsageCount?: number;
maxUsageCount?: number; maxUsageCount?: number;
membershipExpiration?: string | null;
vipPlanName?: string;
chestCircumference?: number; // 胸围 chestCircumference?: number; // 胸围
waistCircumference?: number; // 腰围 waistCircumference?: number; // 腰围
upperHipCircumference?: number; // 上臀围 upperHipCircumference?: number; // 上臀围