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

@@ -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>