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 { 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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
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 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
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;
|
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; // 上臀围
|
||||||
|
|||||||
Reference in New Issue
Block a user