feat(membership): 重构会员系统架构并优化VIP卡片显示
- 创建独立的会员服务模块 services/membership.ts,统一管理会员计划元数据和工具函数 - 新增 membershipSlice Redux状态管理,集中处理会员数据和状态 - 重构个人中心VIP会员卡片,支持动态显示会员计划和有效期 - 优化会员购买弹窗,使用统一的会员计划配置 - 改进会员数据获取流程,确保状态同步和一致性
This commit is contained in:
@@ -6,12 +6,14 @@ import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
|
||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { log } from '@/utils/logger';
|
||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
@@ -57,6 +59,7 @@ export default function PersonalScreen() {
|
||||
|
||||
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
const activeMembershipPlanName = useAppSelector(selectActiveMembershipPlanName);
|
||||
|
||||
|
||||
// 页面聚焦时获取最新用户信息
|
||||
@@ -270,6 +273,72 @@ export default function PersonalScreen() {
|
||||
</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 = () => (
|
||||
<View style={styles.sectionContainer}>
|
||||
@@ -427,7 +496,7 @@ export default function PersonalScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<UserHeader />
|
||||
{userProfile.isVip ? null : <MembershipBanner />}
|
||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
||||
<StatsSection />
|
||||
<View style={styles.fishRecordContainer}>
|
||||
{/* <Image
|
||||
@@ -509,6 +578,116 @@ const styles = StyleSheet.create({
|
||||
height: 180,
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import CustomCheckBox from '@/components/ui/CheckBox';
|
||||
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 {
|
||||
captureMessage,
|
||||
@@ -36,59 +46,7 @@ interface MembershipModalProps {
|
||||
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,但保持健壮性以防类型变化
|
||||
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';
|
||||
|
||||
@@ -151,37 +109,9 @@ const BENEFIT_COMPARISON: BenefitItem[] = [
|
||||
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: {
|
||||
gradient: ['#FFF1DD', '#FFE8FA'] as const,
|
||||
accent: '#7B2CBF',
|
||||
@@ -221,6 +151,7 @@ const getPermissionIcon = (type: PermissionType, isVip: boolean) => {
|
||||
};
|
||||
|
||||
export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
@@ -238,7 +169,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
const getTipsContent = (product: PurchasesStoreProduct | null): string => {
|
||||
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) {
|
||||
return '';
|
||||
}
|
||||
@@ -299,9 +230,9 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
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('没有找到可用的产品套餐', {
|
||||
hasCurrentOffering: offerings.current !== null,
|
||||
packagesLength: offerings.current?.availablePackages.length || 0,
|
||||
@@ -315,17 +246,6 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
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)
|
||||
|
||||
setProducts(productsToUse);
|
||||
@@ -394,6 +314,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
|
||||
// 延迟一点时间,确保购买流程完全完成
|
||||
setTimeout(async () => {
|
||||
void dispatch(fetchMembershipData());
|
||||
// 刷新用户信息
|
||||
// await refreshUserInfo();
|
||||
|
||||
@@ -489,7 +410,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
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) {
|
||||
@@ -747,6 +668,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
restoredProducts,
|
||||
restoredProductsCount: restoredProducts.length
|
||||
});
|
||||
void dispatch(fetchMembershipData());
|
||||
|
||||
try {
|
||||
// 调用后台服务接口进行票据匹配
|
||||
@@ -856,16 +778,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
|
||||
|
||||
const renderPlanCard = (product: PurchasesStoreProduct) => {
|
||||
const plan = DEFAULT_PLANS.find(p => p.id === product.identifier);
|
||||
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const planMeta = getPlanMetaById(product.identifier);
|
||||
const isSelected = selectedProduct === product;
|
||||
const displayTitle = product.title || plan.fallbackTitle;
|
||||
const displayTitle = resolvePlanDisplayName(product, planMeta);
|
||||
const priceLabel = product.priceString || '';
|
||||
const styleConfig = PLAN_STYLE_CONFIG[plan.type];
|
||||
const styleConfig = planMeta ? PLAN_STYLE_CONFIG[planMeta.type] : undefined;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -890,9 +807,9 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
style={styles.planCardGradient}
|
||||
>
|
||||
<View style={styles.planCardTopSection}>
|
||||
{plan.tag && (
|
||||
{planMeta?.tag && (
|
||||
<View style={styles.planTag}>
|
||||
<Text style={styles.planTagText}>{plan.tag}</Text>
|
||||
<Text style={styles.planTagText}>{planMeta.tag}</Text>
|
||||
</View>
|
||||
)}
|
||||
<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 }]}>
|
||||
{priceLabel || '--'}
|
||||
</Text>
|
||||
{plan.originalPrice && (
|
||||
<Text style={styles.planCardOriginalPrice}>{plan.originalPrice}</Text>
|
||||
{planMeta?.originalPrice && (
|
||||
<Text style={styles.planCardOriginalPrice}>{planMeta.originalPrice}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.planCardBottomSection}>
|
||||
<Text style={styles.planCardDescription}>{plan.subtitle}</Text>
|
||||
<Text style={styles.planCardDescription}>{planMeta?.subtitle ?? ''}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -2,7 +2,8 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
||||
import Purchases from 'react-native-purchases';
|
||||
|
||||
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 { logger } from '@/utils/logger';
|
||||
|
||||
@@ -18,6 +19,7 @@ interface MembershipModalContextValue {
|
||||
const MembershipModalContext = createContext<MembershipModalContextValue | null>(null);
|
||||
|
||||
export function MembershipModalProvider({ children }: { children: React.ReactNode }) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pendingSuccessCallback, setPendingSuccessCallback] = useState<(() => void) | undefined>();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
@@ -46,6 +48,7 @@ export function MembershipModalProvider({ children }: { children: React.ReactNod
|
||||
await Purchases.configure(configOptions);
|
||||
setIsInitialized(true);
|
||||
logger.info('[MembershipModalProvider] RevenueCat SDK 初始化成功');
|
||||
dispatch(fetchMembershipData());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[MembershipModalProvider] RevenueCat SDK 初始化失败:', error);
|
||||
@@ -54,7 +57,7 @@ export function MembershipModalProvider({ children }: { children: React.ReactNod
|
||||
};
|
||||
|
||||
initializeRevenueCat();
|
||||
}, [isInitialized, userProfile?.id]);
|
||||
}, [dispatch, isInitialized, userProfile?.id]);
|
||||
|
||||
// 监听用户登录状态变化,在用户登录后更新RevenueCat的用户标识
|
||||
useEffect(() => {
|
||||
@@ -70,14 +73,17 @@ export function MembershipModalProvider({ children }: { children: React.ReactNod
|
||||
console.log('[MembershipModalProvider] 更新RevenueCat用户标识:', userProfile.id);
|
||||
await Purchases.logIn(userProfile.id);
|
||||
}
|
||||
dispatch(fetchMembershipData());
|
||||
} catch (error) {
|
||||
console.error('[MembershipModalProvider] 更新RevenueCat用户标识失败:', error);
|
||||
}
|
||||
} else if (isInitialized && !userProfile?.id) {
|
||||
dispatch(fetchMembershipData());
|
||||
}
|
||||
};
|
||||
|
||||
updateRevenueCatUser();
|
||||
}, [userProfile?.id, isInitialized]);
|
||||
}, [dispatch, userProfile?.id, isInitialized]);
|
||||
|
||||
const openMembershipModal = useCallback((options?: MembershipModalOptions) => {
|
||||
setPendingSuccessCallback(() => options?.onPurchaseSuccess);
|
||||
|
||||
211
services/membership.ts
Normal file
211
services/membership.ts
Normal 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 };
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import circumferenceReducer from './circumferenceSlice';
|
||||
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||
import foodLibraryReducer from './foodLibrarySlice';
|
||||
import foodRecognitionReducer from './foodRecognitionSlice';
|
||||
import membershipReducer from './membershipSlice';
|
||||
import goalsReducer from './goalsSlice';
|
||||
import healthReducer from './healthSlice';
|
||||
import fastingReducer, {
|
||||
@@ -108,6 +109,7 @@ export const store = configureStore({
|
||||
exerciseLibrary: exerciseLibraryReducer,
|
||||
foodLibrary: foodLibraryReducer,
|
||||
foodRecognition: foodRecognitionReducer,
|
||||
membership: membershipReducer,
|
||||
workout: workoutReducer,
|
||||
water: waterReducer,
|
||||
fasting: fastingReducer,
|
||||
|
||||
138
store/membershipSlice.ts
Normal file
138
store/membershipSlice.ts
Normal 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;
|
||||
@@ -78,6 +78,8 @@ export type UserProfile = {
|
||||
isVip?: boolean;
|
||||
freeUsageCount?: number;
|
||||
maxUsageCount?: number;
|
||||
membershipExpiration?: string | null;
|
||||
vipPlanName?: string;
|
||||
chestCircumference?: number; // 胸围
|
||||
waistCircumference?: number; // 腰围
|
||||
upperHipCircumference?: number; // 上臀围
|
||||
|
||||
Reference in New Issue
Block a user