WellNuo/components/screens/subscription/SubscriptionScreen.web.tsx
Sergei 3f0fe56e02 Add protected route middleware and auth store for web app
- Implement Next.js middleware for route protection
- Create Zustand auth store for web (similar to mobile)
- Add comprehensive tests for middleware and auth store
- Protect authenticated routes (/dashboard, /profile)
- Redirect unauthenticated users to /login
- Redirect authenticated users from auth routes to /dashboard
- Handle session expiration with 401 callback
- Set access token cookie for middleware
- All tests passing (105 tests total)
2026-01-31 17:49:21 -08:00

659 lines
22 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
ActivityIndicator,
Modal,
ScrollView,
Linking,
Platform,
} 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'; // Removed for web
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
import type { Beneficiary } from '@/types';
// const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const SUBSCRIPTION_PRICE = 49;
type SubscriptionState = 'active' | 'canceling' | 'none';
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);
const [transactions, setTransactions] = useState<{
id: string;
type: 'subscription' | 'one_time';
amount: number;
currency: string;
status: string;
date: string;
description: string;
invoicePdf?: string;
hostedUrl?: string;
receiptUrl?: string;
}[]>([]);
// const [isLoadingTransactions, setIsLoadingTransactions] = useState(false);
// const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet(); // Removed for web
// const { user } = useAuth(); // Unused here for now
useEffect(() => {
loadBeneficiary();
loadTransactions();
}, [id]);
const loadTransactions = async () => {
if (!id) return;
// setIsLoadingTransactions(true);
try {
const response = await api.getTransactionHistory(parseInt(id, 10));
if (response.ok && response.data) {
setTransactions(response.data.transactions);
}
} catch (error) {
// Failed to load transactions
} finally {
// setIsLoadingTransactions(false);
}
};
const loadBeneficiary = async () => {
if (!id) return;
try {
const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
if (response.ok && response.data) {
setBeneficiary(response.data);
}
} catch (error) {
// Failed to load beneficiary
} finally {
setIsLoading(false);
}
};
const subscription = beneficiary?.subscription;
// Determine subscription state
const getSubscriptionState = (): SubscriptionState => {
if (!subscription) return 'none';
const isStatusActive = subscription.status === 'active' || subscription.status === 'trialing';
const isNotExpired = !subscription.endDate || new Date(subscription.endDate) > new Date();
if (isStatusActive && isNotExpired) {
return subscription.cancelAtPeriodEnd ? 'canceling' : 'active';
}
return 'none';
};
const subscriptionState = getSubscriptionState();
const handleSubscribe = async () => {
// Web Implementation
alert('Subscriptions are currently only available in the mobile app.');
};
const handleCancelSubscription = () => {
// Web Implementation
alert('Please use the mobile app to manage your subscription.');
};
const confirmCancelSubscription = async () => {
// Stub
};
const handleReactivateSubscription = async () => {
// Web Implementation
alert('Please use the mobile app to manage your subscription.');
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
const openReceipt = (url?: string) => {
if (url) Linking.openURL(url);
};
const handleSuccessModalClose = () => {
setShowSuccessModal(false);
router.replace(`/(tabs)/beneficiaries/${id}`);
};
// Loading state
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.centerContainer}>
<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.centerContainer}>
<Text style={styles.errorText}>Unable to load beneficiary</Text>
</View>
</SafeAreaView>
);
}
// Render subscription status card based on state
const renderStatusCard = () => {
switch (subscriptionState) {
case 'active':
return (
<View style={styles.statusCard}>
<View style={styles.statusIconActive}>
<Ionicons name="checkmark-circle" size={32} color={AppColors.white} />
</View>
<Text style={styles.statusTitle}>Active Subscription</Text>
<Text style={styles.statusSubtitle}>
{subscription?.endDate
? `Renews ${formatDate(new Date(subscription.endDate))}`
: 'Renews monthly'}
</Text>
<View style={styles.priceRow}>
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text>
<Text style={styles.pricePeriod}>/month</Text>
</View>
</View>
);
case 'canceling':
return (
<View style={[styles.statusCard, styles.statusCardCanceling]}>
<View style={styles.statusIconCanceling}>
<Ionicons name="time" size={32} color={AppColors.white} />
</View>
<Text style={styles.statusTitle}>Subscription Ending</Text>
<Text style={styles.statusSubtitleCanceling}>
Access until {subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'period end'}
</Text>
<Text style={styles.cancelingNote}>
After this date, monitoring and alerts for {beneficiary.displayName} will stop.
</Text>
</View>
);
case 'none':
default:
return (
<View style={[styles.statusCard, styles.statusCardNone]}>
<View style={styles.statusIconNone}>
<Ionicons name="shield-outline" size={32} color={AppColors.textMuted} />
</View>
<Text style={styles.statusTitleNone}>No Active Subscription</Text>
<Text style={styles.statusSubtitleNone}>
Subscribe to unlock monitoring for {beneficiary.displayName}
</Text>
<View style={styles.priceRow}>
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text>
<Text style={styles.pricePeriod}>/month</Text>
</View>
</View>
);
}
};
// Render action button based on state
const renderActionButton = () => {
switch (subscriptionState) {
case 'active':
return (
<TouchableOpacity
style={styles.cancelButton}
onPress={handleCancelSubscription}
disabled={isCanceling}
>
{isCanceling ? (
<ActivityIndicator size="small" color={AppColors.textMuted} />
) : (
<Text style={styles.cancelButtonText}>Cancel Subscription</Text>
)}
</TouchableOpacity>
);
case 'canceling':
return (
<TouchableOpacity
style={[styles.primaryButton, 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.primaryButtonText}>Reactivate Subscription</Text>
</>
)}
</TouchableOpacity>
);
case 'none':
default:
return (
<TouchableOpacity
style={[styles.primaryButton, isProcessing && styles.buttonDisabled]}
onPress={handleSubscribe}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.primaryButtonText}>Subscribe</Text>
</>
)}
</TouchableOpacity>
);
}
};
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>
<BeneficiaryMenu
beneficiaryId={id || ''}
userRole={beneficiary?.role}
currentPage="subscription"
/>
</View>
<ScrollView
style={styles.scrollContent}
contentContainerStyle={[
styles.content,
// Center content when no subscription and no transactions
subscriptionState === 'none' && transactions.length === 0 && styles.contentCentered
]}
>
{/* Status Card */}
{renderStatusCard()}
{/* Action Button */}
<View style={styles.actionSection}>
{renderActionButton()}
{/* Security note */}
<View style={styles.securityRow}>
<Ionicons name="shield-checkmark" size={14} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment by Stripe</Text>
</View>
</View>
{/* Transaction History */}
{transactions.length > 0 && (
<View style={styles.transactionSection}>
<Text style={styles.sectionTitle}>Payment History</Text>
{transactions.map((tx) => (
<TouchableOpacity
key={tx.id}
style={styles.transactionItem}
onPress={() => openReceipt(tx.invoicePdf || tx.hostedUrl || tx.receiptUrl)}
disabled={!tx.invoicePdf && !tx.hostedUrl && !tx.receiptUrl}
>
<View style={styles.transactionLeft}>
<Text style={styles.transactionDesc}>{tx.description}</Text>
<Text style={styles.transactionDate}>{formatDate(new Date(tx.date))}</Text>
</View>
<View style={styles.transactionRight}>
<Text style={styles.transactionAmount}>${tx.amount.toFixed(2)}</Text>
{(tx.invoicePdf || tx.hostedUrl || tx.receiptUrl) && (
<Ionicons name="chevron-forward" size={16} color={AppColors.textMuted} />
)}
</View>
</TouchableOpacity>
))}
</View>
)}
<View style={{ marginTop: 20, padding: 10, backgroundColor: '#f0f9ff', borderRadius: 8 }}>
<Text style={{ textAlign: 'center', color: '#0066cc', fontSize: 14 }}>
Web version: Subscription management is limited. Please use mobile app.
</Text>
</View>
</ScrollView>
{/* Success Modal */}
<Modal
visible={showSuccessModal}
transparent={true}
animationType="fade"
onRequestClose={handleSuccessModalClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalIcon}>
<Ionicons name="checkmark-circle" size={56} color={AppColors.success} />
</View>
<Text style={styles.modalTitle}>Subscription Active!</Text>
<Text style={styles.modalMessage}>
Monitoring for {beneficiary?.name} is now enabled.
</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,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: FontSizes.base,
color: AppColors.textMuted,
},
scrollContent: {
flex: 1,
},
content: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
contentCentered: {
flexGrow: 1,
justifyContent: 'center',
},
// Status Card Styles
statusCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
borderWidth: 2,
borderColor: AppColors.success,
...Shadows.sm,
},
statusCardCanceling: {
borderColor: '#F59E0B',
backgroundColor: '#FFFBEB',
},
statusCardNone: {
borderColor: AppColors.border,
backgroundColor: AppColors.surface,
},
statusIconActive: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: AppColors.success,
alignItems: 'center',
justifyContent: 'center',
marginBottom: Spacing.md,
},
statusIconCanceling: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#F59E0B',
alignItems: 'center',
justifyContent: 'center',
marginBottom: Spacing.md,
},
statusIconNone: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: AppColors.surfaceSecondary,
alignItems: 'center',
justifyContent: 'center',
marginBottom: Spacing.md,
},
statusTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
statusTitleNone: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textSecondary,
marginBottom: Spacing.xs,
},
statusSubtitle: {
fontSize: FontSizes.sm,
color: AppColors.success,
marginBottom: Spacing.md,
},
statusSubtitleCanceling: {
fontSize: FontSizes.sm,
color: '#B45309',
fontWeight: FontWeights.medium,
marginBottom: Spacing.sm,
},
statusSubtitleNone: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textAlign: 'center',
marginBottom: Spacing.md,
},
cancelingNote: {
fontSize: FontSizes.sm,
color: '#92400E',
textAlign: 'center',
lineHeight: 20,
},
priceRow: {
flexDirection: 'row',
alignItems: 'baseline',
},
priceAmount: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
pricePeriod: {
fontSize: FontSizes.base,
color: AppColors.textMuted,
marginLeft: 2,
},
// Action Section
actionSection: {
marginTop: Spacing.xl,
gap: Spacing.md,
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
...Shadows.sm,
},
reactivateButton: {
backgroundColor: AppColors.success,
},
buttonDisabled: {
opacity: 0.7,
},
primaryButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
cancelButton: {
paddingVertical: Spacing.sm,
alignItems: 'center',
},
cancelButtonText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textDecorationLine: 'underline',
},
securityRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.xs,
},
securityText: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
// Transaction Section
transactionSection: {
marginTop: Spacing.xl,
},
sectionTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.md,
},
transactionItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: AppColors.surface,
padding: Spacing.md,
borderRadius: BorderRadius.md,
marginBottom: Spacing.sm,
},
transactionLeft: {
flex: 1,
},
transactionDesc: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
transactionDate: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: 2,
},
transactionRight: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
transactionAmount: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
// Modal
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: 320,
alignItems: 'center',
...Shadows.lg,
},
modalIcon: {
marginBottom: Spacing.md,
},
modalTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.sm,
},
modalMessage: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
modalButton: {
backgroundColor: AppColors.primary,
paddingHorizontal: Spacing.xl,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
marginTop: Spacing.lg,
width: '100%',
alignItems: 'center',
},
modalButtonText: {
color: AppColors.white,
fontWeight: FontWeights.semibold,
fontSize: FontSizes.base,
},
});