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:
parent
fe4ff1a932
commit
06802c237b
@ -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,61 +219,23 @@ 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>
|
||||
<Text style={styles.primaryButtonText}>Track My Order</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<View style={styles.content}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
@ -274,29 +253,20 @@ 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>
|
||||
))}
|
||||
</View>
|
||||
</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={20} color={AppColors.success} />
|
||||
<Text style={styles.securityText}>
|
||||
Secure payment powered by Stripe
|
||||
</Text>
|
||||
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
|
||||
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<View style={styles.bottomActions}>
|
||||
@ -315,10 +285,11 @@ export default function PurchaseScreen() {
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
||||
<Text style={styles.skipButtonText}>I already have a kit</Text>
|
||||
<TouchableOpacity style={styles.skipButton} onPress={handleAlreadyHaveSensors}>
|
||||
<Text style={styles.skipButtonText}>I already have sensors</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,98 +403,47 @@ 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Show Subscribe button when not active */}
|
||||
{/* Actions */}
|
||||
<View style={styles.actionsSection}>
|
||||
{/* Subscribe button when not active */}
|
||||
{!isActive && (
|
||||
<TouchableOpacity
|
||||
style={[styles.subscribeButton, isProcessing && styles.buttonDisabled]}
|
||||
@ -484,14 +456,14 @@ export default function SubscriptionScreen() {
|
||||
<>
|
||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||
<Text style={styles.subscribeButtonText}>
|
||||
Subscribe — ${SUBSCRIPTION_PRICE}/month
|
||||
Subscribe
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Show Reactivate button when subscription is canceled but still active */}
|
||||
{/* Reactivate button when canceled but still active */}
|
||||
{isCanceledButActive && (
|
||||
<TouchableOpacity
|
||||
style={[styles.reactivateButton, isProcessing && styles.buttonDisabled]}
|
||||
@ -503,32 +475,14 @@ export default function SubscriptionScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="refresh" size={20} color={AppColors.white} />
|
||||
<Text style={styles.subscribeButtonText}>
|
||||
Reactivate Subscription
|
||||
</Text>
|
||||
<Text style={styles.subscribeButtonText}>Reactivate Subscription</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Secure Payment Badge */}
|
||||
<View style={styles.securityBadge}>
|
||||
<Ionicons name="lock-closed" size={16} color={AppColors.success} />
|
||||
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
|
||||
</View>
|
||||
|
||||
{/* Links section */}
|
||||
<View style={styles.linksSection}>
|
||||
<TouchableOpacity style={styles.linkButton} onPress={handleRestorePurchases}>
|
||||
<Text style={styles.linkButtonText}>Restore Purchases</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Cancel link - only show when active subscription */}
|
||||
{isActive && !isCanceledButActive && (
|
||||
<>
|
||||
<Text style={styles.linkDivider}>•</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.linkButton}
|
||||
onPress={handleCancelSubscription}
|
||||
@ -537,19 +491,38 @@ export default function SubscriptionScreen() {
|
||||
{isCanceling ? (
|
||||
<ActivityIndicator size="small" color={AppColors.textMuted} />
|
||||
) : (
|
||||
<Text style={styles.linkButtonTextMuted}>Cancel</Text>
|
||||
<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.
|
||||
{/* 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>
|
||||
</ScrollView>
|
||||
<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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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';
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user