- 创建 MembershipModalContext 统一管理会员弹窗 - 优化 MembershipModal 产品套餐展示和购买流程 - 集成 RevenueCat SDK 并初始化内购功能 - 在个人中心添加会员 Banner,引导非会员用户订阅 - 修复日志工具的循环引用问题,确保错误信息正确记录 - 版本更新至 1.0.20 新增了完整的会员购买流程,包括套餐选择、购买确认、购买恢复等功能。会员 Banner 仅对非会员用户展示,已是会员的用户不会看到。同时优化了错误日志记录,避免循环引用导致的序列化失败。
1195 lines
37 KiB
TypeScript
1195 lines
37 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
||
import CustomCheckBox from '@/components/ui/CheckBox';
|
||
import { USER_AGREEMENT_URL } from '@/constants/Agree';
|
||
// import { useAuth } from '@/contexts/AuthContext';
|
||
// import { UserApi } from '@/services';
|
||
import { log, logger } from '@/utils/logger';
|
||
import {
|
||
captureMessage,
|
||
captureMessageWithContext,
|
||
capturePurchaseEvent,
|
||
captureUserAction
|
||
} from '@/utils/sentry.utils';
|
||
import { Toast as GlobalToast } from '@/utils/toast.utils';
|
||
import { MaterialIcons } from '@expo/vector-icons';
|
||
import { captureException } from '@sentry/react-native';
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
Dimensions,
|
||
Linking,
|
||
Modal,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TouchableOpacity,
|
||
View
|
||
} from 'react-native';
|
||
import Purchases, { CustomerInfo, PurchasesStoreProduct } from 'react-native-purchases';
|
||
|
||
const { height } = Dimensions.get('window');
|
||
|
||
interface MembershipModalProps {
|
||
visible: boolean;
|
||
onClose?: () => void;
|
||
onPurchaseSuccess?: () => void;
|
||
}
|
||
|
||
interface MembershipPlan {
|
||
id: string;
|
||
fallbackTitle: string;
|
||
subtitle: string;
|
||
type: 'weekly' | 'quarterly' | 'lifetime';
|
||
recommended?: boolean;
|
||
}
|
||
|
||
const DEFAULT_PLANS: MembershipPlan[] = [
|
||
{
|
||
id: 'com.anonymous.digitalpilates.membership.lifetime',
|
||
fallbackTitle: '终身会员',
|
||
subtitle: '一次投入,终身健康陪伴',
|
||
type: 'lifetime',
|
||
recommended: true,
|
||
},
|
||
{
|
||
id: 'com.anonymous.digitalpilates.membership.quarter',
|
||
fallbackTitle: '季度会员',
|
||
subtitle: '3个月蜕变计划,见证身材变化',
|
||
type: 'quarterly',
|
||
},
|
||
{
|
||
id: 'com.anonymous.digitalpilates.membership.weekly',
|
||
fallbackTitle: '周会员',
|
||
subtitle: '7天体验,开启健康第一步',
|
||
type: 'weekly',
|
||
},
|
||
];
|
||
|
||
export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) {
|
||
const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [restoring, setRestoring] = useState(false);
|
||
// const { user } = useAuth()
|
||
// const { refreshUserInfo } = useAuth();
|
||
const [products, setProducts] = useState<PurchasesStoreProduct[]>([]);
|
||
|
||
// 协议同意状态 - 只需要一个状态
|
||
const [agreementAccepted, setAgreementAccepted] = useState(false);
|
||
|
||
// 保存监听器引用,用于移除监听器
|
||
const purchaseListenerRef = useRef<((customerInfo: CustomerInfo) => void) | null>(null);
|
||
|
||
// 根据选中的产品生成tips内容
|
||
const getTipsContent = (product: PurchasesStoreProduct | null): string => {
|
||
if (!product) return '';
|
||
|
||
const plan = DEFAULT_PLANS.find(item => item.id === product.identifier);
|
||
if (!plan) {
|
||
return '';
|
||
}
|
||
|
||
switch (plan.type) {
|
||
case 'lifetime':
|
||
return '终身陪伴,见证您的每一次健康蜕变';
|
||
case 'quarterly':
|
||
return '3个月科学计划,让健康成为生活习惯';
|
||
case 'weekly':
|
||
return '7天体验期,感受专业健康指导的力量';
|
||
default:
|
||
return '';
|
||
}
|
||
};
|
||
|
||
// useEffect(() => {
|
||
// if (user) {
|
||
// captureMessage('用户已登录,开始初始化 Purchases');
|
||
// initPurchases();
|
||
// }
|
||
// }, [user]); // 初始化只需要执行一次
|
||
|
||
// 单独的 useEffect 来处理购买监听器,依赖 visible 状态
|
||
useEffect(() => {
|
||
if (visible) {
|
||
setupPurchaseListener();
|
||
setAgreementAccepted(false);
|
||
initPurchases();
|
||
} else {
|
||
removePurchaseListener();
|
||
setProducts([]);
|
||
setSelectedProduct(null);
|
||
setAgreementAccepted(false);
|
||
setLoading(false);
|
||
setRestoring(false);
|
||
}
|
||
|
||
// 组件卸载时确保移除监听器
|
||
return () => {
|
||
removePurchaseListener();
|
||
log.info('MembershipModal 购买监听器已清理');
|
||
};
|
||
}, [visible]); // 依赖 visible 状态
|
||
|
||
|
||
|
||
const initPurchases = async () => {
|
||
capturePurchaseEvent('init', '开始获取会员产品套餐');
|
||
|
||
try {
|
||
// 添加延迟,确保 RevenueCat SDK 完全初始化
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
|
||
const offerings = await Purchases.getOfferings();
|
||
log.info('获取产品套餐', { offerings });
|
||
|
||
logger.info('获取产品套餐成功', {
|
||
currentOffering: offerings.current?.identifier || null,
|
||
availablePackagesCount: offerings.current?.availablePackages.length || 0,
|
||
allOfferingsCount: Object.keys(offerings.all).length,
|
||
});
|
||
|
||
const packages = offerings.current?.availablePackages ?? [];
|
||
|
||
if (packages.length === 0) {
|
||
log.warn('没有找到可用的产品套餐', {
|
||
hasCurrentOffering: offerings.current !== null,
|
||
packagesLength: offerings.current?.availablePackages.length || 0,
|
||
});
|
||
logger.info('没有找到可用的产品套餐', {
|
||
hasCurrentOffering: offerings.current !== null,
|
||
packagesLength: offerings.current?.availablePackages.length || 0,
|
||
});
|
||
setProducts([]);
|
||
setSelectedProduct(null);
|
||
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);
|
||
|
||
// 获取产品后,检查用户的购买记录并自动选中对应套餐
|
||
await checkAndSelectActivePlan(productsToUse);
|
||
|
||
setSelectedProduct(current => current ?? (productsToUse[0] ?? null));
|
||
} catch (e: any) {
|
||
// 安全地处理错误对象,避免循环引用
|
||
const errorData = {
|
||
message: e?.message || '未知错误',
|
||
code: e?.code || null,
|
||
name: e?.name || 'Error',
|
||
// 只包含基本的错误信息,避免可能的循环引用
|
||
};
|
||
|
||
log.error('获取产品套餐失败', { error: errorData });
|
||
captureException(e);
|
||
capturePurchaseEvent('error', '获取产品套餐失败', errorData);
|
||
|
||
// 设置空状态,避免界面卡在加载状态
|
||
setProducts([]);
|
||
setSelectedProduct(null);
|
||
}
|
||
};
|
||
|
||
// 添加购买状态监听器
|
||
const setupPurchaseListener = () => {
|
||
log.info('设置购买监听器', { visible });
|
||
|
||
// 如果已经有监听器,先移除
|
||
if (purchaseListenerRef.current) {
|
||
removePurchaseListener();
|
||
}
|
||
|
||
// 创建监听器函数
|
||
const listener = (customerInfo: CustomerInfo) => {
|
||
log.info('购买状态变化监听器触发', { customerInfo, visible });
|
||
|
||
// 检查是否有有效的购买记录
|
||
const hasActiveEntitlements = Object.keys(customerInfo.entitlements.active).length > 0;
|
||
const hasNonSubscriptionTransactions = customerInfo.nonSubscriptionTransactions.length > 0;
|
||
const hasActiveSubscriptions = Object.keys(customerInfo.activeSubscriptions).length > 0;
|
||
|
||
if (hasActiveEntitlements || hasNonSubscriptionTransactions || hasActiveSubscriptions) {
|
||
capturePurchaseEvent('success', '监听到购买状态变化', {
|
||
hasActiveEntitlements,
|
||
hasNonSubscriptionTransactions,
|
||
hasActiveSubscriptions,
|
||
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
|
||
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
|
||
activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length,
|
||
modalVisible: visible
|
||
});
|
||
|
||
log.info('检测到购买成功,准备刷新用户信息并关闭弹窗');
|
||
|
||
// 延迟一点时间,确保购买流程完全完成
|
||
setTimeout(async () => {
|
||
// 刷新用户信息
|
||
// await refreshUserInfo();
|
||
|
||
// 调用购买成功回调
|
||
onPurchaseSuccess?.();
|
||
|
||
// 关闭弹窗
|
||
onClose?.();
|
||
|
||
// 显示成功提示
|
||
GlobalToast.show({
|
||
message: '会员开通成功',
|
||
});
|
||
}, 1000);
|
||
}
|
||
};
|
||
|
||
// 保存监听器引用
|
||
purchaseListenerRef.current = listener;
|
||
|
||
// 添加监听器
|
||
Purchases.addCustomerInfoUpdateListener(listener);
|
||
|
||
log.info('购买监听器已添加');
|
||
};
|
||
|
||
// 移除购买状态监听器
|
||
const removePurchaseListener = () => {
|
||
if (purchaseListenerRef.current) {
|
||
log.info('移除购买监听器');
|
||
Purchases.removeCustomerInfoUpdateListener(purchaseListenerRef.current);
|
||
purchaseListenerRef.current = null;
|
||
log.info('购买监听器已移除');
|
||
}
|
||
};
|
||
|
||
|
||
// 检查用户的购买记录并自动选中对应套餐
|
||
const checkAndSelectActivePlan = async (availableProducts: PurchasesStoreProduct[]) => {
|
||
try {
|
||
captureUserAction('开始检查用户购买记录');
|
||
|
||
// 获取用户的购买信息
|
||
const customerInfo = await Purchases.getCustomerInfo();
|
||
|
||
log.info('获取用户购买信息', { customerInfo });
|
||
|
||
// 记录详细的购买状态日志
|
||
captureMessageWithContext('获取用户购买信息成功', {
|
||
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
|
||
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
|
||
activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length,
|
||
originalAppUserId: customerInfo.originalAppUserId,
|
||
firstSeen: customerInfo.firstSeen,
|
||
originalPurchaseDate: customerInfo.originalPurchaseDate,
|
||
latestExpirationDate: customerInfo.latestExpirationDate
|
||
});
|
||
|
||
// 查找激活的产品ID
|
||
let activePurchasedProductIds: string[] = [];
|
||
|
||
// 检查权益
|
||
Object.keys(customerInfo.entitlements.active).forEach(key => {
|
||
const entitlement = customerInfo.entitlements.active[key];
|
||
activePurchasedProductIds.push(entitlement.productIdentifier);
|
||
log.debug(`激活的权益: ${key}, 产品ID: ${entitlement.productIdentifier}`);
|
||
});
|
||
|
||
// 检查非订阅购买(如终身会员)
|
||
customerInfo.nonSubscriptionTransactions.forEach(transaction => {
|
||
activePurchasedProductIds.push(transaction.productIdentifier);
|
||
log.debug(`非订阅购买: ${transaction.productIdentifier}, 购买时间: ${transaction.purchaseDate}`);
|
||
});
|
||
|
||
// 检查订阅
|
||
Object.keys(customerInfo.activeSubscriptions).forEach(productId => {
|
||
activePurchasedProductIds.push(productId);
|
||
log.debug(`激活的订阅: ${productId}`);
|
||
});
|
||
|
||
// 去重
|
||
activePurchasedProductIds = [...new Set(activePurchasedProductIds)];
|
||
|
||
captureMessageWithContext('用户激活的产品列表', {
|
||
activePurchasedProductIds,
|
||
activePurchasedProductCount: activePurchasedProductIds.length
|
||
});
|
||
|
||
if (activePurchasedProductIds.length > 0) {
|
||
// 尝试在可用产品中找到匹配的产品
|
||
let selectedProduct: PurchasesStoreProduct | null = null;
|
||
|
||
// 优先级:终身会员 > 季度会员 > 周会员
|
||
const priorityOrder = DEFAULT_PLANS.map(plan => plan.id);
|
||
|
||
// 按照优先级查找
|
||
for (const priorityProductId of priorityOrder) {
|
||
if (activePurchasedProductIds.includes(priorityProductId)) {
|
||
selectedProduct = availableProducts.find(product => product.identifier === priorityProductId) || null;
|
||
if (selectedProduct) {
|
||
log.info(`找到优先级最高的激活产品: ${priorityProductId}`);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果按优先级没找到,尝试找到任何匹配的产品
|
||
if (!selectedProduct) {
|
||
for (const productId of activePurchasedProductIds) {
|
||
selectedProduct = availableProducts.find(product => product.identifier === productId) || null;
|
||
if (selectedProduct) {
|
||
log.info(`找到匹配的激活产品: ${productId}`);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (selectedProduct) {
|
||
setSelectedProduct(selectedProduct);
|
||
|
||
captureMessageWithContext('自动选中用户已购买的套餐', {
|
||
selectedProductId: selectedProduct.identifier,
|
||
selectedProductTitle: selectedProduct.title,
|
||
selectedProductPrice: selectedProduct.price,
|
||
allActivePurchasedProductIds: activePurchasedProductIds
|
||
});
|
||
|
||
} else {
|
||
captureMessageWithContext('未找到匹配的可用产品', {
|
||
activePurchasedProductIds,
|
||
availableProductIds: availableProducts.map(p => p.identifier)
|
||
});
|
||
log.warn('用户有激活的购买记录,但没有找到匹配的可用产品', {
|
||
activePurchasedProductIds,
|
||
availableProductIds: availableProducts.map(p => p.identifier)
|
||
});
|
||
}
|
||
} else {
|
||
captureMessageWithContext('用户没有激活的购买记录', {
|
||
hasEntitlements: Object.keys(customerInfo.entitlements.active).length > 0,
|
||
hasNonSubscriptions: customerInfo.nonSubscriptionTransactions.length > 0,
|
||
hasActiveSubscriptions: Object.keys(customerInfo.activeSubscriptions).length > 0
|
||
});
|
||
log.info('用户没有激活的购买记录,使用默认选择逻辑');
|
||
setSelectedProduct(availableProducts[0] ?? null);
|
||
}
|
||
|
||
} catch (error: any) {
|
||
// 安全地处理错误对象,避免循环引用
|
||
const errorData = {
|
||
message: error?.message || '未知错误',
|
||
code: error?.code || null,
|
||
name: error?.name || 'Error',
|
||
};
|
||
|
||
log.error('检查用户购买记录失败', { error: errorData });
|
||
captureException(error);
|
||
captureMessageWithContext('检查用户购买记录失败', errorData);
|
||
}
|
||
};
|
||
|
||
|
||
const handlePurchase = async () => {
|
||
// 验证是否已同意协议
|
||
if (!agreementAccepted) {
|
||
Alert.alert(
|
||
'请阅读并同意相关协议',
|
||
'购买前需要同意用户协议、会员协议和自动续费协议',
|
||
[
|
||
{
|
||
text: '确定',
|
||
style: 'default',
|
||
}
|
||
]
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 验证是否选择了产品
|
||
if (!selectedProduct) {
|
||
Alert.alert(
|
||
'请选择会员套餐',
|
||
'',
|
||
[
|
||
{
|
||
text: '确定',
|
||
style: 'default',
|
||
}
|
||
]
|
||
);
|
||
return;
|
||
}
|
||
|
||
|
||
|
||
// 防止重复点击
|
||
if (loading) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 设置加载状态
|
||
setLoading(true);
|
||
|
||
// 记录购买开始事件
|
||
capturePurchaseEvent('init', `开始购买: ${selectedProduct.identifier}`, {
|
||
productIdentifier: selectedProduct.identifier,
|
||
productTitle: selectedProduct.title,
|
||
productPrice: selectedProduct.price
|
||
});
|
||
|
||
// 执行购买
|
||
const { customerInfo, productIdentifier } = await Purchases.purchaseStoreProduct(selectedProduct);
|
||
|
||
log.info('购买成功', { customerInfo, productIdentifier });
|
||
|
||
// 记录购买成功事件
|
||
capturePurchaseEvent('success', `购买成功: ${productIdentifier}`, {
|
||
productIdentifier,
|
||
hasActiveEntitlements: Object.keys(customerInfo.entitlements.active).length > 0,
|
||
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
|
||
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
|
||
activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length
|
||
});
|
||
|
||
// 购买成功后,监听器会自动处理后续逻辑(刷新用户信息、关闭弹窗等)
|
||
log.info('购买流程完成,等待监听器处理后续逻辑');
|
||
|
||
} catch (error: any) {
|
||
captureException(error);
|
||
|
||
// 记录购买失败事件
|
||
capturePurchaseEvent('error', `购买失败: ${error.message || '未知错误'}`, {
|
||
errorCode: error.code || null,
|
||
errorMessage: error.message || '未知错误',
|
||
productIdentifier: selectedProduct.identifier
|
||
});
|
||
|
||
// 处理不同类型的购买错误
|
||
if (error.code === 1 || error.code === 'USER_CANCELLED') {
|
||
// 用户取消购买
|
||
GlobalToast.show({
|
||
message: '购买已取消',
|
||
});
|
||
} else if (error.code === 'ITEM_ALREADY_OWNED' || error.code === 'PRODUCT_ALREADY_PURCHASED') {
|
||
// 商品已拥有
|
||
GlobalToast.show({
|
||
message: '您已拥有此商品',
|
||
});
|
||
} else if (error.code === 'NETWORK_ERROR') {
|
||
// 网络错误
|
||
GlobalToast.show({
|
||
message: '网络连接失败',
|
||
});
|
||
} else if (error.code === 'PAYMENT_PENDING') {
|
||
// 支付待处理
|
||
GlobalToast.show({
|
||
message: '支付正在处理中',
|
||
});
|
||
} else if (error.code === 'INVALID_CREDENTIALS') {
|
||
// 凭据无效
|
||
GlobalToast.show({
|
||
message: '账户验证失败',
|
||
});
|
||
} else {
|
||
// 其他错误
|
||
GlobalToast.show({
|
||
message: '购买失败',
|
||
});
|
||
}
|
||
} finally {
|
||
// 确保在所有情况下都重置加载状态
|
||
setLoading(false);
|
||
log.info('购买流程结束,加载状态已重置');
|
||
}
|
||
};
|
||
|
||
const handleRestore = async () => {
|
||
// 防止重复点击
|
||
if (restoring || loading) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setRestoring(true);
|
||
|
||
captureUserAction('开始恢复购买');
|
||
|
||
// 恢复购买
|
||
const customerInfo = await Purchases.restorePurchases();
|
||
|
||
log.info('恢复购买结果', { customerInfo });
|
||
captureMessageWithContext('恢复购买结果', {
|
||
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
|
||
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
|
||
activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length,
|
||
managementUrl: customerInfo.managementURL,
|
||
originalAppUserId: customerInfo.originalAppUserId
|
||
});
|
||
|
||
// 检查是否有有效的购买记录
|
||
const hasActiveEntitlements = Object.keys(customerInfo.entitlements.active).length > 0;
|
||
const hasNonSubscriptionTransactions = customerInfo.nonSubscriptionTransactions.length > 0;
|
||
const hasActiveSubscriptions = Object.keys(customerInfo.activeSubscriptions).length > 0;
|
||
|
||
if (hasActiveEntitlements || hasNonSubscriptionTransactions || hasActiveSubscriptions) {
|
||
// 检查具体的购买内容
|
||
let restoredProducts: string[] = [];
|
||
|
||
// 检查权益
|
||
Object.keys(customerInfo.entitlements.active).forEach(key => {
|
||
const entitlement = customerInfo.entitlements.active[key];
|
||
restoredProducts.push(entitlement.productIdentifier);
|
||
});
|
||
|
||
// 检查非订阅购买(如终身会员)
|
||
customerInfo.nonSubscriptionTransactions.forEach(transaction => {
|
||
restoredProducts.push(transaction.productIdentifier);
|
||
});
|
||
|
||
// 检查订阅
|
||
Object.keys(customerInfo.activeSubscriptions).forEach(productId => {
|
||
restoredProducts.push(productId);
|
||
});
|
||
|
||
log.info('恢复的产品', { restoredProducts });
|
||
capturePurchaseEvent('restore', '恢复购买成功', {
|
||
restoredProducts,
|
||
restoredProductsCount: restoredProducts.length
|
||
});
|
||
|
||
try {
|
||
// 调用后台服务接口进行票据匹配
|
||
captureUserAction('开始调用后台恢复购买接口');
|
||
|
||
// const restoreResponse = await UserApi.restorePurchase({
|
||
// customerInfo: {
|
||
// originalAppUserId: customerInfo.originalAppUserId,
|
||
// activeEntitlements: customerInfo.entitlements.active,
|
||
// nonSubscriptionTransactions: customerInfo.nonSubscriptionTransactions,
|
||
// activeSubscriptions: customerInfo.activeSubscriptions,
|
||
// restoredProducts
|
||
// }
|
||
// });
|
||
|
||
// log.debug('后台恢复购买响应', { restoreResponse });
|
||
|
||
// captureMessageWithContext('后台恢复购买成功', {
|
||
// responseData: restoreResponse,
|
||
// restoredProductsCount: restoredProducts.length
|
||
// });
|
||
|
||
// 刷新用户信息
|
||
// await refreshUserInfo();
|
||
|
||
// 调用购买成功回调
|
||
onPurchaseSuccess?.();
|
||
|
||
// 关闭弹窗
|
||
onClose?.();
|
||
|
||
GlobalToast.show({
|
||
message: '恢复购买成功',
|
||
});
|
||
|
||
} catch (apiError: any) {
|
||
// 安全地处理错误对象,避免循环引用
|
||
const errorData = {
|
||
message: apiError?.message || '未知错误',
|
||
code: apiError?.code || null,
|
||
name: apiError?.name || 'Error',
|
||
restoredProductsCount: restoredProducts.length
|
||
};
|
||
|
||
log.error('后台恢复购买接口调用失败', { error: errorData });
|
||
captureException(apiError);
|
||
captureMessageWithContext('后台恢复购买接口失败', errorData);
|
||
|
||
// 即使后台接口失败,也显示恢复成功(因为 RevenueCat 已经确认有购买记录)
|
||
// 但不关闭弹窗,让用户知道可能需要重试
|
||
GlobalToast.show({
|
||
message: '恢复购买部分失败',
|
||
});
|
||
}
|
||
|
||
} else {
|
||
capturePurchaseEvent('restore', '没有找到购买记录', {
|
||
hasActiveEntitlements,
|
||
hasNonSubscriptionTransactions,
|
||
hasActiveSubscriptions,
|
||
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
|
||
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
|
||
activeSubscriptionsCount: Object.keys(customerInfo.activeSubscriptions).length
|
||
});
|
||
GlobalToast.show({
|
||
message: '没有找到购买记录',
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
// 安全地处理错误对象,避免循环引用
|
||
const errorData = {
|
||
message: error?.message || '未知错误',
|
||
code: error?.code || null,
|
||
name: error?.name || 'Error',
|
||
};
|
||
|
||
log.error('恢复购买失败', { error: errorData });
|
||
captureException(error);
|
||
|
||
// 记录恢复购买失败事件
|
||
capturePurchaseEvent('error', `恢复购买失败: ${errorData.message}`, errorData);
|
||
|
||
// 处理特定的恢复购买错误
|
||
if (error.code === 'RESTORE_CANCELLED' || error.code === 'USER_CANCELLED') {
|
||
GlobalToast.show({
|
||
message: '恢复购买已取消',
|
||
});
|
||
} else if (error.code === 'NETWORK_ERROR') {
|
||
GlobalToast.show({
|
||
message: '网络错误',
|
||
});
|
||
} else if (error.code === 'INVALID_CREDENTIALS') {
|
||
GlobalToast.show({
|
||
message: '账户验证失败',
|
||
});
|
||
} else {
|
||
GlobalToast.show({
|
||
message: '恢复购买失败',
|
||
});
|
||
}
|
||
} finally {
|
||
// 确保在所有情况下都重置恢复状态
|
||
setRestoring(false);
|
||
log.info('恢复购买流程结束,恢复状态已重置');
|
||
}
|
||
};
|
||
|
||
const renderMembershipBenefits = () => (
|
||
<View style={styles.benefitsContainer}>
|
||
<View style={styles.benefitsTitleContainer}>
|
||
<Text style={styles.benefitsTitle}>解锁全部健康功能</Text>
|
||
<Text style={styles.benefitsSubtitle}>开启您的健康蜕变之旅</Text>
|
||
</View>
|
||
|
||
<View style={styles.benefitItem}>
|
||
<MaterialIcons name="check-circle" size={20} color="#DF42D0" />
|
||
<Text style={styles.benefitText}>高级营养分析,精准卡路里计算</Text>
|
||
</View>
|
||
|
||
<View style={styles.benefitItem}>
|
||
<MaterialIcons name="check-circle" size={20} color="#DF42D0" />
|
||
<Text style={styles.benefitText}>定制化减脂计划,科学体重管理</Text>
|
||
</View>
|
||
|
||
<View style={styles.benefitItem}>
|
||
<MaterialIcons name="check-circle" size={20} color="#DF42D0" />
|
||
<Text style={styles.benefitText}>深度健康数据分析,洞察身体变化</Text>
|
||
</View>
|
||
|
||
|
||
{/* 皇冠图标 */}
|
||
<View style={styles.crownContainer}>
|
||
{/* <Image
|
||
source={require('@/assets/images/img_profile_vip_bg.png')}
|
||
style={styles.crownIcon}
|
||
/> */}
|
||
</View>
|
||
</View>
|
||
);
|
||
|
||
const renderPlanCard = (product: PurchasesStoreProduct) => {
|
||
const plan = DEFAULT_PLANS.find(p => p.id === product.identifier);
|
||
|
||
if (!plan) {
|
||
return null;
|
||
}
|
||
|
||
const isSelected = selectedProduct === product;
|
||
const displayTitle = product.title || plan.fallbackTitle;
|
||
const priceLabel = product.priceString || '';
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
key={product.identifier}
|
||
style={[
|
||
styles.planCard,
|
||
isSelected && styles.selectedPlan,
|
||
loading && styles.disabledPlanCard,
|
||
]}
|
||
onPress={() => !loading && product && setSelectedProduct(product)}
|
||
disabled={loading}
|
||
activeOpacity={loading ? 1 : 0.8}
|
||
accessible={true}
|
||
accessibilityLabel={`${displayTitle} ${priceLabel}`}
|
||
accessibilityHint={loading ? '购买进行中,无法切换套餐' : `选择${displayTitle}套餐`}
|
||
accessibilityState={{ disabled: loading, selected: isSelected }}
|
||
>
|
||
{plan.recommended && (
|
||
<View style={styles.recommendedBadge}>
|
||
<Text style={styles.recommendedText}>推荐</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.planHeader}>
|
||
<Text style={[
|
||
styles.planTitle,
|
||
plan.type === 'lifetime' && styles.lifetimePlanTitle,
|
||
plan.type === 'quarterly' && styles.quarterlyPlanTitle,
|
||
plan.type === 'weekly' && styles.weeklyPlanTitle,
|
||
]}>
|
||
{displayTitle}
|
||
</Text>
|
||
</View>
|
||
|
||
<View style={styles.planPricing}>
|
||
<Text style={[
|
||
styles.planPrice,
|
||
plan.type === 'lifetime' && styles.lifetimePlanPrice,
|
||
plan.type === 'quarterly' && styles.quarterlyPlanPrice,
|
||
plan.type === 'weekly' && styles.weeklyPlanPrice,
|
||
]}>
|
||
{priceLabel || '--'}
|
||
</Text>
|
||
</View>
|
||
|
||
<Text style={styles.planSubtitle}>{plan.subtitle}</Text>
|
||
</TouchableOpacity>
|
||
);
|
||
};
|
||
|
||
|
||
return (
|
||
<Modal
|
||
visible={visible}
|
||
transparent={true}
|
||
animationType="slide"
|
||
presentationStyle="overFullScreen"
|
||
>
|
||
<View style={styles.overlay}>
|
||
{/* 半透明背景 */}
|
||
<TouchableOpacity
|
||
style={styles.backdrop}
|
||
activeOpacity={1}
|
||
onPress={onClose} // 阻止点击背景关闭
|
||
/>
|
||
|
||
{/* 会员内容 */}
|
||
<View style={styles.modalContainer}>
|
||
<ScrollView style={styles.modalContent} showsVerticalScrollIndicator={false}>
|
||
{/* 关闭按钮 */}
|
||
{/* <TouchableOpacity
|
||
style={styles.closeButton}
|
||
onPress={onClose}
|
||
>
|
||
<Text style={styles.closeButtonText}>×</Text>
|
||
</TouchableOpacity> */}
|
||
|
||
|
||
{/* 会员权益介绍 */}
|
||
{renderMembershipBenefits()}
|
||
|
||
{/* 会员套餐选择 */}
|
||
{products.length === 0 && (
|
||
<View style={styles.configurationNotice}>
|
||
<Text style={styles.configurationText}>
|
||
暂未获取到会员商品,请在 RevenueCat 中配置 iOS 产品并同步到当前 Offering。
|
||
</Text>
|
||
</View>
|
||
)}
|
||
<View style={styles.plansContainer}>
|
||
{products.map(renderPlanCard)}
|
||
</View>
|
||
|
||
{/* 产品选择提示区域 */}
|
||
{selectedProduct && (
|
||
<View style={styles.tipsContainer}>
|
||
<Text style={styles.tipsText}>
|
||
{getTipsContent(selectedProduct)}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* 协议同意区域 */}
|
||
<View style={styles.agreementRow}>
|
||
<CustomCheckBox
|
||
checked={agreementAccepted}
|
||
onCheckedChange={setAgreementAccepted}
|
||
size={16}
|
||
checkedColor="#E91E63"
|
||
uncheckedColor="#999"
|
||
/>
|
||
<Text style={styles.agreementPrefix}>我已阅读并同意</Text>
|
||
<TouchableOpacity onPress={() => {
|
||
Linking.openURL(USER_AGREEMENT_URL);
|
||
captureMessage('click user agreement');
|
||
}}>
|
||
<Text style={styles.agreementLink}>《用户协议》</Text>
|
||
</TouchableOpacity>
|
||
<Text style={styles.agreementSeparator}> | </Text>
|
||
<TouchableOpacity onPress={() => {
|
||
// Linking.openURL(MEMBERSHIP_AGREEMENT_URL);
|
||
captureMessage('click membership agreement');
|
||
}}>
|
||
<Text style={styles.agreementLink}>《会员协议》</Text>
|
||
</TouchableOpacity>
|
||
<Text style={styles.agreementSeparator}> | </Text>
|
||
<TouchableOpacity onPress={() => {
|
||
// Linking.openURL(AUTO_RENEWAL_AGREEMENT_URL);
|
||
captureMessage('click auto renewal agreement');
|
||
}}>
|
||
<Text style={styles.agreementLink}>《自动续费协议》</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
|
||
{/* 购买按钮 */}
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.purchaseButton,
|
||
(loading || !selectedProduct) && styles.disabledButton
|
||
]}
|
||
onPress={handlePurchase}
|
||
disabled={loading || !selectedProduct}
|
||
accessible={true}
|
||
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
|
||
accessibilityHint={
|
||
loading
|
||
? '购买正在进行中,请稍候'
|
||
: selectedProduct
|
||
? `点击购买${selectedProduct.title || '已选'}会员套餐`
|
||
: '请选择会员套餐后再进行购买'
|
||
}
|
||
accessibilityState={{ disabled: loading || !selectedProduct }}
|
||
>
|
||
{loading ? (
|
||
<View style={styles.loadingContainer}>
|
||
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
|
||
<Text style={styles.purchaseButtonText}>正在处理购买...</Text>
|
||
</View>
|
||
) : (
|
||
<Text style={styles.purchaseButtonText}>开启健康蜕变</Text>
|
||
)}
|
||
</TouchableOpacity>
|
||
|
||
{/* 恢复购买按钮 */}
|
||
<TouchableOpacity
|
||
style={[styles.restoreButton, (restoring || loading) && styles.disabledRestoreButton]}
|
||
onPress={handleRestore}
|
||
disabled={restoring || loading}
|
||
>
|
||
{restoring ? (
|
||
<View style={styles.restoreButtonContent}>
|
||
<ActivityIndicator size="small" color="#666" style={styles.restoreButtonLoader} />
|
||
<Text style={styles.restoreButtonText}>恢复中...</Text>
|
||
</View>
|
||
) : (
|
||
<Text style={styles.restoreButtonText}>恢复购买</Text>
|
||
)}
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
overlay: {
|
||
flex: 1,
|
||
justifyContent: 'flex-end',
|
||
},
|
||
backdrop: {
|
||
...StyleSheet.absoluteFillObject,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||
},
|
||
modalContainer: {
|
||
height: height * 0.6,
|
||
backgroundColor: 'white',
|
||
borderTopLeftRadius: 20,
|
||
borderTopRightRadius: 20,
|
||
overflow: 'hidden',
|
||
},
|
||
modalContent: {
|
||
flex: 1,
|
||
paddingHorizontal: 20,
|
||
paddingTop: 20,
|
||
},
|
||
closeButton: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
right: 0,
|
||
width: 30,
|
||
height: 30,
|
||
borderRadius: 15,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
zIndex: 1,
|
||
},
|
||
closeButtonText: {
|
||
fontSize: 18,
|
||
color: '#666',
|
||
fontWeight: '300',
|
||
},
|
||
configurationNotice: {
|
||
backgroundColor: '#FFF9E6',
|
||
borderRadius: 12,
|
||
padding: 16,
|
||
marginTop: 30,
|
||
marginBottom: 20,
|
||
alignItems: 'center',
|
||
borderWidth: 1,
|
||
borderColor: '#FFE4B5',
|
||
},
|
||
configurationText: {
|
||
fontSize: 14,
|
||
color: '#B8860B',
|
||
textAlign: 'center',
|
||
marginTop: 8,
|
||
marginBottom: 12,
|
||
},
|
||
configurationButton: {
|
||
backgroundColor: '#FF9500',
|
||
borderRadius: 8,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 8,
|
||
},
|
||
configurationButtonText: {
|
||
color: 'white',
|
||
fontSize: 12,
|
||
fontWeight: '600',
|
||
},
|
||
benefitsContainer: {
|
||
position: 'relative',
|
||
backgroundColor: '#FFE7F9',
|
||
borderRadius: 16,
|
||
marginBottom: 30,
|
||
padding: 20,
|
||
},
|
||
benefitsTitleContainer: {
|
||
alignItems: 'center',
|
||
marginBottom: 20,
|
||
},
|
||
benefitsTitle: {
|
||
fontSize: 18,
|
||
fontWeight: 'bold',
|
||
color: '#333',
|
||
marginBottom: 5,
|
||
},
|
||
benefitsSubtitle: {
|
||
fontSize: 14,
|
||
color: '#666',
|
||
textAlign: 'center',
|
||
},
|
||
benefitsTitleBg: {
|
||
width: 180,
|
||
height: 20,
|
||
marginBottom: 10,
|
||
},
|
||
benefitItem: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginTop: 10,
|
||
},
|
||
benefitText: {
|
||
fontSize: 14,
|
||
color: '#333',
|
||
marginLeft: 8,
|
||
fontWeight: '500',
|
||
},
|
||
crownContainer: {
|
||
position: 'absolute',
|
||
top: 10,
|
||
right: 10,
|
||
width: 100,
|
||
height: 100,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
},
|
||
crownIcon: {
|
||
width: 80,
|
||
height: 80,
|
||
resizeMode: 'contain',
|
||
},
|
||
plansContainer: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 20,
|
||
},
|
||
planCard: {
|
||
flex: 1,
|
||
marginHorizontal: 4,
|
||
borderRadius: 12,
|
||
borderWidth: 2,
|
||
borderColor: '#E0E0E0',
|
||
paddingVertical: 20,
|
||
paddingHorizontal: 12,
|
||
alignItems: 'center',
|
||
position: 'relative',
|
||
backgroundColor: 'white',
|
||
},
|
||
selectedPlan: {
|
||
borderColor: '#DF42D0',
|
||
backgroundColor: '#FFF5FE',
|
||
},
|
||
recommendedBadge: {
|
||
position: 'absolute',
|
||
top: -10,
|
||
backgroundColor: '#7B2CBF',
|
||
borderRadius: 10,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 4,
|
||
},
|
||
recommendedText: {
|
||
color: 'white',
|
||
fontSize: 10,
|
||
fontWeight: 'bold',
|
||
},
|
||
planHeader: {
|
||
alignItems: 'center',
|
||
marginBottom: 8,
|
||
},
|
||
planTitle: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: '#333',
|
||
},
|
||
lifetimePlanTitle: {
|
||
color: '#7B2CBF',
|
||
},
|
||
quarterlyPlanTitle: {
|
||
color: '#DF42D0',
|
||
},
|
||
weeklyPlanTitle: {
|
||
color: '#FF9500',
|
||
},
|
||
planPricing: {
|
||
alignItems: 'center',
|
||
marginBottom: 8,
|
||
},
|
||
planPrice: {
|
||
fontSize: 24,
|
||
fontWeight: 'bold',
|
||
color: '#333',
|
||
},
|
||
lifetimePlanPrice: {
|
||
color: '#7B2CBF',
|
||
},
|
||
quarterlyPlanPrice: {
|
||
color: '#DF42D0',
|
||
},
|
||
weeklyPlanPrice: {
|
||
color: '#FF9500',
|
||
},
|
||
planSubtitle: {
|
||
fontSize: 12,
|
||
color: '#666',
|
||
textAlign: 'center',
|
||
lineHeight: 16,
|
||
},
|
||
purchaseButton: {
|
||
backgroundColor: '#DF42D0',
|
||
borderRadius: 25,
|
||
height: 50,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
marginBottom: 15,
|
||
marginTop: 10,
|
||
shadowColor: '#DF42D0',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 4,
|
||
},
|
||
shadowOpacity: 0.3,
|
||
shadowRadius: 8,
|
||
elevation: 6,
|
||
},
|
||
disabledButton: {
|
||
backgroundColor: '#ccc',
|
||
shadowOpacity: 0,
|
||
elevation: 0,
|
||
},
|
||
purchaseButtonText: {
|
||
color: 'white',
|
||
fontSize: 18,
|
||
fontWeight: '600',
|
||
},
|
||
restoreButton: {
|
||
backgroundColor: 'transparent',
|
||
borderRadius: 25,
|
||
height: 50,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
marginBottom: 20,
|
||
},
|
||
restoreButtonText: {
|
||
color: '#666',
|
||
fontSize: 16,
|
||
fontWeight: '500',
|
||
},
|
||
disabledRestoreButton: {
|
||
opacity: 0.5,
|
||
},
|
||
restoreButtonContent: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
restoreButtonLoader: {
|
||
marginRight: 8,
|
||
},
|
||
loadingContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
loadingSpinner: {
|
||
marginRight: 8,
|
||
},
|
||
disabledPlanCard: {
|
||
opacity: 0.5,
|
||
},
|
||
agreementRow: {
|
||
width: '100%',
|
||
marginTop: 6,
|
||
borderRadius: 12,
|
||
display: 'flex',
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
agreementPrefix: {
|
||
fontSize: 10,
|
||
color: '#333',
|
||
marginRight: 4,
|
||
},
|
||
agreementLink: {
|
||
fontSize: 10,
|
||
color: '#E91E63',
|
||
textDecorationLine: 'underline',
|
||
fontWeight: '500',
|
||
},
|
||
agreementSeparator: {
|
||
fontSize: 10,
|
||
color: '#666',
|
||
marginHorizontal: 2,
|
||
},
|
||
tipsContainer: {
|
||
borderRadius: 8,
|
||
},
|
||
tipsText: {
|
||
fontSize: 12,
|
||
color: '#666',
|
||
lineHeight: 18,
|
||
textAlign: 'center',
|
||
},
|
||
});
|