WellNuo/components/screens/subscription/SubscriptionScreen.native.tsx
Sergei d453126c89 feat: Room location picker + robster credentials
- Backend: Update Legacy API credentials to robster/rob2
- Frontend: ROOM_LOCATIONS with icons and legacyCode mapping
- Device Settings: Modal picker for room selection
- api.ts: Bidirectional conversion (code ↔ name)
- Various UI/UX improvements across screens

PRD-DEPLOYMENT.md completed (Score: 9/10)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:22:40 -08:00

818 lines
24 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
ActivityIndicator,
Modal,
ScrollView,
Linking,
} 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 { 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<Array<{
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();
const { user } = useAuth();
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) {
// Silently ignore
} 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) {
// Silently ignore
} 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 () => {
if (!beneficiary) {
Alert.alert('Error', 'Beneficiary data not loaded.');
return;
}
setIsProcessing(true);
try {
const token = await api.getToken();
if (!token) {
Alert.alert('Error', 'Please log in again');
setIsProcessing(false);
return;
}
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 }),
});
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();
if (data.alreadySubscribed) {
Alert.alert('Already Subscribed!', `${beneficiary.displayName} already has an active subscription.`);
await loadBeneficiary();
return;
}
if (!data.clientSecret) {
throw new Error(data.error || 'Failed to create subscription - no clientSecret');
}
const isSetupIntent = data.clientSecret.startsWith('seti_');
// Build payment sheet params based on intent type
const baseParams = {
merchantDisplayName: 'WellNuo',
customerId: data.customer,
...(data.customerSessionClientSecret
? { customerSessionClientSecret: data.customerSessionClientSecret }
: { customerEphemeralKeySecret: data.ephemeralKey }),
returnURL: 'wellnuo://stripe-redirect',
applePay: { merchantCountryCode: 'US' },
googlePay: { merchantCountryCode: 'US', testEnv: true },
};
const paymentSheetParams = isSetupIntent
? { ...baseParams, setupIntentClientSecret: data.clientSecret }
: { ...baseParams, paymentIntentClientSecret: data.clientSecret };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: initError } = await initPaymentSheet(paymentSheetParams as any);
if (initError) {
throw new Error(initError.message);
}
const { error: presentError } = await presentPaymentSheet();
if (presentError) {
if (presentError.code === 'Canceled') {
setIsProcessing(false);
return;
}
throw new Error(presentError.message);
}
// For PaymentIntent (subscription with immediate payment), the subscription
// is automatically activated via Stripe webhook when payment succeeds.
// No need to call confirm endpoint for PaymentIntent.
if (isSetupIntent && data.subscriptionId) {
// Only for SetupIntent flow (future payment), we need to confirm
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,
beneficiaryId: beneficiary.id,
}),
});
const confirmData = await confirmResponse.json();
if (!confirmResponse.ok || confirmData.error) {
throw new Error(confirmData.error || 'Failed to confirm subscription');
}
}
// Wait a moment for webhook to process
await new Promise(resolve => setTimeout(resolve, 2000));
setJustSubscribed(true);
await loadBeneficiary();
setShowSuccessModal(true);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Something went wrong';
Alert.alert('Payment Failed', errorMsg);
} finally {
setIsProcessing(false);
}
};
const handleCancelSubscription = () => {
if (!beneficiary) return;
Alert.alert(
'Cancel Subscription?',
`Your subscription will remain active until ${subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'the end of your billing period'}. You can reactivate anytime before then.`,
[
{ text: 'Keep Subscription', style: 'cancel' },
{ text: 'Cancel', 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?.message || 'Failed to cancel subscription');
await loadBeneficiary();
Alert.alert('Subscription Canceled', 'You can reactivate anytime before the period ends.');
} catch (error) {
Alert.alert('Error', error instanceof Error ? error.message : 'Something went wrong.');
} finally {
setIsCanceling(false);
}
};
const handleReactivateSubscription = async () => {
if (!beneficiary) return;
setIsProcessing(true);
try {
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');
}
await loadBeneficiary();
Alert.alert('Reactivated!', 'Your subscription will continue to renew automatically.');
} catch (error) {
Alert.alert('Error', error instanceof Error ? error.message : 'Something went wrong.');
} finally {
setIsProcessing(false);
}
};
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>
)}
</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,
textAlign: 'center',
marginBottom: Spacing.lg,
},
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',
},
});