Database: - Simplified beneficiary schema: single `name` field instead of first_name/last_name - Single `address` field instead of 5 separate address columns - Added migration 008_update_notification_settings.sql Backend: - Updated all beneficiaries routes for new schema - Fixed admin routes for simplified fields - Updated notification settings routes - Improved stripe and webhook handlers Frontend: - Updated all forms to use single name/address fields - Added new equipment-status and purchase screens - Added BeneficiaryDetailController service - Added subscription service - Improved navigation and auth flow - Various UI improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
416 lines
12 KiB
TypeScript
416 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 { useToast } from '@/components/ui/Toast';
|
|
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
|
|
import type { Beneficiary } 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 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.`
|
|
);
|
|
|
|
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') {
|
|
// User canceled - don't show error, just return
|
|
setIsProcessing(false);
|
|
return;
|
|
}
|
|
throw new Error(presentError.message);
|
|
}
|
|
|
|
// 4. Payment sheet closed - verify the subscription is actually active
|
|
// This is CRITICAL: presentPaymentSheet can return success even without payment!
|
|
const statusResponse = await fetch(
|
|
`${STRIPE_API_URL}/subscription-status/${beneficiary.id}`
|
|
);
|
|
const statusData = await statusResponse.json();
|
|
|
|
console.log('Subscription status after payment:', statusData);
|
|
|
|
// Check if subscription is actually active
|
|
if (!['active', 'trialing'].includes(statusData.status)) {
|
|
// Payment was not completed - subscription is still incomplete
|
|
console.log('Payment not completed. Status:', statusData.status);
|
|
throw new Error(
|
|
statusData.status === 'incomplete'
|
|
? 'Payment was not completed. Please try again and enter your card details.'
|
|
: `Subscription activation failed. Status: ${statusData.status}`
|
|
);
|
|
}
|
|
|
|
// Update local state with data from Stripe
|
|
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,
|
|
},
|
|
});
|