WellNuo/components/SubscriptionPayment.tsx
Sergei 20be9a94c2 WIP: Navigation controller, subscription flow, and various improvements
- Add NavigationController for centralized routing logic
- Add useNavigationFlow hook for easy usage in components
- Update subscription flow with Stripe integration
- Simplify activate.tsx
- Update beneficiaries and profile screens
- Update CLAUDE.md with navigation documentation
2026-01-04 12:53:38 -08:00

425 lines
12 KiB
TypeScript

import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { usePaymentSheet } from '@stripe/stripe-react-native';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useToast } from '@/components/ui/Toast';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
import type { Beneficiary, BeneficiarySubscription } from '@/types';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const SUBSCRIPTION_PRICE = 49; // $49/month
interface SubscriptionPaymentProps {
beneficiary: Beneficiary;
onSuccess?: () => void;
compact?: boolean; // For inline use vs full page
}
export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }: SubscriptionPaymentProps) {
const [isProcessing, setIsProcessing] = useState(false);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { updateLocalBeneficiary } = useBeneficiary();
const toast = useToast();
const isExpired = beneficiary?.subscription?.status === 'expired';
const handleSubscribe = async () => {
setIsProcessing(true);
try {
// 1. Create subscription payment sheet via new Stripe Subscriptions API
const response = await fetch(`${STRIPE_API_URL}/create-subscription-payment-sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
beneficiaryId: beneficiary.id,
}),
});
const data = await response.json();
// Check if already subscribed
if (data.alreadySubscribed) {
toast.success(
'Already Subscribed!',
`${beneficiary.name} already has an active subscription.`
);
// Update local state
await updateLocalBeneficiary(beneficiary.id, {
subscription: {
status: 'active',
endDate: data.subscription.currentPeriodEnd,
planType: 'monthly',
price: SUBSCRIPTION_PRICE,
},
});
onSuccess?.();
return;
}
if (!data.clientSecret) {
throw new Error(data.error || 'Failed to create subscription');
}
// 2. Initialize the Payment Sheet
const { error: initError } = await initPaymentSheet({
merchantDisplayName: 'WellNuo',
paymentIntentClientSecret: data.clientSecret,
customerId: data.customer,
customerEphemeralKeySecret: data.ephemeralKey,
returnURL: 'wellnuo://stripe-redirect',
applePay: {
merchantCountryCode: 'US',
},
googlePay: {
merchantCountryCode: 'US',
testEnv: true,
},
});
if (initError) {
throw new Error(initError.message);
}
// 3. Present the Payment Sheet
const { error: presentError } = await presentPaymentSheet();
if (presentError) {
if (presentError.code === 'Canceled') {
setIsProcessing(false);
return;
}
throw new Error(presentError.message);
}
// 4. Payment successful! Subscription is now active in Stripe
// Fetch the current status from Stripe to confirm
const statusResponse = await fetch(
`${STRIPE_API_URL}/subscription-status/${beneficiary.id}`
);
const statusData = await statusResponse.json();
// Update local state with data from Stripe
const newSubscription: BeneficiarySubscription = {
status: statusData.status === 'active' ? 'active' : 'none',
endDate: statusData.currentPeriodEnd,
planType: 'monthly',
price: SUBSCRIPTION_PRICE,
};
await updateLocalBeneficiary(beneficiary.id, {
subscription: newSubscription,
});
toast.success(
'Subscription Activated!',
`Subscription for ${beneficiary.name} is now active.`
);
onSuccess?.();
} catch (error) {
console.error('Payment error:', error);
toast.error(
'Payment Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
} finally {
setIsProcessing(false);
}
};
const features = [
'24/7 AI wellness monitoring',
'Unlimited Julia AI chat',
'Detailed activity reports',
'Smart alerts & notifications',
];
if (compact) {
// Compact version for inline use
return (
<View style={styles.compactContainer}>
<View style={styles.compactHeader}>
<View style={styles.compactIconContainer}>
<Ionicons
name={isExpired ? 'time-outline' : 'diamond-outline'}
size={32}
color={isExpired ? AppColors.error : AppColors.accent}
/>
</View>
<View style={styles.compactInfo}>
<Text style={styles.compactTitle}>
{isExpired ? 'Subscription Expired' : 'Subscription Required'}
</Text>
<Text style={styles.compactPrice}>
${SUBSCRIPTION_PRICE}/month
</Text>
</View>
</View>
<TouchableOpacity
style={[styles.subscribeButton, isProcessing && styles.buttonDisabled]}
onPress={handleSubscribe}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.subscribeButtonText}>
{isExpired ? 'Renew Now' : 'Subscribe Now'}
</Text>
</>
)}
</TouchableOpacity>
</View>
);
}
// Full version
return (
<View style={styles.container}>
{/* Icon */}
<View style={[styles.iconContainer, isExpired && { backgroundColor: AppColors.errorLight }]}>
<Ionicons
name={isExpired ? 'time-outline' : 'diamond-outline'}
size={56}
color={isExpired ? AppColors.error : AppColors.accent}
/>
</View>
{/* Title */}
<Text style={styles.title}>
{isExpired ? 'Subscription Expired' : 'Subscription Required'}
</Text>
<Text style={styles.subtitle}>
{isExpired
? `Your subscription for ${beneficiary.name} has expired. Renew now to continue monitoring their wellness.`
: `Activate a subscription to view ${beneficiary.name}'s dashboard and wellness data.`}
</Text>
{/* Price Card */}
<View style={styles.priceCard}>
<View style={styles.priceHeader}>
<View>
<Text style={styles.planName}>WellNuo Pro</Text>
<Text style={styles.planDesc}>Full access to all features</Text>
</View>
<View style={styles.priceBadge}>
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text>
<Text style={styles.priceUnit}>/month</Text>
</View>
</View>
<View style={styles.features}>
{features.map((feature, index) => (
<View key={index} style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
</View>
{/* Subscribe Button */}
<TouchableOpacity
style={[styles.subscribeButtonFull, isProcessing && styles.buttonDisabled]}
onPress={handleSubscribe}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={22} color={AppColors.white} />
<Text style={styles.subscribeButtonTextFull}>
{isExpired ? 'Renew Subscription' : 'Subscribe Now'}
</Text>
</>
)}
</TouchableOpacity>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="lock-closed" size={14} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
// Full version styles
container: {
flex: 1,
padding: Spacing.xl,
alignItems: 'center',
justifyContent: 'center',
},
iconContainer: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: AppColors.accentLight,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.lg,
},
title: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
},
subtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 24,
marginBottom: Spacing.xl,
paddingHorizontal: Spacing.md,
},
priceCard: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.xl,
...Shadows.sm,
},
priceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.md,
},
planName: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
planDesc: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
priceBadge: {
flexDirection: 'row',
alignItems: 'baseline',
},
priceAmount: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
priceUnit: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginLeft: 2,
},
features: {
gap: Spacing.sm,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
featureText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
subscribeButtonFull: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
width: '100%',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
...Shadows.primary,
},
subscribeButtonTextFull: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
marginTop: Spacing.lg,
},
securityText: {
fontSize: FontSizes.xs,
color: AppColors.success,
},
// Compact version styles
compactContainer: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
...Shadows.sm,
},
compactHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: Spacing.md,
},
compactIconContainer: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: AppColors.accentLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
compactInfo: {
flex: 1,
},
compactTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
compactPrice: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.bold,
color: AppColors.primary,
marginTop: 2,
},
subscribeButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
},
subscribeButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
buttonDisabled: {
opacity: 0.7,
},
});