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 { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
ScrollView,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
} from 'react-native'; } 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 { AppColors, Spacing, BorderRadius, FontSizes, FontWeights, Shadows } from '@/constants/theme';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api'; 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'; const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
@ -22,34 +23,58 @@ const STARTER_KIT = {
name: 'WellNuo Starter Kit', name: 'WellNuo Starter Kit',
price: '$249', price: '$249',
priceValue: 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() { export default function PurchaseScreen() {
// Get lovedOneName from add-loved-one flow
const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>(); const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>();
const lovedOneName = params.lovedOneName || ''; const lovedOneName = params.lovedOneName || '';
const beneficiaryId = params.beneficiaryId; const beneficiaryId = params.beneficiaryId;
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [step, setStep] = useState<'purchase' | 'order_placed'>('purchase'); const [step, setStep] = useState<'purchase' | 'order_placed'>('purchase');
const { user } = useAuth(); const { user } = useAuth();
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet(); 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 () => { const handlePurchase = async () => {
setIsProcessing(true); setIsProcessing(true);
try { try {
// Validate required data before proceeding
const userId = user?.user_id; const userId = user?.user_id;
if (!userId) { if (!userId) {
Alert.alert('Error', 'User not authenticated. Please log in again.'); Alert.alert('Error', 'User not authenticated. Please log in again.');
@ -63,7 +88,6 @@ export default function PurchaseScreen() {
return; return;
} }
// Get auth token
const token = await api.getToken(); const token = await api.getToken();
if (!token) { if (!token) {
Alert.alert('Error', 'Please log in again'); 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); 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`, { const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -82,7 +105,7 @@ export default function PurchaseScreen() {
}, },
body: JSON.stringify({ body: JSON.stringify({
email: user?.email, email: user?.email,
amount: STARTER_KIT.priceValue * 100, // Convert to cents ($249.00) amount: STARTER_KIT.priceValue * 100,
metadata: { metadata: {
userId: String(userId), userId: String(userId),
beneficiaryId: String(beneficiaryId), beneficiaryId: String(beneficiaryId),
@ -98,7 +121,6 @@ export default function PurchaseScreen() {
throw new Error(data.error || 'Failed to create payment sheet'); throw new Error(data.error || 'Failed to create payment sheet');
} }
// 2. Initialize the Payment Sheet
const { error: initError } = await initPaymentSheet({ const { error: initError } = await initPaymentSheet({
merchantDisplayName: 'WellNuo', merchantDisplayName: 'WellNuo',
paymentIntentClientSecret: data.paymentIntent, paymentIntentClientSecret: data.paymentIntent,
@ -121,19 +143,16 @@ export default function PurchaseScreen() {
throw new Error(initError.message); throw new Error(initError.message);
} }
// 3. Present the Payment Sheet
const { error: presentError } = await presentPaymentSheet(); const { error: presentError } = await presentPaymentSheet();
if (presentError) { if (presentError) {
if (presentError.code === 'Canceled') { if (presentError.code === 'Canceled') {
// User cancelled - do nothing
setIsProcessing(false); setIsProcessing(false);
return; return;
} }
throw new Error(presentError.message); throw new Error(presentError.message);
} }
// 4. Payment successful! Update equipment status to 'ordered'
console.log('[Purchase] Payment successful, updating equipment status...'); console.log('[Purchase] Payment successful, updating equipment status...');
const statusResponse = await api.updateBeneficiaryEquipmentStatus( const statusResponse = await api.updateBeneficiaryEquipmentStatus(
parseInt(beneficiaryId, 10), parseInt(beneficiaryId, 10),
@ -142,13 +161,9 @@ export default function PurchaseScreen() {
if (!statusResponse.ok) { if (!statusResponse.ok) {
console.warn('[Purchase] Failed to update equipment status:', statusResponse.error?.message); console.warn('[Purchase] Failed to update equipment status:', statusResponse.error?.message);
// Continue anyway - payment was successful
} }
// Mark onboarding as completed
await api.setOnboardingCompleted(true); await api.setOnboardingCompleted(true);
// Show Order Placed screen
setStep('order_placed'); setStep('order_placed');
} catch (error) { } catch (error) {
console.error('Payment error:', error); console.error('Payment error:', error);
@ -161,8 +176,7 @@ export default function PurchaseScreen() {
setIsProcessing(false); setIsProcessing(false);
}; };
const handleSkip = () => { const handleAlreadyHaveSensors = () => {
// User says "I already have a kit" - go to activate screen with beneficiary ID
router.replace({ router.replace({
pathname: '/(auth)/activate', pathname: '/(auth)/activate',
params: { beneficiaryId, lovedOneName }, 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 // Order Placed Screen
if (step === 'order_placed') { if (step === 'order_placed') {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.orderPlacedContainer}> <View style={styles.orderPlacedContainer}>
{/* Success Icon */}
<View style={styles.successIcon}> <View style={styles.successIcon}>
<Ionicons name="checkmark" size={64} color={AppColors.white} /> <Ionicons name="checkmark" size={64} color={AppColors.white} />
</View> </View>
@ -192,7 +210,6 @@ export default function PurchaseScreen() {
Thank you for your purchase Thank you for your purchase
</Text> </Text>
{/* Order Info */}
<View style={styles.orderInfoCard}> <View style={styles.orderInfoCard}>
<View style={styles.orderInfoRow}> <View style={styles.orderInfoRow}>
<Text style={styles.orderInfoLabel}>Item</Text> <Text style={styles.orderInfoLabel}>Item</Text>
@ -202,61 +219,23 @@ export default function PurchaseScreen() {
<Text style={styles.orderInfoLabel}>For</Text> <Text style={styles.orderInfoLabel}>For</Text>
<Text style={styles.orderInfoValue}>{lovedOneName || 'Your loved one'}</Text> <Text style={styles.orderInfoValue}>{lovedOneName || 'Your loved one'}</Text>
</View> </View>
<View style={styles.orderInfoRow}> <View style={[styles.orderInfoRow, { borderBottomWidth: 0 }]}>
<Text style={styles.orderInfoLabel}>Total</Text> <Text style={styles.orderInfoLabel}>Total</Text>
<Text style={[styles.orderInfoValue, styles.orderInfoPrice]}>{STARTER_KIT.price}</Text> <Text style={[styles.orderInfoValue, styles.orderInfoPrice]}>{STARTER_KIT.price}</Text>
</View> </View>
</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}> <TouchableOpacity style={styles.primaryButton} onPress={handleGoToEquipmentStatus}>
<Text style={styles.primaryButtonText}>Track My Kit</Text> <Text style={styles.primaryButtonText}>Track My Order</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
</SafeAreaView> </SafeAreaView>
); );
} }
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<ScrollView contentContainerStyle={styles.content}> <View style={styles.content}>
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <TouchableOpacity
@ -274,29 +253,20 @@ export default function PurchaseScreen() {
<View style={styles.productIcon}> <View style={styles.productIcon}>
<Ionicons name="hardware-chip" size={48} color={AppColors.primary} /> <Ionicons name="hardware-chip" size={48} color={AppColors.primary} />
</View> </View>
<Text style={styles.productName}>{STARTER_KIT.name}</Text> <Text style={styles.productName}>{STARTER_KIT.name}</Text>
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text> <Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
<Text style={styles.productDescription}>{STARTER_KIT.description}</Text>
{/* Features */} <Text style={styles.productDescription}>
<View style={styles.features}> 4 smart sensors that easily plug into any outlet and set up through the app in minutes
{STARTER_KIT.features.map((feature, index) => ( </Text>
<View key={index} style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
</View>
{/* Security Badge */} {/* Security Badge */}
<View style={styles.securityBadge}> <View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={20} color={AppColors.success} /> <Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
<Text style={styles.securityText}> <Text style={styles.securityText}>Secure payment powered by Stripe</Text>
Secure payment powered by Stripe </View>
</Text>
</View> </View>
</ScrollView>
{/* Bottom Actions */} {/* Bottom Actions */}
<View style={styles.bottomActions}> <View style={styles.bottomActions}>
@ -315,10 +285,11 @@ export default function PurchaseScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}> <TouchableOpacity style={styles.skipButton} onPress={handleAlreadyHaveSensors}>
<Text style={styles.skipButtonText}>I already have a kit</Text> <Text style={styles.skipButtonText}>I already have sensors</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -329,13 +300,14 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
}, },
content: { content: {
flex: 1,
padding: Spacing.lg, padding: Spacing.lg,
justifyContent: 'space-between',
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: Spacing.xl,
}, },
backButton: { backButton: {
padding: Spacing.sm, padding: Spacing.sm,
@ -356,11 +328,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
borderWidth: 2, borderWidth: 2,
borderColor: AppColors.primary, borderColor: AppColors.primary,
shadowColor: '#000', ...Shadows.md,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
}, },
productIcon: { productIcon: {
width: 80, width: 80,
@ -372,45 +340,31 @@ const styles = StyleSheet.create({
marginBottom: Spacing.lg, marginBottom: Spacing.lg,
}, },
productName: { productName: {
fontSize: FontSizes['2xl'], fontSize: FontSizes.xl,
fontWeight: FontWeights.bold, fontWeight: FontWeights.bold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
textAlign: 'center', textAlign: 'center',
marginBottom: Spacing.sm, marginBottom: Spacing.xs,
}, },
productPrice: { productPrice: {
fontSize: FontSizes['3xl'], fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold, fontWeight: FontWeights.bold,
color: AppColors.primary, color: AppColors.primary,
marginBottom: Spacing.sm, marginBottom: Spacing.lg,
}, },
productDescription: { productDescription: {
fontSize: FontSizes.base, fontSize: FontSizes.base,
color: AppColors.textSecondary, color: AppColors.textSecondary,
textAlign: 'center', textAlign: 'center',
marginBottom: Spacing.xl, lineHeight: 22,
}, marginBottom: Spacing.lg,
features: {
width: '100%',
gap: Spacing.md,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
featureText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
flex: 1,
}, },
securityBadge: { securityBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: Spacing.xs,
gap: Spacing.sm, paddingVertical: Spacing.sm,
marginTop: Spacing.xl, paddingHorizontal: Spacing.md,
paddingVertical: Spacing.md,
backgroundColor: `${AppColors.success}10`, backgroundColor: `${AppColors.success}10`,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
}, },
@ -419,8 +373,6 @@ const styles = StyleSheet.create({
color: AppColors.success, color: AppColors.success,
}, },
bottomActions: { bottomActions: {
padding: Spacing.lg,
paddingBottom: Spacing.xl,
gap: Spacing.md, gap: Spacing.md,
}, },
purchaseButton: { purchaseButton: {
@ -431,6 +383,7 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg, paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
...Shadows.primary,
}, },
buttonDisabled: { buttonDisabled: {
opacity: 0.7, opacity: 0.7,
@ -442,14 +395,14 @@ const styles = StyleSheet.create({
}, },
skipButton: { skipButton: {
alignItems: 'center', alignItems: 'center',
paddingVertical: Spacing.md, paddingVertical: Spacing.sm,
}, },
skipButtonText: { skipButtonText: {
fontSize: FontSizes.base, fontSize: FontSizes.base,
color: AppColors.textSecondary, color: AppColors.textSecondary,
textDecorationLine: 'underline', textDecorationLine: 'underline',
}, },
// Order Placed Screen Styles // Order Placed Screen
orderPlacedContainer: { orderPlacedContainer: {
flex: 1, flex: 1,
padding: Spacing.lg, padding: Spacing.lg,
@ -482,7 +435,7 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
padding: Spacing.lg, padding: Spacing.lg,
marginBottom: Spacing.lg, marginBottom: Spacing.xl,
...Shadows.sm, ...Shadows.sm,
}, },
orderInfoRow: { orderInfoRow: {
@ -506,58 +459,8 @@ const styles = StyleSheet.create({
color: AppColors.primary, color: AppColors.primary,
fontWeight: FontWeights.bold, 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: { primaryButton: {
width: '100%',
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg, paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,

View File

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

View File

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

View File

@ -34,14 +34,7 @@ const STARTER_KIT = {
name: 'WellNuo Starter Kit', name: 'WellNuo Starter Kit',
price: '$249', price: '$249',
priceValue: 249, priceValue: 249,
features: [ description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
'Motion sensor (PIR)',
'Door/window sensor',
'Temperature & humidity sensor',
'WellNuo Hub',
'Mobile app access',
'1 year subscription included',
],
}; };
export default function PurchaseScreen() { 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) { if (isLoading) {
return <LoadingSpinner fullScreen message="Loading..." />; return <LoadingSpinner fullScreen message="Loading..." />;
} }
@ -253,15 +224,17 @@ export default function PurchaseScreen() {
<Text style={styles.kitName}>{STARTER_KIT.name}</Text> <Text style={styles.kitName}>{STARTER_KIT.name}</Text>
<Text style={styles.kitPrice}>{STARTER_KIT.price}</Text> <Text style={styles.kitPrice}>{STARTER_KIT.price}</Text>
<View style={styles.features}> <Text style={styles.kitDescription}>{STARTER_KIT.description}</Text>
{STARTER_KIT.features.map((feature, index) => (
<View key={index} style={styles.featureRow}> {/* Security Badge */}
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} /> <View style={styles.securityBadge}>
<Text style={styles.featureText}>{feature}</Text> <Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment by Stripe</Text>
</View> </View>
))}
</View> </View>
{/* Actions */}
<View style={styles.actionsSection}>
<TouchableOpacity <TouchableOpacity
style={[styles.buyButton, isProcessing && styles.buyButtonDisabled]} style={[styles.buyButton, isProcessing && styles.buyButtonDisabled]}
onPress={handlePurchase} onPress={handlePurchase}
@ -277,25 +250,8 @@ export default function PurchaseScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
{/* Security Badge */} <TouchableOpacity style={styles.alreadyHaveButton} onPress={handleAlreadyHaveSensors}>
<View style={styles.securityBadge}> <Text style={styles.alreadyHaveText}>I already have sensors</Text>
<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> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
@ -379,19 +335,14 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
marginVertical: Spacing.md, marginVertical: Spacing.md,
}, },
features: { kitDescription: {
marginBottom: Spacing.lg, fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 22,
}, },
featureRow: { actionsSection: {
flexDirection: 'row', gap: Spacing.md,
alignItems: 'center',
paddingVertical: Spacing.xs,
gap: Spacing.sm,
},
featureText: {
fontSize: FontSizes.sm,
color: AppColors.textPrimary,
flex: 1,
}, },
buyButton: { buyButton: {
flexDirection: 'row', flexDirection: 'row',
@ -421,20 +372,13 @@ const styles = StyleSheet.create({
fontSize: FontSizes.xs, fontSize: FontSizes.xs,
color: AppColors.success, color: AppColors.success,
}, },
alternativeSection: { alreadyHaveButton: {
gap: Spacing.sm,
},
alternativeButton: {
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: AppColors.surface, paddingVertical: Spacing.sm,
padding: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.md,
}, },
alternativeText: { alreadyHaveText: {
flex: 1,
fontSize: FontSizes.base, fontSize: FontSizes.base,
color: AppColors.textPrimary, color: AppColors.textSecondary,
textDecorationLine: 'underline',
}, },
}); });

View File

@ -3,10 +3,10 @@ import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
ScrollView,
TouchableOpacity, TouchableOpacity,
Alert, Alert,
ActivityIndicator, ActivityIndicator,
Modal,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const SUBSCRIPTION_PRICE = 49; // $49/month 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() { export default function SubscriptionScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
@ -47,6 +28,8 @@ export default function SubscriptionScreen() {
const [isCanceling, setIsCanceling] = useState(false); const [isCanceling, setIsCanceling] = useState(false);
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null); const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true); 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 { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { user } = useAuth(); const { user } = useAuth();
@ -80,14 +63,13 @@ export default function SubscriptionScreen() {
// Check if subscription is canceled but still active until period end // Check if subscription is canceled but still active until period end
const isCanceledButActive = isActive && subscription?.cancelAtPeriodEnd === true; 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 // Self-guard: redirect if user shouldn't be on this page
useEffect(() => { useEffect(() => {
if (isLoading || !beneficiary || !id) return; 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 no devices - redirect to purchase (waterfall priority)
if (!hasBeneficiaryDevices(beneficiary)) { if (!hasBeneficiaryDevices(beneficiary)) {
const status = beneficiary.equipmentStatus; const status = beneficiary.equipmentStatus;
@ -103,7 +85,7 @@ export default function SubscriptionScreen() {
if (isActive) { if (isActive) {
router.replace(`/(tabs)/beneficiaries/${id}`); router.replace(`/(tabs)/beneficiaries/${id}`);
} }
}, [beneficiary, isLoading, id, isActive]); }, [beneficiary, isLoading, id, isActive, justSubscribed, showSuccessModal]);
const handleSubscribe = async () => { const handleSubscribe = async () => {
if (!beneficiary) { 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(); const data = await response.json();
// Check if already subscribed // Check if already subscribed
@ -151,11 +146,17 @@ export default function SubscriptionScreen() {
} }
// 2. Initialize the Payment Sheet // 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', merchantDisplayName: 'WellNuo',
paymentIntentClientSecret: data.clientSecret,
customerId: data.customer, 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', returnURL: 'wellnuo://stripe-redirect',
applePay: { applePay: {
merchantCountryCode: 'US', merchantCountryCode: 'US',
@ -164,7 +165,16 @@ export default function SubscriptionScreen() {
merchantCountryCode: 'US', merchantCountryCode: 'US',
testEnv: true, 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) { if (initError) {
throw new Error(initError.message); throw new Error(initError.message);
@ -181,7 +191,26 @@ export default function SubscriptionScreen() {
throw new Error(presentError.message); 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( const statusResponse = await fetch(
`${STRIPE_API_URL}/subscription-status/${beneficiary.id}`, `${STRIPE_API_URL}/subscription-status/${beneficiary.id}`,
{ {
@ -192,14 +221,14 @@ export default function SubscriptionScreen() {
); );
const statusData = await statusResponse.json(); const statusData = await statusResponse.json();
// Mark as just subscribed to prevent self-guard redirect during modal
setJustSubscribed(true);
// Reload beneficiary to get updated subscription // Reload beneficiary to get updated subscription
await loadBeneficiary(); await loadBeneficiary();
Alert.alert( // Show success modal instead of Alert
'Subscription Activated!', setShowSuccessModal(true);
`Subscription for ${beneficiary.name} is now active.`,
[{ text: 'Great!' }]
);
} catch (error) { } catch (error) {
console.error('Payment error:', error); console.error('Payment error:', error);
Alert.alert( 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) => { const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
year: 'numeric', 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) { if (isLoading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
@ -380,98 +403,47 @@ export default function SubscriptionScreen() {
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
<ScrollView showsVerticalScrollIndicator={false}> <View style={styles.content}>
{/* 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>
{/* Subscription Card */} {/* Subscription Card */}
<View style={styles.section}>
<View style={styles.subscriptionCard}> <View style={styles.subscriptionCard}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={styles.proBadge}> <Text style={styles.proBadgeText}>WellNuo Subscription</Text>
<Ionicons name="shield-checkmark" size={20} color={AppColors.primary} />
<Text style={styles.proBadgeText}>WellNuo</Text>
</View>
<View style={styles.priceContainer}> <View style={styles.priceContainer}>
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text> <Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text>
<Text style={styles.pricePeriod}>/month</Text> <Text style={styles.pricePeriod}>/month</Text>
</View> </View>
</View> </View>
<View style={styles.featuresContainer}> <Text style={styles.planDescription}>
<PlanFeature text="Real-time activity monitoring" included={true} /> Full access to real-time monitoring, AI insights, alerts, voice companion, and family sharing for {beneficiary.name}
<PlanFeature text="AI-powered wellness insights" included={true} /> </Text>
<PlanFeature text="Instant alert notifications" included={true} />
<PlanFeature text="Voice AI companion (Julia)" included={true} /> {/* Status indicator */}
<PlanFeature text="Activity history & trends" included={true} /> {isActive && (
<PlanFeature text="Family sharing" included={true} /> <View style={isCanceledButActive ? styles.cancelingBadge : styles.activeBadge}>
<PlanFeature text="24/7 Priority support" included={true} /> <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>
)}
{/* 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> </View>
{/* Show Subscribe button when not active */} {/* Actions */}
<View style={styles.actionsSection}>
{/* Subscribe button when not active */}
{!isActive && ( {!isActive && (
<TouchableOpacity <TouchableOpacity
style={[styles.subscribeButton, isProcessing && styles.buttonDisabled]} style={[styles.subscribeButton, isProcessing && styles.buttonDisabled]}
@ -484,14 +456,14 @@ export default function SubscriptionScreen() {
<> <>
<Ionicons name="card" size={20} color={AppColors.white} /> <Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.subscribeButtonText}> <Text style={styles.subscribeButtonText}>
Subscribe ${SUBSCRIPTION_PRICE}/month Subscribe
</Text> </Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* Show Reactivate button when subscription is canceled but still active */} {/* Reactivate button when canceled but still active */}
{isCanceledButActive && ( {isCanceledButActive && (
<TouchableOpacity <TouchableOpacity
style={[styles.reactivateButton, isProcessing && styles.buttonDisabled]} style={[styles.reactivateButton, isProcessing && styles.buttonDisabled]}
@ -503,32 +475,14 @@ export default function SubscriptionScreen() {
) : ( ) : (
<> <>
<Ionicons name="refresh" size={20} color={AppColors.white} /> <Ionicons name="refresh" size={20} color={AppColors.white} />
<Text style={styles.subscribeButtonText}> <Text style={styles.subscribeButtonText}>Reactivate Subscription</Text>
Reactivate Subscription
</Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> {/* Cancel link - only show when active subscription */}
</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>
{/* Links section */}
<View style={styles.linksSection}>
<TouchableOpacity style={styles.linkButton} onPress={handleRestorePurchases}>
<Text style={styles.linkButtonText}>Restore Purchases</Text>
</TouchableOpacity>
{isActive && !isCanceledButActive && ( {isActive && !isCanceledButActive && (
<>
<Text style={styles.linkDivider}></Text>
<TouchableOpacity <TouchableOpacity
style={styles.linkButton} style={styles.linkButton}
onPress={handleCancelSubscription} onPress={handleCancelSubscription}
@ -537,19 +491,38 @@ export default function SubscriptionScreen() {
{isCanceling ? ( {isCanceling ? (
<ActivityIndicator size="small" color={AppColors.textMuted} /> <ActivityIndicator size="small" color={AppColors.textMuted} />
) : ( ) : (
<Text style={styles.linkButtonTextMuted}>Cancel</Text> <Text style={styles.linkButtonTextMuted}>Cancel Subscription</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</>
)} )}
</View> </View>
</View>
{/* Terms */} {/* Success Modal */}
<Text style={styles.termsText}> <Modal
Payment will be charged to your account at the confirmation of purchase. visible={showSuccessModal}
Subscription can be cancelled at any time from your account settings. 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> </Text>
</ScrollView> <TouchableOpacity
style={styles.modalButton}
onPress={handleSuccessModalClose}
>
<Text style={styles.modalButtonText}>Continue</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -557,7 +530,7 @@ export default function SubscriptionScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: AppColors.surface, backgroundColor: AppColors.background,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -584,7 +557,6 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
// No beneficiary state
noBeneficiaryContainer: { noBeneficiaryContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
@ -612,171 +584,96 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
lineHeight: 24, lineHeight: 24,
}, },
// Beneficiary banner content: {
beneficiaryBanner: { flex: 1,
flexDirection: 'row', padding: Spacing.lg,
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
marginHorizontal: Spacing.lg,
marginTop: Spacing.md,
padding: Spacing.md,
borderRadius: BorderRadius.lg,
},
beneficiaryAvatar: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: AppColors.primary,
justifyContent: 'center', justifyContent: 'center',
alignItems: '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: { subscriptionCard: {
backgroundColor: AppColors.background, backgroundColor: AppColors.surface,
marginHorizontal: Spacing.lg, borderRadius: BorderRadius.xl,
borderRadius: BorderRadius.lg, padding: Spacing.lg,
overflow: 'hidden',
borderWidth: 2, borderWidth: 2,
borderColor: AppColors.primary, borderColor: AppColors.primary,
width: '100%',
...Shadows.md,
}, },
cardHeader: { cardHeader: {
backgroundColor: `${AppColors.primary}10`,
padding: Spacing.lg,
alignItems: 'center', alignItems: 'center',
}, },
proBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
proBadgeText: { proBadgeText: {
fontSize: FontSizes.lg, fontSize: FontSizes.lg,
fontWeight: FontWeights.bold, fontWeight: FontWeights.bold,
color: AppColors.primary, color: AppColors.textPrimary,
}, },
priceContainer: { priceContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'baseline', alignItems: 'baseline',
marginTop: Spacing.md, marginTop: Spacing.sm,
}, },
priceAmount: { priceAmount: {
fontSize: 48, fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold, fontWeight: FontWeights.bold,
color: AppColors.textPrimary, color: AppColors.primary,
}, },
pricePeriod: { pricePeriod: {
fontSize: FontSizes.lg, fontSize: FontSizes.lg,
color: AppColors.textSecondary, color: AppColors.textSecondary,
marginLeft: Spacing.xs, marginLeft: Spacing.xs,
}, },
featuresContainer: { planDescription: {
padding: Spacing.lg, fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 22,
marginTop: Spacing.md,
}, },
featureRow: { activeBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', 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, fontSize: FontSizes.sm,
color: AppColors.textPrimary, fontWeight: FontWeights.medium,
marginLeft: Spacing.sm, color: AppColors.success,
}, },
featureTextDisabled: { cancelingBadge: {
color: AppColors.textMuted, 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: { subscribeButton: {
flexDirection: 'row', flexDirection: 'row',
@ -784,9 +681,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
gap: Spacing.sm, gap: Spacing.sm,
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
marginHorizontal: Spacing.lg, paddingVertical: Spacing.md,
marginBottom: Spacing.lg,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
}, },
buttonDisabled: { buttonDisabled: {
@ -803,52 +698,63 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
gap: Spacing.sm, gap: Spacing.sm,
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
marginHorizontal: Spacing.lg, paddingVertical: Spacing.md,
marginBottom: Spacing.lg,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg, 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: { linkButton: {
paddingVertical: Spacing.xs, paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.xs, alignItems: 'center',
},
linkButtonText: {
fontSize: FontSizes.sm,
color: AppColors.primary,
}, },
linkButtonTextMuted: { linkButtonTextMuted: {
fontSize: FontSizes.sm, fontSize: FontSizes.sm,
color: AppColors.textMuted, color: AppColors.textMuted,
textDecorationLine: 'underline',
}, },
linkDivider: { // Modal styles
fontSize: FontSizes.sm, modalOverlay: {
color: AppColors.textMuted, flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.lg,
}, },
termsText: { modalContent: {
fontSize: FontSizes.xs, backgroundColor: AppColors.surface,
color: AppColors.textMuted, 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', 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, paddingHorizontal: Spacing.xl,
paddingBottom: Spacing.xl, borderRadius: BorderRadius.lg,
lineHeight: 16, 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 = () => { 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]; if (user?.email) return user.email.split('@')[0];
return 'User'; return 'User';
}; };

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ const PRODUCTS = {
PREMIUM_SUBSCRIPTION: { PREMIUM_SUBSCRIPTION: {
name: 'WellNuo Premium', name: 'WellNuo Premium',
description: 'AI Julia chat, 90-day history, invite up to 5 family members', 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', type: 'recurring',
interval: 'month' 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 * Customer is tied to BENEFICIARY (not user) so subscription persists when access is transferred
*/ */
async function getOrCreateStripeCustomer(beneficiaryId) { async function getOrCreateStripeCustomer(beneficiaryId) {
// Get beneficiary from DB // Get beneficiary from DB (new beneficiaries table)
const { data: beneficiary, error } = await supabase const { data: beneficiary, error } = await supabase
.from('users') .from('beneficiaries')
.select('id, email, first_name, last_name, stripe_customer_id') .select('id, name, stripe_customer_id')
.eq('id', beneficiaryId) .eq('id', beneficiaryId)
.single(); .single();
@ -227,8 +227,7 @@ async function getOrCreateStripeCustomer(beneficiaryId) {
// Create new Stripe customer for this beneficiary // Create new Stripe customer for this beneficiary
const customer = await stripe.customers.create({ const customer = await stripe.customers.create({
email: beneficiary.email, name: beneficiary.name || undefined,
name: `${beneficiary.first_name || ''} ${beneficiary.last_name || ''}`.trim() || undefined,
metadata: { metadata: {
beneficiary_id: beneficiary.id.toString(), beneficiary_id: beneficiary.id.toString(),
type: 'beneficiary' type: 'beneficiary'
@ -237,7 +236,7 @@ async function getOrCreateStripeCustomer(beneficiaryId) {
// Save stripe_customer_id to DB // Save stripe_customer_id to DB
await supabase await supabase
.from('users') .from('beneficiaries')
.update({ stripe_customer_id: customer.id }) .update({ stripe_customer_id: customer.id })
.eq('id', beneficiaryId); .eq('id', beneficiaryId);
@ -354,7 +353,7 @@ router.get('/subscription-status/:beneficiaryId', async (req, res) => {
// Get beneficiary's stripe_customer_id // Get beneficiary's stripe_customer_id
const { data: beneficiary } = await supabase const { data: beneficiary } = await supabase
.from('users') .from('beneficiaries')
.select('stripe_customer_id') .select('stripe_customer_id')
.eq('id', beneficiaryId) .eq('id', beneficiaryId)
.single(); .single();
@ -432,7 +431,7 @@ router.post('/cancel-subscription', async (req, res) => {
// Get beneficiary's stripe_customer_id // Get beneficiary's stripe_customer_id
const { data: beneficiary } = await supabase const { data: beneficiary } = await supabase
.from('users') .from('beneficiaries')
.select('stripe_customer_id') .select('stripe_customer_id')
.eq('id', beneficiaryId) .eq('id', beneficiaryId)
.single(); .single();
@ -484,7 +483,7 @@ router.post('/reactivate-subscription', async (req, res) => {
} }
const { data: beneficiary } = await supabase const { data: beneficiary } = await supabase
.from('users') .from('beneficiaries')
.select('stripe_customer_id') .select('stripe_customer_id')
.eq('id', beneficiaryId) .eq('id', beneficiaryId)
.single(); .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 /api/stripe/session/:sessionId
* Get checkout session details (for success page) * 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 type { ApiError, User } from '@/types';
import React, { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; 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 { interface AuthState {
user: User | null; user: User | null;
isLoading: boolean; isLoading: boolean;
@ -13,7 +10,24 @@ interface AuthState {
error: ApiError | null; 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); const AuthContext = createContext<AuthContextType | null>(null);
@ -30,7 +44,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
console.log('[AuthContext] checkAuth starting...'); console.log('[AuthContext] checkAuth starting...');
checkAuth(); checkAuth();
}, []); }, [checkAuth]);
// Auto-logout when WellNuo API returns 401 (token expired) // Auto-logout when WellNuo API returns 401 (token expired)
// Token now expires after 365 days, so this should rarely happen // 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 { try {
console.log(`[AuthContext] checkAuth: Checking token...`); console.log(`[AuthContext] checkAuth: Checking token...`);
const token = await api.getToken(); const token = await api.getToken();
@ -91,7 +105,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} finally { } finally {
console.log(`[AuthContext] checkAuth: Finished`); console.log(`[AuthContext] checkAuth: Finished`);
} }
}; }, []);
const checkEmail = useCallback(async (email: string): Promise<CheckEmailResult> => { const checkEmail = useCallback(async (email: string): Promise<CheckEmailResult> => {
setState((prev) => ({ ...prev, isLoading: true, error: null })); setState((prev) => ({ ...prev, isLoading: true, error: null }));
@ -157,23 +171,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setState((prev) => ({ ...prev, isLoading: true, error: null })); setState((prev) => ({ ...prev, isLoading: true, error: null }));
try { 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) // Verify OTP via WellNuo API (for all users including dev)
const verifyResponse = await api.verifyOTP(email, code); const verifyResponse = await api.verifyOTP(email, code);
if (verifyResponse.ok && verifyResponse.data) { if (verifyResponse.ok && verifyResponse.data) {
const user: User = { const user: User = {
user_id: verifyResponse.data.user.id, user_id: verifyResponse.data.user.id,
user_name: verifyResponse.data.user.first_name || email.split('@')[0],
email: email, email: email,
firstName: verifyResponse.data.user.first_name || null,
lastName: verifyResponse.data.user.last_name || null,
max_role: 'USER', max_role: 'USER',
privileges: '', 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 () => { const logout = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true })); setState((prev) => ({ ...prev, isLoading: true }));
@ -225,7 +242,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ ...state, checkEmail, requestOtp, verifyOtp, logout, clearError }}> <AuthContext.Provider value={{ ...state, checkEmail, requestOtp, verifyOtp, logout, clearError, refreshAuth, updateUser }}>
{children} {children}
</AuthContext.Provider> </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) // Get legacy API token (for eluxnetworks.net API - dashboard, voice_ask)
private async getLegacyToken(): Promise<string | null> { private async getLegacyToken(): Promise<string | null> {
try { 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 { private generateNonce(): string {
const randomBytes = Crypto.getRandomBytes(16); const randomBytes = Crypto.getRandomBytes(16);
return Array.from(randomBytes) return Array.from(randomBytes)
@ -150,7 +128,7 @@ class ApiService {
async login(username: string, password: string): Promise<ApiResponse<AuthResponse>> { async login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
const response = await this.makeRequest<AuthResponse>({ const response = await this.makeRequest<AuthResponse>({
function: 'credentials', function: 'credentials',
user_name: username, email: username,
ps: password, ps: password,
clientId: CLIENT_ID, clientId: CLIENT_ID,
nonce: this.generateNonce(), nonce: this.generateNonce(),
@ -160,10 +138,8 @@ class ApiService {
// Save LEGACY credentials separately (not to accessToken!) // Save LEGACY credentials separately (not to accessToken!)
// accessToken is reserved for WellNuo API JWT tokens // accessToken is reserved for WellNuo API JWT tokens
await SecureStore.setItemAsync('legacyAccessToken', response.data.access_token); await SecureStore.setItemAsync('legacyAccessToken', response.data.access_token);
await SecureStore.setItemAsync('legacyUserName', username);
// Keep these for backward compatibility // Keep these for backward compatibility
await SecureStore.setItemAsync('userId', response.data.user_id.toString()); await SecureStore.setItemAsync('userId', response.data.user_id.toString());
await SecureStore.setItemAsync('userName', username);
await SecureStore.setItemAsync('privileges', response.data.privileges); await SecureStore.setItemAsync('privileges', response.data.privileges);
await SecureStore.setItemAsync('maxRole', response.data.max_role.toString()); await SecureStore.setItemAsync('maxRole', response.data.max_role.toString());
} }
@ -179,9 +155,6 @@ class ApiService {
await SecureStore.deleteItemAsync('onboardingCompleted'); await SecureStore.deleteItemAsync('onboardingCompleted');
// Clear legacy API auth data // Clear legacy API auth data
await SecureStore.deleteItemAsync('legacyAccessToken'); 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('privileges');
await SecureStore.deleteItemAsync('maxRole'); await SecureStore.deleteItemAsync('maxRole');
} }
@ -215,10 +188,9 @@ class ApiService {
} }
// Save mock user (for dev mode OTP flow) // 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('accessToken', `mock-token-${user.user_id}`);
await SecureStore.setItemAsync('userId', 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('privileges', user.privileges.join(','));
await SecureStore.setItemAsync('maxRole', user.max_role); await SecureStore.setItemAsync('maxRole', user.max_role);
await SecureStore.setItemAsync('userEmail', user.email); await SecureStore.setItemAsync('userEmail', user.email);
@ -362,7 +334,6 @@ class ApiService {
const email = await SecureStore.getItemAsync('userEmail'); const email = await SecureStore.getItemAsync('userEmail');
return { return {
user_id: parseInt(userId, 10), user_id: parseInt(userId, 10),
user_name: email?.split('@')[0] || 'User',
email: email || undefined, email: email || undefined,
privileges: '', privileges: '',
max_role: 0, max_role: 0,
@ -375,7 +346,6 @@ class ApiService {
return { return {
user_id: userData.id, user_id: userData.id,
user_name: userData.firstName || userData.email?.split('@')[0] || 'User',
email: userData.email, email: userData.email,
firstName: userData.firstName, firstName: userData.firstName,
lastName: userData.lastName, lastName: userData.lastName,
@ -391,7 +361,6 @@ class ApiService {
if (userId) { if (userId) {
return { return {
user_id: parseInt(userId, 10), user_id: parseInt(userId, 10),
user_name: email?.split('@')[0] || 'User',
email: email || undefined, email: email || undefined,
privileges: '', privileges: '',
max_role: 0, max_role: 0,
@ -575,13 +544,11 @@ class ApiService {
async getBeneficiaryDashboard(deploymentId: string): Promise<ApiResponse<BeneficiaryDashboardData>> { async getBeneficiaryDashboard(deploymentId: string): Promise<ApiResponse<BeneficiaryDashboardData>> {
// Use legacy API credentials for dashboard // Use legacy API credentials for dashboard
const token = await this.getLegacyToken(); const token = await this.getLegacyToken();
const userName = await this.getLegacyUserName();
if (!token || !userName) { if (!token) {
// Fallback to regular credentials if legacy not available // Fallback to regular credentials if legacy not available
const fallbackToken = await this.getToken(); const fallbackToken = await this.getToken();
const fallbackUserName = await this.getUserName(); if (!fallbackToken) {
if (!fallbackToken || !fallbackUserName) {
return { ok: false, error: { message: 'Not authenticated for dashboard access', code: 'UNAUTHORIZED' } }; 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 // 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>({ const response = await this.makeRequest<DashboardSingleResponse>({
function: 'dashboard_single', function: 'dashboard_single',
user_name: userName || await this.getUserName() || '',
token: token || await this.getToken() || '', token: token || await this.getToken() || '',
deployment_id: deploymentId, deployment_id: deploymentId,
date: today, date: today,
@ -863,16 +829,14 @@ class ApiService {
} }
// Use legacy API credentials for voice_ask // Use legacy API credentials for voice_ask
const token = await this.getLegacyToken() || await this.getToken(); 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 { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
} }
return this.makeRequest<ChatResponse>({ return this.makeRequest<ChatResponse>({
function: 'voice_ask', function: 'voice_ask',
clientId: CLIENT_ID, clientId: CLIENT_ID,
user_name: userName,
token: token, token: token,
question: question, question: question,
deployment_id: deploymentId, deployment_id: deploymentId,

View File

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