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
This commit is contained in:
Sergei 2026-01-08 21:35:24 -08:00
parent fe4ff1a932
commit 06802c237b
14 changed files with 513 additions and 745 deletions

View File

@ -1,10 +1,9 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
ActivityIndicator,
Alert,
} from 'react-native';
@ -15,6 +14,8 @@ import { usePaymentSheet } from '@stripe/stripe-react-native';
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights, Shadows } from '@/constants/theme';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
@ -22,34 +23,58 @@ const STARTER_KIT = {
name: 'WellNuo Starter Kit',
price: '$249',
priceValue: 249,
description: 'Everything you need to start monitoring your loved ones',
features: [
'Motion sensor (PIR)',
'Door/window sensor',
'Temperature & humidity sensor',
'WellNuo Hub',
'Mobile app access',
'1 year subscription included',
],
};
export default function PurchaseScreen() {
// Get lovedOneName from add-loved-one flow
const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>();
const lovedOneName = params.lovedOneName || '';
const beneficiaryId = params.beneficiaryId;
const [isProcessing, setIsProcessing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [step, setStep] = useState<'purchase' | 'order_placed'>('purchase');
const { user } = useAuth();
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
// Check if equipment is already ordered - redirect to equipment-status
const checkEquipmentStatus = useCallback(async () => {
if (!beneficiaryId) {
setIsLoading(false);
return;
}
try {
const response = await api.getWellNuoBeneficiary(parseInt(beneficiaryId, 10));
if (response.ok && response.data) {
// If user already has devices - go to main screen
if (hasBeneficiaryDevices(response.data)) {
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}`);
return;
}
// If equipment is ordered/shipped/delivered - go to equipment-status
const status = response.data.equipmentStatus;
if (status && ['ordered', 'shipped', 'delivered'].includes(status)) {
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}/equipment-status`);
return;
}
}
} catch (error) {
console.warn('[Purchase] Failed to check equipment status:', error);
}
setIsLoading(false);
}, [beneficiaryId]);
useEffect(() => {
checkEquipmentStatus();
}, [checkEquipmentStatus]);
const handlePurchase = async () => {
setIsProcessing(true);
try {
// Validate required data before proceeding
const userId = user?.user_id;
if (!userId) {
Alert.alert('Error', 'User not authenticated. Please log in again.');
@ -63,7 +88,6 @@ export default function PurchaseScreen() {
return;
}
// Get auth token
const token = await api.getToken();
if (!token) {
Alert.alert('Error', 'Please log in again');
@ -73,7 +97,6 @@ export default function PurchaseScreen() {
console.log('[Purchase] Creating payment sheet for userId:', userId, 'beneficiaryId:', beneficiaryId);
// 1. Create Payment Sheet on our server
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
method: 'POST',
headers: {
@ -82,7 +105,7 @@ export default function PurchaseScreen() {
},
body: JSON.stringify({
email: user?.email,
amount: STARTER_KIT.priceValue * 100, // Convert to cents ($249.00)
amount: STARTER_KIT.priceValue * 100,
metadata: {
userId: String(userId),
beneficiaryId: String(beneficiaryId),
@ -98,7 +121,6 @@ export default function PurchaseScreen() {
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,
@ -121,19 +143,16 @@ export default function PurchaseScreen() {
throw new Error(initError.message);
}
// 3. Present the Payment Sheet
const { error: presentError } = await presentPaymentSheet();
if (presentError) {
if (presentError.code === 'Canceled') {
// User cancelled - do nothing
setIsProcessing(false);
return;
}
throw new Error(presentError.message);
}
// 4. Payment successful! Update equipment status to 'ordered'
console.log('[Purchase] Payment successful, updating equipment status...');
const statusResponse = await api.updateBeneficiaryEquipmentStatus(
parseInt(beneficiaryId, 10),
@ -142,13 +161,9 @@ export default function PurchaseScreen() {
if (!statusResponse.ok) {
console.warn('[Purchase] Failed to update equipment status:', statusResponse.error?.message);
// Continue anyway - payment was successful
}
// Mark onboarding as completed
await api.setOnboardingCompleted(true);
// Show Order Placed screen
setStep('order_placed');
} catch (error) {
console.error('Payment error:', error);
@ -161,8 +176,7 @@ export default function PurchaseScreen() {
setIsProcessing(false);
};
const handleSkip = () => {
// User says "I already have a kit" - go to activate screen with beneficiary ID
const handleAlreadyHaveSensors = () => {
router.replace({
pathname: '/(auth)/activate',
params: { beneficiaryId, lovedOneName },
@ -177,12 +191,16 @@ export default function PurchaseScreen() {
}
};
// Loading state - checking equipment status
if (isLoading) {
return <LoadingSpinner fullScreen message="Loading..." />;
}
// Order Placed Screen
if (step === 'order_placed') {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.orderPlacedContainer}>
{/* Success Icon */}
<View style={styles.successIcon}>
<Ionicons name="checkmark" size={64} color={AppColors.white} />
</View>
@ -192,7 +210,6 @@ export default function PurchaseScreen() {
Thank you for your purchase
</Text>
{/* Order Info */}
<View style={styles.orderInfoCard}>
<View style={styles.orderInfoRow}>
<Text style={styles.orderInfoLabel}>Item</Text>
@ -202,53 +219,15 @@ export default function PurchaseScreen() {
<Text style={styles.orderInfoLabel}>For</Text>
<Text style={styles.orderInfoValue}>{lovedOneName || 'Your loved one'}</Text>
</View>
<View style={styles.orderInfoRow}>
<View style={[styles.orderInfoRow, { borderBottomWidth: 0 }]}>
<Text style={styles.orderInfoLabel}>Total</Text>
<Text style={[styles.orderInfoValue, styles.orderInfoPrice]}>{STARTER_KIT.price}</Text>
</View>
</View>
{/* What's Next */}
<View style={styles.whatsNextCard}>
<Text style={styles.whatsNextTitle}>What's Next?</Text>
<View style={styles.stepItem}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>1</Text>
</View>
<View style={styles.stepContent}>
<Text style={styles.stepTitle}>Order Processing</Text>
<Text style={styles.stepDescription}>We'll prepare your kit for shipping</Text>
</View>
</View>
<View style={styles.stepItem}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>2</Text>
</View>
<View style={styles.stepContent}>
<Text style={styles.stepTitle}>Shipping Notification</Text>
<Text style={styles.stepDescription}>You'll receive an email with tracking info</Text>
</View>
</View>
<View style={styles.stepItem}>
<View style={[styles.stepNumber, styles.stepNumberLast]}>
<Text style={styles.stepNumberText}>3</Text>
</View>
<View style={styles.stepContent}>
<Text style={styles.stepTitle}>Activate Your Kit</Text>
<Text style={styles.stepDescription}>Enter the serial number when delivered</Text>
</View>
</View>
</View>
{/* Actions */}
<View style={styles.orderPlacedActions}>
<TouchableOpacity style={styles.primaryButton} onPress={handleGoToEquipmentStatus}>
<Text style={styles.primaryButtonText}>Track My Kit</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.primaryButton} onPress={handleGoToEquipmentStatus}>
<Text style={styles.primaryButtonText}>Track My Order</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
@ -256,7 +235,7 @@ export default function PurchaseScreen() {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
@ -274,50 +253,42 @@ export default function PurchaseScreen() {
<View style={styles.productIcon}>
<Ionicons name="hardware-chip" size={48} color={AppColors.primary} />
</View>
<Text style={styles.productName}>{STARTER_KIT.name}</Text>
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
<Text style={styles.productDescription}>{STARTER_KIT.description}</Text>
{/* Features */}
<View style={styles.features}>
{STARTER_KIT.features.map((feature, index) => (
<View key={index} style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
<Text style={styles.productDescription}>
4 smart sensors that easily plug into any outlet and set up through the app in minutes
</Text>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
</View>
</View>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={20} color={AppColors.success} />
<Text style={styles.securityText}>
Secure payment powered by Stripe
</Text>
{/* Bottom Actions */}
<View style={styles.bottomActions}>
<TouchableOpacity
style={[styles.purchaseButton, isProcessing && styles.buttonDisabled]}
onPress={handlePurchase}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={handleAlreadyHaveSensors}>
<Text style={styles.skipButtonText}>I already have sensors</Text>
</TouchableOpacity>
</View>
</ScrollView>
{/* Bottom Actions */}
<View style={styles.bottomActions}>
<TouchableOpacity
style={[styles.purchaseButton, isProcessing && styles.buttonDisabled]}
onPress={handlePurchase}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipButtonText}>I already have a kit</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
@ -329,13 +300,14 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.background,
},
content: {
flex: 1,
padding: Spacing.lg,
justifyContent: 'space-between',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.xl,
},
backButton: {
padding: Spacing.sm,
@ -356,11 +328,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
borderWidth: 2,
borderColor: AppColors.primary,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
...Shadows.md,
},
productIcon: {
width: 80,
@ -372,45 +340,31 @@ const styles = StyleSheet.create({
marginBottom: Spacing.lg,
},
productName: {
fontSize: FontSizes['2xl'],
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
marginBottom: Spacing.xs,
},
productPrice: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
marginBottom: Spacing.sm,
marginBottom: Spacing.lg,
},
productDescription: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.xl,
},
features: {
width: '100%',
gap: Spacing.md,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
featureText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
flex: 1,
lineHeight: 22,
marginBottom: Spacing.lg,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
marginTop: Spacing.xl,
paddingVertical: Spacing.md,
gap: Spacing.xs,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: `${AppColors.success}10`,
borderRadius: BorderRadius.lg,
},
@ -419,8 +373,6 @@ const styles = StyleSheet.create({
color: AppColors.success,
},
bottomActions: {
padding: Spacing.lg,
paddingBottom: Spacing.xl,
gap: Spacing.md,
},
purchaseButton: {
@ -431,6 +383,7 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
...Shadows.primary,
},
buttonDisabled: {
opacity: 0.7,
@ -442,14 +395,14 @@ const styles = StyleSheet.create({
},
skipButton: {
alignItems: 'center',
paddingVertical: Spacing.md,
paddingVertical: Spacing.sm,
},
skipButtonText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textDecorationLine: 'underline',
},
// Order Placed Screen Styles
// Order Placed Screen
orderPlacedContainer: {
flex: 1,
padding: Spacing.lg,
@ -482,7 +435,7 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.lg,
marginBottom: Spacing.lg,
marginBottom: Spacing.xl,
...Shadows.sm,
},
orderInfoRow: {
@ -506,58 +459,8 @@ const styles = StyleSheet.create({
color: AppColors.primary,
fontWeight: FontWeights.bold,
},
whatsNextCard: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.lg,
marginBottom: Spacing.xl,
...Shadows.sm,
},
whatsNextTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.lg,
},
stepItem: {
flexDirection: 'row',
marginBottom: Spacing.md,
},
stepNumber: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: AppColors.primary,
alignItems: 'center',
justifyContent: 'center',
marginRight: Spacing.md,
},
stepNumberLast: {
backgroundColor: AppColors.textMuted,
},
stepNumberText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.bold,
color: AppColors.white,
},
stepContent: {
flex: 1,
},
stepTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
stepDescription: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
orderPlacedActions: {
width: '100%',
},
primaryButton: {
width: '100%',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,

View File

@ -191,7 +191,7 @@ export default function EquipmentStatusScreen() {
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<TouchableOpacity style={styles.backButton} onPress={() => router.replace('/(tabs)')}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiary.name}</Text>

View File

@ -64,8 +64,6 @@ export default function BeneficiaryDetailScreen() {
const [showWebView, setShowWebView] = useState(false);
const [isWebViewReady, setIsWebViewReady] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<number | null>(null);
// Edit modal state
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
@ -78,11 +76,7 @@ export default function BeneficiaryDetailScreen() {
const loadLegacyToken = async () => {
try {
const token = await api.getLegacyToken();
const name = await api.getLegacyUserName();
const id = await api.getLegacyUserId();
setAuthToken(token);
setUserName(name);
setUserId(id);
setIsWebViewReady(true);
} catch (err) {
console.log('[BeneficiaryDetail] Legacy token not available');
@ -238,12 +232,10 @@ export default function BeneficiaryDetailScreen() {
(function() {
try {
var authData = {
username: '${userName || ''}',
token: '${authToken}',
user_id: ${userId || 'null'}
token: '${authToken}'
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Auth data injected:', authData.username);
console.log('Auth data injected');
} catch(e) {
console.error('Failed to inject token:', e);
}

View File

@ -34,14 +34,7 @@ const STARTER_KIT = {
name: 'WellNuo Starter Kit',
price: '$249',
priceValue: 249,
features: [
'Motion sensor (PIR)',
'Door/window sensor',
'Temperature & humidity sensor',
'WellNuo Hub',
'Mobile app access',
'1 year subscription included',
],
description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
};
export default function PurchaseScreen() {
@ -186,28 +179,6 @@ export default function PurchaseScreen() {
});
};
const handleTryDemo = async () => {
if (!beneficiary || !id) return;
setIsProcessing(true);
try {
// Create demo device via API
const response = await api.activateDemoDevice(parseInt(id, 10));
if (response.ok) {
toast.success('Demo Activated!', 'You can now explore the dashboard.');
router.replace(`/(tabs)/beneficiaries/${id}/subscription`);
} else {
toast.error('Error', response.error?.message || 'Failed to activate demo');
}
} catch (error) {
toast.error('Error', 'Failed to activate demo mode');
} finally {
setIsProcessing(false);
}
};
if (isLoading) {
return <LoadingSpinner fullScreen message="Loading..." />;
}
@ -253,15 +224,17 @@ export default function PurchaseScreen() {
<Text style={styles.kitName}>{STARTER_KIT.name}</Text>
<Text style={styles.kitPrice}>{STARTER_KIT.price}</Text>
<View style={styles.features}>
{STARTER_KIT.features.map((feature, index) => (
<View key={index} style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
<Text style={styles.kitDescription}>{STARTER_KIT.description}</Text>
{/* 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}>
<TouchableOpacity
style={[styles.buyButton, isProcessing && styles.buyButtonDisabled]}
onPress={handlePurchase}
@ -277,25 +250,8 @@ export default function PurchaseScreen() {
)}
</TouchableOpacity>
{/* 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>
{/* Alternative Options */}
<View style={styles.alternativeSection}>
<TouchableOpacity style={styles.alternativeButton} onPress={handleAlreadyHaveSensors}>
<Ionicons name="wifi" size={20} color={AppColors.primary} />
<Text style={styles.alternativeText}>I already have sensors</Text>
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
<TouchableOpacity style={styles.alternativeButton} onPress={handleTryDemo}>
<Ionicons name="play-circle-outline" size={20} color={AppColors.primary} />
<Text style={styles.alternativeText}>Try demo mode</Text>
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
<TouchableOpacity style={styles.alreadyHaveButton} onPress={handleAlreadyHaveSensors}>
<Text style={styles.alreadyHaveText}>I already have sensors</Text>
</TouchableOpacity>
</View>
</ScrollView>
@ -379,19 +335,14 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginVertical: Spacing.md,
},
features: {
marginBottom: Spacing.lg,
kitDescription: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 22,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.xs,
gap: Spacing.sm,
},
featureText: {
fontSize: FontSizes.sm,
color: AppColors.textPrimary,
flex: 1,
actionsSection: {
gap: Spacing.md,
},
buyButton: {
flexDirection: 'row',
@ -421,20 +372,13 @@ const styles = StyleSheet.create({
fontSize: FontSizes.xs,
color: AppColors.success,
},
alternativeSection: {
gap: Spacing.sm,
},
alternativeButton: {
flexDirection: 'row',
alreadyHaveButton: {
alignItems: 'center',
backgroundColor: AppColors.surface,
padding: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.md,
paddingVertical: Spacing.sm,
},
alternativeText: {
flex: 1,
alreadyHaveText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
color: AppColors.textSecondary,
textDecorationLine: 'underline',
},
});

View File

@ -3,10 +3,10 @@ import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
@ -21,25 +21,6 @@ import type { Beneficiary } 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 }>();
@ -47,6 +28,8 @@ export default function SubscriptionScreen() {
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();
@ -80,14 +63,13 @@ export default function SubscriptionScreen() {
// Check if subscription is canceled but still active until period end
const isCanceledButActive = isActive && subscription?.cancelAtPeriodEnd === true;
const daysRemaining = subscription?.endDate
? Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: 0;
// 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;
@ -103,7 +85,7 @@ export default function SubscriptionScreen() {
if (isActive) {
router.replace(`/(tabs)/beneficiaries/${id}`);
}
}, [beneficiary, isLoading, id, isActive]);
}, [beneficiary, isLoading, id, isActive, justSubscribed, showSuccessModal]);
const handleSubscribe = async () => {
if (!beneficiary) {
@ -134,6 +116,19 @@ export default function SubscriptionScreen() {
}),
});
// 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
@ -151,11 +146,17 @@ export default function SubscriptionScreen() {
}
// 2. Initialize the Payment Sheet
const { error: initError } = await initPaymentSheet({
// Determine if clientSecret is for PaymentIntent (pi_) or SetupIntent (seti_)
const isSetupIntent = data.clientSecret.startsWith('seti_');
const paymentSheetParams: Parameters<typeof initPaymentSheet>[0] = {
merchantDisplayName: 'WellNuo',
paymentIntentClientSecret: data.clientSecret,
customerId: data.customer,
customerEphemeralKeySecret: data.ephemeralKey,
// 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',
@ -164,7 +165,16 @@ export default function SubscriptionScreen() {
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);
@ -181,7 +191,26 @@ export default function SubscriptionScreen() {
throw new Error(presentError.message);
}
// 4. Payment successful! Fetch subscription status from Stripe
// 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}`,
{
@ -192,14 +221,14 @@ export default function SubscriptionScreen() {
);
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();
Alert.alert(
'Subscription Activated!',
`Subscription for ${beneficiary.name} is now active.`,
[{ text: 'Great!' }]
);
// Show success modal instead of Alert
setShowSuccessModal(true);
} catch (error) {
console.error('Payment error:', error);
Alert.alert(
@ -309,18 +338,6 @@ export default function SubscriptionScreen() {
}
};
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',
@ -329,6 +346,12 @@ export default function SubscriptionScreen() {
});
};
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']}>
@ -380,176 +403,126 @@ export default function SubscriptionScreen() {
<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 ? (
isCanceledButActive ? (
// Subscription canceled but still active until period end
<>
<View style={styles.cancelingBadge}>
<Ionicons name="time-outline" size={20} color={AppColors.warning} />
<Text style={styles.cancelingBadgeText}>ENDING SOON</Text>
</View>
<Text style={styles.statusTitle}>Active until {subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'N/A'}</Text>
<View style={styles.daysRemaining}>
<Text style={[styles.daysRemainingNumber, { color: AppColors.warning }]}>{daysRemaining}</Text>
<Text style={styles.daysRemainingLabel}>days left</Text>
</View>
</>
) : (
// Active subscription
<>
<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>
<View style={styles.content}>
{/* 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 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>
<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} />
<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>
)}
{/* Show 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 ${SUBSCRIPTION_PRICE}/month
</Text>
</>
)}
</TouchableOpacity>
)}
{/* Show Reactivate button when subscription is 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>
)}
{/* 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>
{/* 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>
{/* 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>
)}
{/* Links section */}
<View style={styles.linksSection}>
<TouchableOpacity style={styles.linkButton} onPress={handleRestorePurchases}>
<Text style={styles.linkButtonText}>Restore Purchases</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 && (
<>
<Text style={styles.linkDivider}></Text>
<TouchableOpacity
style={styles.linkButton}
onPress={handleCancelSubscription}
disabled={isCanceling}
>
{isCanceling ? (
<ActivityIndicator size="small" color={AppColors.textMuted} />
) : (
<Text style={styles.linkButtonTextMuted}>Cancel</Text>
)}
</TouchableOpacity>
</>
<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>
{/* 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>
{/* 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>
);
}
@ -557,7 +530,7 @@ export default function SubscriptionScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.surface,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
@ -584,7 +557,6 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
// No beneficiary state
noBeneficiaryContainer: {
flex: 1,
justifyContent: 'center',
@ -612,171 +584,96 @@ const styles = StyleSheet.create({
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,
content: {
flex: 1,
padding: Spacing.lg,
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,
},
cancelingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FEF3C7',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.xs,
borderRadius: BorderRadius.full,
gap: Spacing.xs,
},
cancelingBadgeText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.bold,
color: AppColors.warning,
},
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',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
borderWidth: 2,
borderColor: AppColors.primary,
width: '100%',
...Shadows.md,
},
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,
color: AppColors.textPrimary,
},
priceContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: Spacing.md,
marginTop: Spacing.sm,
},
priceAmount: {
fontSize: 48,
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
color: AppColors.primary,
},
pricePeriod: {
fontSize: FontSizes.lg,
color: AppColors.textSecondary,
marginLeft: Spacing.xs,
},
featuresContainer: {
padding: Spacing.lg,
planDescription: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 22,
marginTop: Spacing.md,
},
featureRow: {
activeBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.xs,
backgroundColor: '#D1FAE5',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
marginTop: Spacing.md,
},
featureText: {
activeBadgeText: {
fontSize: FontSizes.sm,
color: AppColors.textPrimary,
marginLeft: Spacing.sm,
fontWeight: FontWeights.medium,
color: AppColors.success,
},
featureTextDisabled: {
color: AppColors.textMuted,
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',
@ -784,9 +681,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.primary,
marginHorizontal: Spacing.lg,
marginBottom: Spacing.lg,
paddingVertical: Spacing.lg,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
},
buttonDisabled: {
@ -803,52 +698,63 @@ const styles = StyleSheet.create({
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.success,
marginHorizontal: Spacing.lg,
marginBottom: Spacing.lg,
paddingVertical: Spacing.lg,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.xs,
marginTop: Spacing.lg,
paddingVertical: Spacing.md,
},
securityText: {
fontSize: FontSizes.sm,
color: AppColors.success,
},
linksSection: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.md,
gap: Spacing.sm,
},
linkButton: {
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.xs,
},
linkButtonText: {
fontSize: FontSizes.sm,
color: AppColors.primary,
alignItems: 'center',
},
linkButtonTextMuted: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textDecorationLine: 'underline',
},
linkDivider: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.lg,
},
termsText: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
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,
paddingBottom: Spacing.xl,
lineHeight: 16,
borderRadius: BorderRadius.lg,
width: '100%',
},
modalButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
textAlign: 'center',
},
});

View File

@ -232,7 +232,11 @@ export default function HomeScreen() {
};
const getDisplayName = () => {
if (user?.user_name) return user.user_name;
// Check firstName/lastName from API
if (user?.firstName) {
return user.lastName ? `${user.firstName} ${user.lastName}` : user.firstName;
}
// Fallback to email prefix
if (user?.email) return user.email.split('@')[0];
return 'User';
};

View File

@ -89,7 +89,6 @@ export default function EditProfileScreen() {
// Update user in AuthContext (will refetch from API)
if (updateUser) {
updateUser({
user_name: displayName.trim(),
firstName,
lastName,
phone,

View File

@ -120,8 +120,14 @@ export default function ProfileScreen() {
);
};
const userName = user?.user_name || 'User';
const userInitial = userName.charAt(0).toUpperCase();
const displayName = useMemo(() => {
if (user?.firstName) {
return user.lastName ? `${user.firstName} ${user.lastName}` : user.firstName;
}
if (user?.email) return user.email.split('@')[0];
return 'User';
}, [user?.firstName, user?.lastName, user?.email]);
const userInitial = displayName.charAt(0).toUpperCase();
// Generate invite code based on user email or id
const inviteCode = useMemo(() => {
@ -168,7 +174,7 @@ export default function ProfileScreen() {
</View>
</TouchableOpacity>
<Text style={styles.userName}>{userName}</Text>
<Text style={styles.displayName}>{displayName}</Text>
<Text style={styles.userEmail}>{user?.email || ''}</Text>
{/* Invite Code */}
@ -326,7 +332,7 @@ const styles = StyleSheet.create({
borderWidth: 3,
borderColor: AppColors.surface,
},
userName: {
displayName: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,

View File

@ -254,14 +254,13 @@ export default function VoiceAIScreen() {
}, [isSpeaking]);
// Fetch activity data
const getActivityContext = async (token: string, userName: string, deploymentId: string): Promise<string> => {
const getActivityContext = async (token: string, deploymentId: string): Promise<string> => {
try {
const response = await fetch(OLD_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
function: 'activities_report_details',
user_name: userName,
token: token,
deployment_id: deploymentId,
filter: '0',
@ -298,7 +297,7 @@ export default function VoiceAIScreen() {
}
};
const getDashboardContext = async (token: string, userName: string, deploymentId: string): Promise<string> => {
const getDashboardContext = async (token: string, deploymentId: string): Promise<string> => {
try {
const today = new Date().toISOString().split('T')[0];
const response = await fetch(OLD_API_URL, {
@ -306,7 +305,6 @@ export default function VoiceAIScreen() {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
function: 'dashboard_single',
user_name: userName,
token: token,
deployment_id: deploymentId,
date: today,
@ -331,17 +329,16 @@ export default function VoiceAIScreen() {
const sendToVoiceAsk = async (question: string): Promise<string> => {
const token = await SecureStore.getItemAsync('accessToken');
const userName = await SecureStore.getItemAsync('userName');
if (!token || !userName) throw new Error('Please log in to use voice assistant');
if (!token) throw new Error('Please log in to use voice assistant');
if (!currentBeneficiary?.id) throw new Error('Please select a beneficiary first');
const beneficiaryName = currentBeneficiary.name || 'the patient';
const deploymentId = currentBeneficiary.id.toString();
let activityContext = await getActivityContext(token, userName, deploymentId);
let activityContext = await getActivityContext(token, deploymentId);
if (!activityContext) {
activityContext = await getDashboardContext(token, userName, deploymentId);
activityContext = await getDashboardContext(token, deploymentId);
}
let enhancedQuestion: string;
@ -357,7 +354,6 @@ export default function VoiceAIScreen() {
body: new URLSearchParams({
function: 'voice_ask',
clientId: '001',
user_name: userName,
token: token,
question: enhancedQuestion,
deployment_id: deploymentId,

View File

@ -15,7 +15,7 @@ const PRODUCTS = {
PREMIUM_SUBSCRIPTION: {
name: 'WellNuo Premium',
description: 'AI Julia chat, 90-day history, invite up to 5 family members',
price: 999, // $9.99 in cents
price: 4900, // $49.00 in cents
type: 'recurring',
interval: 'month'
}

View File

@ -209,10 +209,10 @@ router.get('/products', async (req, res) => {
* Customer is tied to BENEFICIARY (not user) so subscription persists when access is transferred
*/
async function getOrCreateStripeCustomer(beneficiaryId) {
// Get beneficiary from DB
// Get beneficiary from DB (new beneficiaries table)
const { data: beneficiary, error } = await supabase
.from('users')
.select('id, email, first_name, last_name, stripe_customer_id')
.from('beneficiaries')
.select('id, name, stripe_customer_id')
.eq('id', beneficiaryId)
.single();
@ -227,8 +227,7 @@ async function getOrCreateStripeCustomer(beneficiaryId) {
// Create new Stripe customer for this beneficiary
const customer = await stripe.customers.create({
email: beneficiary.email,
name: `${beneficiary.first_name || ''} ${beneficiary.last_name || ''}`.trim() || undefined,
name: beneficiary.name || undefined,
metadata: {
beneficiary_id: beneficiary.id.toString(),
type: 'beneficiary'
@ -237,7 +236,7 @@ async function getOrCreateStripeCustomer(beneficiaryId) {
// Save stripe_customer_id to DB
await supabase
.from('users')
.from('beneficiaries')
.update({ stripe_customer_id: customer.id })
.eq('id', beneficiaryId);
@ -354,7 +353,7 @@ router.get('/subscription-status/:beneficiaryId', async (req, res) => {
// Get beneficiary's stripe_customer_id
const { data: beneficiary } = await supabase
.from('users')
.from('beneficiaries')
.select('stripe_customer_id')
.eq('id', beneficiaryId)
.single();
@ -432,7 +431,7 @@ router.post('/cancel-subscription', async (req, res) => {
// Get beneficiary's stripe_customer_id
const { data: beneficiary } = await supabase
.from('users')
.from('beneficiaries')
.select('stripe_customer_id')
.eq('id', beneficiaryId)
.single();
@ -484,7 +483,7 @@ router.post('/reactivate-subscription', async (req, res) => {
}
const { data: beneficiary } = await supabase
.from('users')
.from('beneficiaries')
.select('stripe_customer_id')
.eq('id', beneficiaryId)
.single();
@ -591,6 +590,42 @@ router.post('/create-subscription-payment-sheet', async (req, res) => {
}
});
/**
* POST /api/stripe/confirm-subscription-payment
* Confirms the latest invoice PaymentIntent for a subscription if needed
*/
router.post('/confirm-subscription-payment', async (req, res) => {
try {
const { subscriptionId } = req.body;
if (!subscriptionId) {
return res.status(400).json({ error: 'subscriptionId is required' });
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['latest_invoice.payment_intent']
});
const paymentIntent = subscription.latest_invoice?.payment_intent;
const paymentIntentId = typeof paymentIntent === 'string' ? paymentIntent : paymentIntent?.id;
const paymentIntentStatus = typeof paymentIntent === 'string' ? null : paymentIntent?.status;
if (!paymentIntentId) {
return res.status(400).json({ error: 'Payment intent not found for subscription' });
}
if (paymentIntentStatus === 'requires_confirmation') {
const confirmed = await stripe.paymentIntents.confirm(paymentIntentId);
return res.json({ success: true, status: confirmed.status });
}
return res.json({ success: true, status: paymentIntentStatus || 'unknown' });
} catch (error) {
console.error('Confirm subscription payment error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/stripe/session/:sessionId
* Get checkout session details (for success page)

View File

@ -2,9 +2,6 @@ import { api, setOnUnauthorizedCallback } from '@/services/api';
import type { ApiError, User } from '@/types';
import React, { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
// Test account for development - uses legacy anandk credentials
const DEV_EMAIL = 'serter2069@gmail.com';
interface AuthState {
user: User | null;
isLoading: boolean;
@ -13,7 +10,24 @@ interface AuthState {
error: ApiError | null;
}
// ...
type CheckEmailResult = { exists: boolean; name?: string | null };
type OtpResult = { success: boolean; skipOtp: boolean };
type UserProfileUpdate = Partial<User> & {
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
email?: string;
};
interface AuthContextType extends AuthState {
checkEmail: (email: string) => Promise<CheckEmailResult>;
requestOtp: (email: string) => Promise<OtpResult>;
verifyOtp: (email: string, code: string) => Promise<boolean>;
logout: () => Promise<void>;
clearError: () => void;
refreshAuth: () => Promise<void>;
updateUser: (updates: UserProfileUpdate) => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
@ -30,7 +44,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
console.log('[AuthContext] checkAuth starting...');
checkAuth();
}, []);
}, [checkAuth]);
// Auto-logout when WellNuo API returns 401 (token expired)
// Token now expires after 365 days, so this should rarely happen
@ -49,7 +63,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
}, []);
const checkAuth = async () => {
const checkAuth = useCallback(async () => {
try {
console.log(`[AuthContext] checkAuth: Checking token...`);
const token = await api.getToken();
@ -91,7 +105,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} finally {
console.log(`[AuthContext] checkAuth: Finished`);
}
};
}, []);
const checkEmail = useCallback(async (email: string): Promise<CheckEmailResult> => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
@ -157,23 +171,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
// Dev account - also login to legacy API for dashboard access
if (email.toLowerCase() === DEV_EMAIL.toLowerCase()) {
// Login with legacy API to get dashboard access
const legacyResponse = await api.login('anandk', 'anandk_8');
if (legacyResponse.ok && legacyResponse.data) {
console.log('[AuthContext] Dev mode: Legacy API login successful');
}
}
// Verify OTP via WellNuo API (for all users including dev)
const verifyResponse = await api.verifyOTP(email, code);
if (verifyResponse.ok && verifyResponse.data) {
const user: User = {
user_id: verifyResponse.data.user.id,
user_name: verifyResponse.data.user.first_name || email.split('@')[0],
email: email,
firstName: verifyResponse.data.user.first_name || null,
lastName: verifyResponse.data.user.last_name || null,
max_role: 'USER',
privileges: '',
};
@ -205,6 +211,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
const refreshAuth = useCallback(async () => {
await checkAuth();
}, [checkAuth]);
const updateUser = useCallback((updates: UserProfileUpdate) => {
setState((prev) => {
if (!prev.user) return prev;
return { ...prev, user: { ...prev.user, ...updates } };
});
}, []);
const logout = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
@ -225,7 +242,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
return (
<AuthContext.Provider value={{ ...state, checkEmail, requestOtp, verifyOtp, logout, clearError }}>
<AuthContext.Provider value={{ ...state, checkEmail, requestOtp, verifyOtp, logout, clearError, refreshAuth, updateUser }}>
{children}
</AuthContext.Provider>
);

View File

@ -55,14 +55,6 @@ class ApiService {
}
}
private async getUserName(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('userName');
} catch {
return null;
}
}
// Get legacy API token (for eluxnetworks.net API - dashboard, voice_ask)
private async getLegacyToken(): Promise<string | null> {
try {
@ -72,20 +64,6 @@ class ApiService {
}
}
// Save legacy API credentials (for dev mode with anandk account)
async saveLegacyCredentials(token: string, userName: string): Promise<void> {
await SecureStore.setItemAsync('legacyAccessToken', token);
await SecureStore.setItemAsync('legacyUserName', userName);
}
private async getLegacyUserName(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('legacyUserName');
} catch {
return null;
}
}
private generateNonce(): string {
const randomBytes = Crypto.getRandomBytes(16);
return Array.from(randomBytes)
@ -150,7 +128,7 @@ class ApiService {
async login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
const response = await this.makeRequest<AuthResponse>({
function: 'credentials',
user_name: username,
email: username,
ps: password,
clientId: CLIENT_ID,
nonce: this.generateNonce(),
@ -160,10 +138,8 @@ class ApiService {
// Save LEGACY credentials separately (not to accessToken!)
// accessToken is reserved for WellNuo API JWT tokens
await SecureStore.setItemAsync('legacyAccessToken', response.data.access_token);
await SecureStore.setItemAsync('legacyUserName', username);
// Keep these for backward compatibility
await SecureStore.setItemAsync('userId', response.data.user_id.toString());
await SecureStore.setItemAsync('userName', username);
await SecureStore.setItemAsync('privileges', response.data.privileges);
await SecureStore.setItemAsync('maxRole', response.data.max_role.toString());
}
@ -179,9 +155,6 @@ class ApiService {
await SecureStore.deleteItemAsync('onboardingCompleted');
// Clear legacy API auth data
await SecureStore.deleteItemAsync('legacyAccessToken');
await SecureStore.deleteItemAsync('legacyUserName');
// Legacy cleanup (can be removed later)
await SecureStore.deleteItemAsync('userName');
await SecureStore.deleteItemAsync('privileges');
await SecureStore.deleteItemAsync('maxRole');
}
@ -215,10 +188,9 @@ class ApiService {
}
// Save mock user (for dev mode OTP flow)
async saveMockUser(user: { user_id: string; user_name: string; email: string; max_role: string; privileges: string[] }): Promise<void> {
async saveMockUser(user: { user_id: string; email: string; max_role: string; privileges: string[] }): Promise<void> {
await SecureStore.setItemAsync('accessToken', `mock-token-${user.user_id}`);
await SecureStore.setItemAsync('userId', user.user_id);
await SecureStore.setItemAsync('userName', user.user_name);
await SecureStore.setItemAsync('privileges', user.privileges.join(','));
await SecureStore.setItemAsync('maxRole', user.max_role);
await SecureStore.setItemAsync('userEmail', user.email);
@ -362,7 +334,6 @@ class ApiService {
const email = await SecureStore.getItemAsync('userEmail');
return {
user_id: parseInt(userId, 10),
user_name: email?.split('@')[0] || 'User',
email: email || undefined,
privileges: '',
max_role: 0,
@ -375,7 +346,6 @@ class ApiService {
return {
user_id: userData.id,
user_name: userData.firstName || userData.email?.split('@')[0] || 'User',
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
@ -391,7 +361,6 @@ class ApiService {
if (userId) {
return {
user_id: parseInt(userId, 10),
user_name: email?.split('@')[0] || 'User',
email: email || undefined,
privileges: '',
max_role: 0,
@ -575,13 +544,11 @@ class ApiService {
async getBeneficiaryDashboard(deploymentId: string): Promise<ApiResponse<BeneficiaryDashboardData>> {
// Use legacy API credentials for dashboard
const token = await this.getLegacyToken();
const userName = await this.getLegacyUserName();
if (!token || !userName) {
if (!token) {
// Fallback to regular credentials if legacy not available
const fallbackToken = await this.getToken();
const fallbackUserName = await this.getUserName();
if (!fallbackToken || !fallbackUserName) {
if (!fallbackToken) {
return { ok: false, error: { message: 'Not authenticated for dashboard access', code: 'UNAUTHORIZED' } };
}
// Note: This will likely fail if using WellNuo token, but we try anyway
@ -591,7 +558,6 @@ class ApiService {
const response = await this.makeRequest<DashboardSingleResponse>({
function: 'dashboard_single',
user_name: userName || await this.getUserName() || '',
token: token || await this.getToken() || '',
deployment_id: deploymentId,
date: today,
@ -863,16 +829,14 @@ class ApiService {
}
// Use legacy API credentials for voice_ask
const token = await this.getLegacyToken() || await this.getToken();
const userName = await this.getLegacyUserName() || await this.getUserName();
if (!token || !userName) {
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
return this.makeRequest<ChatResponse>({
function: 'voice_ask',
clientId: CLIENT_ID,
user_name: userName,
token: token,
question: question,
deployment_id: deploymentId,

View File

@ -2,8 +2,10 @@
// User & Auth Types
export interface User {
user_id: number | string;
user_name: string;
email?: string;
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
max_role: number | string;
privileges: string | string[];
}