WellNuo/app/(tabs)/beneficiaries/[id]/subscription.tsx
Sergei 06802c237b Improve subscription flow, Stripe integration & auth context
- Refactor subscription page with simplified UI flow
- Update Stripe routes and config for price handling
- Improve AuthContext with better profile management
- Fix equipment status and beneficiary screens
- Update voice screen and profile pages
- Simplify purchase flow
2026-01-08 21:35:24 -08:00

761 lines
23 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
ActivityIndicator,
Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { usePaymentSheet } from '@stripe/stripe-react-native';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
import type { Beneficiary } from '@/types';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const SUBSCRIPTION_PRICE = 49; // $49/month
export default function SubscriptionScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [isProcessing, setIsProcessing] = useState(false);
const [isCanceling, setIsCanceling] = useState(false);
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [justSubscribed, setJustSubscribed] = useState(false); // Prevent self-guard redirect after payment
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { user } = useAuth();
useEffect(() => {
loadBeneficiary();
}, [id]);
const loadBeneficiary = async () => {
if (!id) return;
try {
const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
if (response.ok && response.data) {
setBeneficiary(response.data);
} else {
console.error('Failed to load beneficiary:', response.error);
}
} catch (error) {
console.error('Failed to load beneficiary:', error);
} finally {
setIsLoading(false);
}
};
const subscription = beneficiary?.subscription;
const isActive = (subscription?.status === 'active' || subscription?.status === 'trialing') &&
subscription?.endDate &&
new Date(subscription.endDate) > new Date();
// Check if subscription is canceled but still active until period end
const isCanceledButActive = isActive && subscription?.cancelAtPeriodEnd === true;
// Self-guard: redirect if user shouldn't be on this page
useEffect(() => {
if (isLoading || !beneficiary || !id) return;
// Don't redirect if we just subscribed and modal is showing
if (justSubscribed || showSuccessModal) return;
// If no devices - redirect to purchase (waterfall priority)
if (!hasBeneficiaryDevices(beneficiary)) {
const status = beneficiary.equipmentStatus;
if (status && ['ordered', 'shipped', 'delivered'].includes(status)) {
router.replace(`/(tabs)/beneficiaries/${id}/equipment-status`);
} else {
router.replace(`/(tabs)/beneficiaries/${id}/purchase`);
}
return;
}
// If already has active subscription - redirect to dashboard
if (isActive) {
router.replace(`/(tabs)/beneficiaries/${id}`);
}
}, [beneficiary, isLoading, id, isActive, justSubscribed, showSuccessModal]);
const handleSubscribe = async () => {
if (!beneficiary) {
Alert.alert('Error', 'Beneficiary data not loaded.');
return;
}
setIsProcessing(true);
try {
// Get auth token
const token = await api.getToken();
if (!token) {
Alert.alert('Error', 'Please log in again');
setIsProcessing(false);
return;
}
// 1. Create subscription payment sheet via Stripe Subscriptions API
const response = await fetch(`${STRIPE_API_URL}/create-subscription-payment-sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
beneficiaryId: beneficiary.id,
}),
});
// Check if response is OK before parsing JSON
if (!response.ok) {
const errorText = await response.text();
let errorMessage = 'Failed to create payment';
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.error || errorJson.message || errorMessage;
} catch {
errorMessage = `Server error (${response.status})`;
}
throw new Error(errorMessage);
}
const data = await response.json();
// Check if already subscribed
if (data.alreadySubscribed) {
Alert.alert(
'Already Subscribed!',
`${beneficiary.name} already has an active subscription.`
);
await loadBeneficiary();
return;
}
if (!data.clientSecret) {
throw new Error(data.error || 'Failed to create subscription');
}
// 2. Initialize the Payment Sheet
// Determine if clientSecret is for PaymentIntent (pi_) or SetupIntent (seti_)
const isSetupIntent = data.clientSecret.startsWith('seti_');
const paymentSheetParams: Parameters<typeof initPaymentSheet>[0] = {
merchantDisplayName: 'WellNuo',
customerId: data.customer,
// Use Customer Session for showing saved payment methods (new API)
// Falls back to Ephemeral Key for backwards compatibility
...(data.customerSessionClientSecret
? { customerSessionClientSecret: data.customerSessionClientSecret }
: { customerEphemeralKeySecret: data.ephemeralKey }),
returnURL: 'wellnuo://stripe-redirect',
applePay: {
merchantCountryCode: 'US',
},
googlePay: {
merchantCountryCode: 'US',
testEnv: true,
},
};
// Use correct parameter based on secret type
if (isSetupIntent) {
paymentSheetParams.setupIntentClientSecret = data.clientSecret;
} else {
paymentSheetParams.paymentIntentClientSecret = data.clientSecret;
}
const { error: initError } = await initPaymentSheet(paymentSheetParams);
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! Confirm the subscription payment
// This ensures the subscription invoice gets paid if using manual PaymentIntent
console.log('[Subscription] Payment Sheet completed, confirming payment...');
const confirmResponse = await fetch(
`${STRIPE_API_URL}/confirm-subscription-payment`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscriptionId: data.subscriptionId,
}),
}
);
const confirmData = await confirmResponse.json();
console.log('[Subscription] Confirm response:', confirmData);
// 5. Fetch subscription status from Stripe
const statusResponse = await fetch(
`${STRIPE_API_URL}/subscription-status/${beneficiary.id}`,
{
headers: {
'Authorization': `Bearer ${token}`,
},
}
);
const statusData = await statusResponse.json();
// Mark as just subscribed to prevent self-guard redirect during modal
setJustSubscribed(true);
// Reload beneficiary to get updated subscription
await loadBeneficiary();
// Show success modal instead of Alert
setShowSuccessModal(true);
} catch (error) {
console.error('Payment error:', error);
Alert.alert(
'Payment Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
} finally {
setIsProcessing(false);
}
};
const handleCancelSubscription = () => {
if (!beneficiary) return;
Alert.alert(
'Cancel Subscription?',
`Are you sure you want to cancel the subscription for ${beneficiary.name}?\n\n• You'll keep access until ${subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'the end of the billing period'}\n• No refunds for remaining time\n• You can reactivate anytime before the period ends`,
[
{ text: 'Keep Subscription', style: 'cancel' },
{
text: 'Cancel Subscription',
style: 'destructive',
onPress: confirmCancelSubscription,
},
]
);
};
const confirmCancelSubscription = async () => {
if (!beneficiary) return;
setIsCanceling(true);
try {
const response = await api.cancelSubscription(beneficiary.id);
if (!response.ok) {
throw new Error(response.error || 'Failed to cancel subscription');
}
// Reload beneficiary to get updated subscription status
await loadBeneficiary();
Alert.alert(
'Subscription Canceled',
`Your subscription will remain active until ${response.data?.cancelAt ? formatDate(new Date(response.data.cancelAt)) : 'the end of the billing period'}. You can reactivate anytime before then.`,
[{ text: 'OK' }]
);
} catch (error) {
console.error('Cancel error:', error);
Alert.alert(
'Cancellation Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
} finally {
setIsCanceling(false);
}
};
const handleReactivateSubscription = async () => {
if (!beneficiary) return;
setIsProcessing(true);
try {
// Get auth token
const token = await api.getToken();
if (!token) {
Alert.alert('Error', 'Please log in again');
setIsProcessing(false);
return;
}
const response = await fetch(`${STRIPE_API_URL}/reactivate-subscription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
beneficiaryId: beneficiary.id,
}),
});
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || 'Failed to reactivate subscription');
}
// Reload beneficiary to get updated subscription status
await loadBeneficiary();
Alert.alert(
'Subscription Reactivated!',
'Your subscription has been reactivated and will continue to renew automatically.',
[{ text: 'Great!' }]
);
} catch (error) {
console.error('Reactivate error:', error);
Alert.alert(
'Reactivation Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
} finally {
setIsProcessing(false);
}
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const handleSuccessModalClose = () => {
setShowSuccessModal(false);
// Navigate to beneficiary dashboard after closing modal
router.replace(`/(tabs)/beneficiaries/${id}`);
};
if (isLoading) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Subscription</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
</View>
</SafeAreaView>
);
}
if (!beneficiary) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Subscription</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.noBeneficiaryContainer}>
<View style={styles.noBeneficiaryIcon}>
<Ionicons name="person-outline" size={48} color={AppColors.textMuted} />
</View>
<Text style={styles.noBeneficiaryTitle}>Beneficiary Not Found</Text>
<Text style={styles.noBeneficiaryText}>
Unable to load beneficiary information.
</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Subscription</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.content}>
{/* Subscription Card */}
<View style={styles.subscriptionCard}>
<View style={styles.cardHeader}>
<Text style={styles.proBadgeText}>WellNuo Subscription</Text>
<View style={styles.priceContainer}>
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text>
<Text style={styles.pricePeriod}>/month</Text>
</View>
</View>
<Text style={styles.planDescription}>
Full access to real-time monitoring, AI insights, alerts, voice companion, and family sharing for {beneficiary.name}
</Text>
{/* Status indicator */}
{isActive && (
<View style={isCanceledButActive ? styles.cancelingBadge : styles.activeBadge}>
<Ionicons
name={isCanceledButActive ? 'time-outline' : 'checkmark-circle'}
size={16}
color={isCanceledButActive ? AppColors.warning : AppColors.success}
/>
<Text style={isCanceledButActive ? styles.cancelingBadgeText : styles.activeBadgeText}>
{isCanceledButActive
? `Ends ${subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'soon'}`
: `Active until ${subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'N/A'}`}
</Text>
</View>
)}
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment by Stripe</Text>
</View>
</View>
{/* Actions */}
<View style={styles.actionsSection}>
{/* Subscribe button when not active */}
{!isActive && (
<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}>
Subscribe
</Text>
</>
)}
</TouchableOpacity>
)}
{/* Reactivate button when canceled but still active */}
{isCanceledButActive && (
<TouchableOpacity
style={[styles.reactivateButton, isProcessing && styles.buttonDisabled]}
onPress={handleReactivateSubscription}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="refresh" size={20} color={AppColors.white} />
<Text style={styles.subscribeButtonText}>Reactivate Subscription</Text>
</>
)}
</TouchableOpacity>
)}
{/* Cancel link - only show when active subscription */}
{isActive && !isCanceledButActive && (
<TouchableOpacity
style={styles.linkButton}
onPress={handleCancelSubscription}
disabled={isCanceling}
>
{isCanceling ? (
<ActivityIndicator size="small" color={AppColors.textMuted} />
) : (
<Text style={styles.linkButtonTextMuted}>Cancel Subscription</Text>
)}
</TouchableOpacity>
)}
</View>
</View>
{/* Success Modal */}
<Modal
visible={showSuccessModal}
transparent={true}
animationType="fade"
onRequestClose={handleSuccessModalClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalIconContainer}>
<Ionicons name="checkmark-circle" size={64} color={AppColors.success} />
</View>
<Text style={styles.modalTitle}>Subscription Activated!</Text>
<Text style={styles.modalMessage}>
Subscription for {beneficiary?.name} is now active.
</Text>
<TouchableOpacity
style={styles.modalButton}
onPress={handleSuccessModalClose}
>
<Text style={styles.modalButtonText}>Continue</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
noBeneficiaryContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: Spacing.xl,
},
noBeneficiaryIcon: {
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.lg,
},
noBeneficiaryTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.sm,
},
noBeneficiaryText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 24,
},
content: {
flex: 1,
padding: Spacing.lg,
justifyContent: 'center',
alignItems: 'center',
},
subscriptionCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
borderWidth: 2,
borderColor: AppColors.primary,
width: '100%',
...Shadows.md,
},
cardHeader: {
alignItems: 'center',
},
proBadgeText: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
priceContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: Spacing.sm,
},
priceAmount: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
pricePeriod: {
fontSize: FontSizes.lg,
color: AppColors.textSecondary,
marginLeft: Spacing.xs,
},
planDescription: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 22,
marginTop: Spacing.md,
},
activeBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#D1FAE5',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
marginTop: Spacing.md,
},
activeBadgeText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.success,
},
cancelingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FEF3C7',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
marginTop: Spacing.md,
},
cancelingBadgeText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.warning,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: Spacing.md,
gap: Spacing.xs,
},
securityText: {
fontSize: FontSizes.xs,
color: AppColors.success,
},
actionsSection: {
width: '100%',
gap: Spacing.md,
marginTop: Spacing.xl,
},
subscribeButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
},
buttonDisabled: {
opacity: 0.7,
},
subscribeButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
reactivateButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.success,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
},
linkButton: {
paddingVertical: Spacing.xs,
alignItems: 'center',
},
linkButtonTextMuted: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textDecorationLine: 'underline',
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.lg,
},
modalContent: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
width: '100%',
maxWidth: 340,
alignItems: 'center',
...Shadows.lg,
},
modalIconContainer: {
marginBottom: Spacing.md,
},
modalTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.sm,
textAlign: 'center',
},
modalMessage: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.lg,
lineHeight: 22,
},
modalButton: {
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.xl,
borderRadius: BorderRadius.lg,
width: '100%',
},
modalButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
textAlign: 'center',
},
});