Files
digital-pilates/components/model/MembershipModal.tsx
richarjiang fcf1be211f feat(vip): 实现VIP服务权限控制和食物识别功能限制
- 添加VIP服务权限检查hook,支持免费使用次数限制
- 为食物识别功能添加登录验证和VIP权限检查
- 优化RevenueCat用户标识同步逻辑
- 修复会员购买状态检查的类型安全问题
- 为营养成分分析添加登录验证
2025-10-29 09:44:30 +08:00

1523 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable react-hooks/exhaustive-deps */
import CustomCheckBox from '@/components/ui/CheckBox';
import { USER_AGREEMENT_URL } from '@/constants/Agree';
import { log, logger } from '@/utils/logger';
import {
captureMessage,
captureMessageWithContext,
capturePurchaseEvent,
captureUserAction
} from '@/utils/sentry.utils';
import { Toast as GlobalToast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { captureException } from '@sentry/react-native';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
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;
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';
// 权限配置接口
interface PermissionConfig {
type: PermissionType;
text: string;
vipText?: string; // VIP用户的特殊文案可选
}
// 权益对比项接口
interface BenefitItem {
title: string;
description?: string; // 功能描述,可选
vip: PermissionConfig;
regular: PermissionConfig;
}
// 权益对比配置
const BENEFIT_COMPARISON: BenefitItem[] = [
{
title: 'AI拍照记录热量',
description: '通过拍照识别食物并自动记录热量',
vip: {
type: 'unlimited',
text: '无限次使用',
vipText: '无限次使用'
},
regular: {
type: 'limited',
text: '有限次使用',
vipText: '每日3次'
}
},
{
title: 'AI拍照识别包装',
description: '识别食品包装上的营养成分信息',
vip: {
type: 'unlimited',
text: '无限次使用',
vipText: '无限次使用'
},
regular: {
type: 'limited',
text: '有限次使用',
vipText: '每日5次'
}
},
{
title: '每日健康提醒',
description: '根据个人目标提供个性化健康提醒',
vip: {
type: 'unlimited',
text: '完全支持',
vipText: '智能提醒'
},
regular: {
type: 'unlimited',
text: '基础提醒',
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 }> = {
lifetime: {
gradient: ['#FFF1DD', '#FFE8FA'] as const,
accent: '#7B2CBF',
},
quarterly: {
gradient: ['#E5EAFE', '#F4E8FF'] as const,
accent: '#5B5BE6',
},
weekly: {
gradient: ['#FFF6E5', '#FFE7CC'] as const,
accent: '#FF9500',
},
};
// 根据权限类型获取对应的图标
const getPermissionIcon = (type: PermissionType, isVip: boolean) => {
switch (type) {
case 'exclusive':
return isVip ? (
<Ionicons name="checkmark-circle" size={20} color="#FFB200" />
) : (
<Ionicons name="remove" size={20} color="#D1D4DA" />
);
case 'limited':
return isVip ? (
<Ionicons name="checkmark-circle" size={20} color="#FFB200" />
) : (
<Ionicons name="time-outline" size={20} color="#8E8E93" />
);
case 'unlimited':
return (
<Ionicons name="checkmark-circle" size={20} color={isVip ? "#FFB200" : "#8E8E93"} />
);
default:
return <Ionicons name="remove" size={20} color="#D1D4DA" />;
}
};
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 {
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 => {
// 如果已经有选中的产品,保持不变
if (current) return current;
// 否则选择第一个可用产品
return 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 activeSubscriptionIds = getActiveSubscriptionIds(customerInfo);
// 检查是否有有效的购买记录
const hasActiveEntitlements = Object.keys(customerInfo.entitlements.active).length > 0;
const hasNonSubscriptionTransactions = customerInfo.nonSubscriptionTransactions.length > 0;
const hasActiveSubscriptions = activeSubscriptionIds.length > 0;
if (hasActiveEntitlements || hasNonSubscriptionTransactions || hasActiveSubscriptions) {
capturePurchaseEvent('success', '监听到购买状态变化', {
hasActiveEntitlements,
hasNonSubscriptionTransactions,
hasActiveSubscriptions,
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
activeSubscriptionsCount: activeSubscriptionIds.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 });
// 记录详细的购买状态日志
const activeSubscriptionIds = getActiveSubscriptionIds(customerInfo);
captureMessageWithContext('获取用户购买信息成功', {
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
activeSubscriptionsCount: activeSubscriptionIds.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}`);
});
// 检查订阅
activeSubscriptionIds.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: activeSubscriptionIds.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 () => {
// 添加调试日志
log.info('handlePurchase 被调用', {
loading,
productsLength: products.length,
selectedProductId: selectedProduct?.identifier,
selectedProductTitle: selectedProduct?.title,
agreementAccepted
});
// 验证是否已同意协议
if (!agreementAccepted) {
Alert.alert(
'请阅读并同意相关协议',
'购买前需要同意用户协议、会员协议和自动续费协议',
[
{
text: '确定',
style: 'default',
}
]
);
return;
}
// 如果没有选中的产品但有可用产品,自动选择第一个
if (!selectedProduct && products.length > 0) {
log.info('自动选择第一个可用产品', {
firstProductId: products[0]?.identifier,
firstProductTitle: products[0]?.title
});
setSelectedProduct(products[0]);
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 });
const activeSubscriptionIds = getActiveSubscriptionIds(customerInfo);
// 记录购买成功事件
capturePurchaseEvent('success', `购买成功: ${productIdentifier}`, {
productIdentifier,
hasActiveEntitlements: Object.keys(customerInfo.entitlements.active).length > 0,
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
activeSubscriptionsCount: activeSubscriptionIds.length
});
// 购买成功后,监听器会自动处理后续逻辑(刷新用户信息、关闭弹窗等)
log.info('购买流程完成,等待监听器处理后续逻辑');
} catch (error: any) {
captureException(error);
// 记录购买失败事件
capturePurchaseEvent('error', `购买失败: ${error.message || '未知错误'}`, {
errorCode: error.code || null,
errorMessage: error.message || '未知错误',
productIdentifier: selectedProduct.identifier
});
// 处理不同类型的购买错误
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
// 用户取消购买
GlobalToast.show({
message: '购买已取消',
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
// 商品已拥有
GlobalToast.show({
message: '您已拥有此商品',
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
// 网络错误
GlobalToast.show({
message: '网络连接失败',
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
// 支付待处理
GlobalToast.show({
message: '支付正在处理中',
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
// 凭据无效
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 });
const activeSubscriptionIds = getActiveSubscriptionIds(customerInfo);
captureMessageWithContext('恢复购买结果', {
activeEntitlementsCount: Object.keys(customerInfo.entitlements.active).length,
nonSubscriptionTransactionsCount: customerInfo.nonSubscriptionTransactions.length,
activeSubscriptionsCount: activeSubscriptionIds.length,
managementUrl: customerInfo.managementURL,
originalAppUserId: customerInfo.originalAppUserId
});
// 检查是否有有效的购买记录
const hasActiveEntitlements = Object.keys(customerInfo.entitlements.active).length > 0;
const hasNonSubscriptionTransactions = customerInfo.nonSubscriptionTransactions.length > 0;
const hasActiveSubscriptions = activeSubscriptionIds.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);
});
// 检查订阅
activeSubscriptionIds.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: activeSubscriptionIds.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.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
GlobalToast.show({
message: '恢复购买已取消',
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
GlobalToast.show({
message: '网络错误',
});
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
GlobalToast.show({
message: '账户验证失败',
});
} else {
GlobalToast.show({
message: '恢复购买失败',
});
}
} finally {
// 确保在所有情况下都重置恢复状态
setRestoring(false);
log.info('恢复购买流程结束,恢复状态已重置');
}
};
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 || '';
const styleConfig = PLAN_STYLE_CONFIG[plan.type];
return (
<TouchableOpacity
key={product.identifier}
style={[
styles.planCardWrapper,
isSelected && styles.planCardWrapperSelected,
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 }}
>
<LinearGradient
colors={styleConfig?.gradient ?? ['#FFFFFF', '#FFFFFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.planCardGradient}
>
<View style={styles.planCardTopSection}>
{plan.tag && (
<View style={styles.planTag}>
<Text style={styles.planTagText}>{plan.tag}</Text>
</View>
)}
<Text style={styles.planCardTitle}>{displayTitle}</Text>
</View>
<View style={styles.planCardMiddleSection}>
<Text style={[styles.planCardPrice, styleConfig && { color: styleConfig.accent }]}>
{priceLabel || '--'}
</Text>
{plan.originalPrice && (
<Text style={styles.planCardOriginalPrice}>{plan.originalPrice}</Text>
)}
</View>
<View style={styles.planCardBottomSection}>
<Text style={styles.planCardDescription}>{plan.subtitle}</Text>
</View>
</LinearGradient>
</TouchableOpacity>
);
};
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
presentationStyle="overFullScreen"
>
<View style={styles.overlay}>
{/* 半透明背景 */}
<TouchableOpacity
style={styles.backdrop}
activeOpacity={1}
onPress={onClose} // 阻止点击背景关闭
/>
<View style={styles.modalContainer}>
{/* 悬浮返回按钮 - 移到 ScrollView 外部以确保始终在最上层 */}
<TouchableOpacity
onPress={onClose}
activeOpacity={0.7}
accessible={true}
accessibilityLabel="返回"
accessibilityHint="关闭会员购买弹窗"
style={styles.floatingBackButtonContainer}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.floatingBackButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="chevron-back" size={24} color="#333" />
</GlassView>
) : (
<View style={[styles.floatingBackButton, styles.fallbackBackButton]}>
<Ionicons name="chevron-back" size={24} color="#333" />
</View>
)}
</TouchableOpacity>
<ScrollView
style={styles.modalContent}
contentContainerStyle={styles.modalContentContainer}
showsVerticalScrollIndicator={false}
>
<View style={[styles.sectionCard, {
marginTop: 56
}]}>
<View style={styles.sectionTitleRow}>
<View style={styles.sectionTitleBadge}>
<Ionicons name="star" size={16} color="#7B2CBF" />
</View>
<Text style={styles.sectionTitle}></Text>
</View>
<Text style={styles.sectionSubtitle}></Text>
{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}>
<Ionicons name="bulb" size={18} color="#F29F05" />
<Text style={styles.tipsText}>{getTipsContent(selectedProduct)}</Text>
</View>
)}
</>
)}
</View>
<View style={styles.sectionCard}>
<View style={styles.sectionTitleRow}>
<View style={styles.sectionTitleBadge}>
<Ionicons name="checkbox" size={16} color="#FF9F0A" />
</View>
<Text style={styles.sectionTitle}></Text>
</View>
<Text style={styles.sectionSubtitle}></Text>
<View style={styles.comparisonTable}>
<View style={[styles.tableRow, styles.tableHeader]}>
<Text style={[styles.tableHeaderText, styles.tableTitleCell]}></Text>
<Text style={[styles.tableHeaderText, styles.tableVipCell]}>VIP</Text>
<Text style={[styles.tableHeaderText, styles.tableNormalCell]}></Text>
</View>
{BENEFIT_COMPARISON.map((row, index) => (
<View
key={row.title}
style={[
styles.tableRow,
index % 2 === 1 && styles.tableRowAlt,
]}
>
<View style={styles.tableTitleCell}>
<Text style={styles.tableCellText}>{row.title}</Text>
{row.description && (
<Text style={styles.tableDescriptionText}>{row.description}</Text>
)}
</View>
{/* VIP 权限列 */}
<View style={styles.tableVipCell}>
<View style={styles.permissionContainer}>
{getPermissionIcon(row.vip.type, true)}
<Text style={styles.permissionText}>{row.vip.vipText || row.vip.text}</Text>
</View>
</View>
{/* 普通用户权限列 */}
<View style={styles.tableNormalCell}>
<View style={styles.permissionContainer}>
{getPermissionIcon(row.regular.type, false)}
<Text style={styles.permissionText}>{row.regular.vipText || row.regular.text}</Text>
</View>
</View>
</View>
))}
</View>
</View>
<View style={styles.bottomSection}>
<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={() => {
captureMessage('click membership agreement');
}}
>
<Text style={styles.agreementLink}></Text>
</TouchableOpacity>
<Text style={styles.agreementSeparator}>|</Text>
<TouchableOpacity
onPress={() => {
captureMessage('click auto renewal agreement');
}}
>
<Text style={styles.agreementLink}></Text>
</TouchableOpacity>
</View>
<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>
</View>
</ScrollView>
{/* 悬浮购买按钮 */}
<View style={styles.floatingPurchaseContainer}>
{isLiquidGlassAvailable() ? (
<GlassView
style={[
styles.purchaseButton,
(loading || products.length === 0) && styles.disabledButton
]}
glassEffectStyle="clear"
tintColor="rgba(21, 21, 21, 0.8)"
isInteractive={true}
>
<TouchableOpacity
onPress={handlePurchase}
disabled={loading || products.length === 0}
accessible={true}
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
accessibilityHint={
loading
? '购买正在进行中,请稍候'
: products.length === 0
? '正在加载会员套餐,请稍候'
: !selectedProduct
? '请选择会员套餐后再进行购买'
: `点击购买${selectedProduct.title || '已选'}会员套餐`
}
accessibilityState={{ disabled: loading || products.length === 0 }}
style={styles.purchaseButtonContent}
>
{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>
</GlassView>
) : (
<View
style={[
styles.purchaseButton,
styles.fallbackPurchaseButton,
(loading || products.length === 0) && styles.disabledButton
]}
>
<TouchableOpacity
onPress={handlePurchase}
disabled={loading || products.length === 0}
accessible={true}
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
accessibilityHint={
loading
? '购买正在进行中,请稍候'
: products.length === 0
? '正在加载会员套餐,请稍候'
: !selectedProduct
? '请选择会员套餐后再进行购买'
: `点击购买${selectedProduct.title || '已选'}会员套餐`
}
accessibilityState={{ disabled: loading || products.length === 0 }}
style={styles.purchaseButtonContent}
>
{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>
</View>
)}
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.35)',
},
modalContainer: {
height: height * 0.92,
backgroundColor: '#F5F6FA',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
overflow: 'hidden',
},
modalContent: {
flex: 1,
},
modalContentContainer: {
paddingHorizontal: 20,
paddingBottom: 100, // 增加底部内边距,避免内容被悬浮按钮遮挡
paddingTop: 16,
},
floatingBackButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
floatingBackButtonContainer: {
position: 'absolute',
top: 20,
left: 20,
zIndex: 10, // 确保按钮在最上层
},
fallbackBackButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
sectionCard: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
paddingHorizontal: 20,
paddingVertical: 18,
marginBottom: 20,
shadowColor: '#1C1C1E',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 2,
},
sectionTitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
sectionTitleBadge: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F4EFFD',
marginRight: 8,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#2B2B2E',
},
sectionSubtitle: {
fontSize: 13,
color: '#6B6B73',
marginTop: 6,
marginBottom: 16,
},
configurationNotice: {
borderRadius: 16,
padding: 16,
backgroundColor: '#FFF4E5',
},
configurationText: {
fontSize: 14,
color: '#B86A04',
textAlign: 'center',
lineHeight: 20,
},
plansContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
},
planCardWrapper: {
flex: 1,
marginHorizontal: 4,
borderRadius: 18,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(123,44,191,0.08)',
},
planCardWrapperSelected: {
borderColor: '#7B2CBF',
shadowColor: '#7B2CBF',
shadowOpacity: 0.18,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 4,
},
planCardGradient: {
flex: 1,
paddingHorizontal: 16,
paddingVertical: 18,
minHeight: 170,
justifyContent: 'space-between',
},
planTag: {
alignSelf: 'flex-start',
backgroundColor: '#2F2F36',
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 4,
marginBottom: 12,
},
planTagText: {
color: '#FFFFFF',
fontSize: 11,
fontWeight: '600',
},
planCardTitle: {
fontSize: 18,
fontWeight: '700',
color: '#241F1F',
},
planCardPrice: {
fontSize: 28,
fontWeight: '700',
marginTop: 12,
},
planCardOriginalPrice: {
fontSize: 13,
color: '#8E8EA1',
textDecorationLine: 'line-through',
marginTop: 2,
},
planCardDescription: {
fontSize: 12,
color: '#6C6C77',
lineHeight: 17,
},
planCardTopSection: {
flex: 1,
justifyContent: 'flex-start',
},
planCardMiddleSection: {
flex: 1,
justifyContent: 'center',
alignItems: 'flex-start',
},
planCardBottomSection: {
flex: 1,
justifyContent: 'flex-end',
},
tipsContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FEF4E6',
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 10,
marginTop: 16,
},
tipsText: {
flex: 1,
fontSize: 12,
color: '#9B6200',
marginLeft: 6,
lineHeight: 16,
},
comparisonTable: {
borderRadius: 16,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#ECECF3',
},
tableRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 16,
backgroundColor: '#FFFFFF',
},
tableHeader: {
backgroundColor: '#F8F8FF',
},
tableHeaderText: {
fontSize: 12,
fontWeight: '600',
color: '#575764',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
tableCellText: {
fontSize: 13,
color: '#3E3E44',
},
tableTitleCell: {
flex: 1.5,
justifyContent: 'center',
},
tableVipCell: {
flex: 0.8,
alignItems: 'center',
justifyContent: 'center',
},
tableNormalCell: {
flex: 0.8,
alignItems: 'center',
justifyContent: 'center',
},
tableRowAlt: {
backgroundColor: '#FBFBFF',
},
bottomSection: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
paddingHorizontal: 20,
paddingVertical: 20,
marginBottom: 10,
shadowColor: '#1C1C1E',
shadowOpacity: 0.04,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 1,
},
purchaseButton: {
borderRadius: 28,
height: 64,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
purchaseButtonContent: {
flex: 1,
width: '100%',
justifyContent: 'center',
alignItems: 'center',
},
floatingPurchaseContainer: {
position: 'absolute',
bottom: 34, // 底部安全区域
left: 20,
right: 20,
},
fallbackPurchaseButton: {
backgroundColor: '#151515',
},
disabledButton: {
backgroundColor: '#C6C6C8',
},
purchaseButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
},
loadingContainer: {
flexDirection: 'row',
alignItems: 'center',
},
loadingSpinner: {
marginRight: 8,
},
agreementRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'nowrap',
marginBottom: 16,
},
agreementPrefix: {
fontSize: 10,
color: '#666672',
marginRight: 4,
},
agreementLink: {
fontSize: 10,
color: '#E91E63',
textDecorationLine: 'underline',
fontWeight: '500',
marginHorizontal: 2,
},
agreementSeparator: {
fontSize: 10,
color: '#A0A0B0',
marginHorizontal: 2,
},
restoreButton: {
alignSelf: 'center',
paddingVertical: 6,
},
restoreButtonText: {
color: '#6F6F7A',
fontSize: 12,
fontWeight: '500',
},
disabledRestoreButton: {
opacity: 0.5,
},
restoreButtonContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
restoreButtonLoader: {
marginRight: 8,
},
disabledPlanCard: {
opacity: 0.5,
},
// 新增样式:权限相关
tableDescriptionText: {
fontSize: 11,
color: '#8E8E93',
marginTop: 2,
lineHeight: 14,
},
permissionContainer: {
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
paddingVertical: 4,
},
permissionText: {
fontSize: 10,
color: '#6B6B73',
marginTop: 4,
textAlign: 'center',
lineHeight: 12,
},
});