629 lines
18 KiB
TypeScript
629 lines
18 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
Alert,
|
|
ActivityIndicator,
|
|
} 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 { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import type { Beneficiary, BeneficiarySubscription } from '@/types';
|
|
|
|
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
|
const SUBSCRIPTION_PRICE = 49; // $49/month
|
|
|
|
interface PlanFeatureProps {
|
|
text: string;
|
|
included: boolean;
|
|
}
|
|
|
|
function PlanFeature({ text, included }: PlanFeatureProps) {
|
|
return (
|
|
<View style={styles.featureRow}>
|
|
<Ionicons
|
|
name={included ? 'checkmark-circle' : 'close-circle'}
|
|
size={20}
|
|
color={included ? AppColors.success : AppColors.textMuted}
|
|
/>
|
|
<Text style={[styles.featureText, !included && styles.featureTextDisabled]}>
|
|
{text}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default function SubscriptionScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
|
|
const { user } = useAuth();
|
|
const { getBeneficiaryById, updateLocalBeneficiary } = useBeneficiary();
|
|
|
|
useEffect(() => {
|
|
loadBeneficiary();
|
|
}, [id]);
|
|
|
|
const loadBeneficiary = async () => {
|
|
if (!id) return;
|
|
|
|
try {
|
|
const data = await getBeneficiaryById(id);
|
|
setBeneficiary(data);
|
|
} catch (error) {
|
|
console.error('Failed to load beneficiary:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const subscription = beneficiary?.subscription;
|
|
const isActive = subscription?.status === 'active' &&
|
|
subscription?.endDate &&
|
|
new Date(subscription.endDate) > new Date();
|
|
|
|
const daysRemaining = subscription?.endDate
|
|
? Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
|
: 0;
|
|
|
|
const handleSubscribe = async () => {
|
|
if (!beneficiary) {
|
|
Alert.alert('Error', 'Beneficiary data not loaded.');
|
|
return;
|
|
}
|
|
|
|
setIsProcessing(true);
|
|
|
|
try {
|
|
// 1. Create Payment Sheet on our server
|
|
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
email: user?.email || 'guest@wellnuo.com',
|
|
amount: SUBSCRIPTION_PRICE * 100, // Convert to cents ($49.00)
|
|
metadata: {
|
|
type: 'subscription',
|
|
planType: 'monthly',
|
|
userId: user?.user_id || 'guest',
|
|
beneficiaryId: beneficiary.id,
|
|
beneficiaryName: beneficiary.name,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.paymentIntent) {
|
|
throw new Error(data.error || 'Failed to create payment sheet');
|
|
}
|
|
|
|
// 2. Initialize the Payment Sheet
|
|
const { error: initError } = await initPaymentSheet({
|
|
merchantDisplayName: 'WellNuo',
|
|
paymentIntentClientSecret: data.paymentIntent,
|
|
customerId: data.customer,
|
|
customerEphemeralKeySecret: data.ephemeralKey,
|
|
defaultBillingDetails: {
|
|
email: user?.email || '',
|
|
},
|
|
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! Save subscription to beneficiary
|
|
const now = new Date();
|
|
const endDate = new Date(now);
|
|
endDate.setMonth(endDate.getMonth() + 1); // 1 month subscription
|
|
|
|
const newSubscription: BeneficiarySubscription = {
|
|
status: 'active',
|
|
startDate: now.toISOString(),
|
|
endDate: endDate.toISOString(),
|
|
planType: 'monthly',
|
|
price: SUBSCRIPTION_PRICE,
|
|
};
|
|
|
|
await updateLocalBeneficiary(beneficiary.id, {
|
|
subscription: newSubscription,
|
|
});
|
|
|
|
// Reload beneficiary to get updated subscription
|
|
await loadBeneficiary();
|
|
|
|
Alert.alert(
|
|
'Subscription Activated!',
|
|
`Subscription for ${beneficiary.name} is now active until ${formatDate(endDate)}.`,
|
|
[{ text: 'Great!' }]
|
|
);
|
|
} 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 handleRestorePurchases = () => {
|
|
Alert.alert(
|
|
'Restoring Purchases',
|
|
'Looking for previous purchases...',
|
|
[{ text: 'OK' }]
|
|
);
|
|
|
|
setTimeout(() => {
|
|
Alert.alert('No Purchases Found', 'We couldn\'t find any previous purchases associated with your account.');
|
|
}, 1500);
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
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>
|
|
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
{/* Beneficiary Info */}
|
|
<View style={styles.beneficiaryBanner}>
|
|
<View style={styles.beneficiaryAvatar}>
|
|
<Text style={styles.beneficiaryAvatarText}>
|
|
{beneficiary.name.charAt(0).toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.beneficiaryInfo}>
|
|
<Text style={styles.beneficiaryLabel}>Subscription for</Text>
|
|
<Text style={styles.beneficiaryName}>{beneficiary.name}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Current Status */}
|
|
<View style={styles.statusBanner}>
|
|
{isActive ? (
|
|
<>
|
|
<View style={styles.activeBadge}>
|
|
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
|
|
<Text style={styles.activeBadgeText}>ACTIVE</Text>
|
|
</View>
|
|
<Text style={styles.statusTitle}>Subscription is active</Text>
|
|
<Text style={styles.statusDescription}>
|
|
Valid until {subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'N/A'}
|
|
</Text>
|
|
<View style={styles.daysRemaining}>
|
|
<Text style={styles.daysRemainingNumber}>{daysRemaining}</Text>
|
|
<Text style={styles.daysRemainingLabel}>days remaining</Text>
|
|
</View>
|
|
</>
|
|
) : (
|
|
<>
|
|
<View style={styles.inactiveBadge}>
|
|
<Ionicons name="close-circle" size={20} color={AppColors.error} />
|
|
<Text style={styles.inactiveBadgeText}>
|
|
{subscription?.status === 'expired' ? 'EXPIRED' : 'NO SUBSCRIPTION'}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.statusTitle}>
|
|
{subscription?.status === 'expired'
|
|
? 'Subscription has expired'
|
|
: `Subscribe for ${beneficiary.name}`}
|
|
</Text>
|
|
<Text style={styles.statusDescription}>
|
|
Get full access to monitoring features
|
|
</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
{/* Subscription Card */}
|
|
<View style={styles.section}>
|
|
<View style={styles.subscriptionCard}>
|
|
<View style={styles.cardHeader}>
|
|
<View style={styles.proBadge}>
|
|
<Ionicons name="shield-checkmark" size={20} color={AppColors.primary} />
|
|
<Text style={styles.proBadgeText}>WellNuo</Text>
|
|
</View>
|
|
<View style={styles.priceContainer}>
|
|
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text>
|
|
<Text style={styles.pricePeriod}>/month</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.featuresContainer}>
|
|
<PlanFeature text="Real-time activity monitoring" included={true} />
|
|
<PlanFeature text="AI-powered wellness insights" included={true} />
|
|
<PlanFeature text="Instant alert notifications" included={true} />
|
|
<PlanFeature text="Voice AI companion (Julia)" included={true} />
|
|
<PlanFeature text="Activity history & trends" included={true} />
|
|
<PlanFeature text="Family sharing" included={true} />
|
|
<PlanFeature text="24/7 Priority support" included={true} />
|
|
</View>
|
|
|
|
{!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 — ${SUBSCRIPTION_PRICE}/month
|
|
</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Secure Payment Badge */}
|
|
<View style={styles.securityBadge}>
|
|
<Ionicons name="lock-closed" size={16} color={AppColors.success} />
|
|
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
|
|
</View>
|
|
|
|
{/* Restore Purchases */}
|
|
<TouchableOpacity style={styles.restoreButton} onPress={handleRestorePurchases}>
|
|
<Text style={styles.restoreButtonText}>Restore Purchases</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Terms */}
|
|
<Text style={styles.termsText}>
|
|
Payment will be charged to your account at the confirmation of purchase.
|
|
Subscription can be cancelled at any time from your account settings.
|
|
</Text>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: AppColors.surface,
|
|
},
|
|
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',
|
|
},
|
|
// No beneficiary state
|
|
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,
|
|
},
|
|
// Beneficiary banner
|
|
beneficiaryBanner: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: AppColors.primaryLighter,
|
|
marginHorizontal: Spacing.lg,
|
|
marginTop: Spacing.md,
|
|
padding: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
},
|
|
beneficiaryAvatar: {
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 24,
|
|
backgroundColor: AppColors.primary,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
beneficiaryAvatarText: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.white,
|
|
},
|
|
beneficiaryInfo: {
|
|
marginLeft: Spacing.md,
|
|
},
|
|
beneficiaryLabel: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
beneficiaryName: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.textPrimary,
|
|
},
|
|
// Status banner
|
|
statusBanner: {
|
|
backgroundColor: AppColors.background,
|
|
padding: Spacing.xl,
|
|
alignItems: 'center',
|
|
},
|
|
activeBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: '#D1FAE5',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.xs,
|
|
borderRadius: BorderRadius.full,
|
|
gap: Spacing.xs,
|
|
},
|
|
activeBadgeText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.success,
|
|
},
|
|
inactiveBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: '#FEE2E2',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.xs,
|
|
borderRadius: BorderRadius.full,
|
|
gap: Spacing.xs,
|
|
},
|
|
inactiveBadgeText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.error,
|
|
},
|
|
statusTitle: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.textPrimary,
|
|
marginTop: Spacing.md,
|
|
textAlign: 'center',
|
|
},
|
|
statusDescription: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
marginTop: Spacing.xs,
|
|
textAlign: 'center',
|
|
},
|
|
daysRemaining: {
|
|
marginTop: Spacing.lg,
|
|
alignItems: 'center',
|
|
},
|
|
daysRemainingNumber: {
|
|
fontSize: 48,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.primary,
|
|
},
|
|
daysRemainingLabel: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
section: {
|
|
marginTop: Spacing.md,
|
|
},
|
|
subscriptionCard: {
|
|
backgroundColor: AppColors.background,
|
|
marginHorizontal: Spacing.lg,
|
|
borderRadius: BorderRadius.lg,
|
|
overflow: 'hidden',
|
|
borderWidth: 2,
|
|
borderColor: AppColors.primary,
|
|
},
|
|
cardHeader: {
|
|
backgroundColor: `${AppColors.primary}10`,
|
|
padding: Spacing.lg,
|
|
alignItems: 'center',
|
|
},
|
|
proBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.xs,
|
|
},
|
|
proBadgeText: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.primary,
|
|
},
|
|
priceContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'baseline',
|
|
marginTop: Spacing.md,
|
|
},
|
|
priceAmount: {
|
|
fontSize: 48,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.textPrimary,
|
|
},
|
|
pricePeriod: {
|
|
fontSize: FontSizes.lg,
|
|
color: AppColors.textSecondary,
|
|
marginLeft: Spacing.xs,
|
|
},
|
|
featuresContainer: {
|
|
padding: Spacing.lg,
|
|
},
|
|
featureRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: Spacing.xs,
|
|
},
|
|
featureText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textPrimary,
|
|
marginLeft: Spacing.sm,
|
|
},
|
|
featureTextDisabled: {
|
|
color: AppColors.textMuted,
|
|
},
|
|
subscribeButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: Spacing.sm,
|
|
backgroundColor: AppColors.primary,
|
|
marginHorizontal: Spacing.lg,
|
|
marginBottom: Spacing.lg,
|
|
paddingVertical: Spacing.lg,
|
|
borderRadius: BorderRadius.lg,
|
|
},
|
|
buttonDisabled: {
|
|
opacity: 0.7,
|
|
},
|
|
subscribeButtonText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.white,
|
|
},
|
|
securityBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: Spacing.xs,
|
|
marginTop: Spacing.lg,
|
|
paddingVertical: Spacing.md,
|
|
},
|
|
securityText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.success,
|
|
},
|
|
restoreButton: {
|
|
alignItems: 'center',
|
|
paddingVertical: Spacing.md,
|
|
},
|
|
restoreButtonText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.primary,
|
|
},
|
|
termsText: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textMuted,
|
|
textAlign: 'center',
|
|
paddingHorizontal: Spacing.xl,
|
|
paddingBottom: Spacing.xl,
|
|
lineHeight: 16,
|
|
},
|
|
});
|