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 {
|
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,53 +219,15 @@ 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 */}
|
<TouchableOpacity style={styles.primaryButton} onPress={handleGoToEquipmentStatus}>
|
||||||
<View style={styles.whatsNextCard}>
|
<Text style={styles.primaryButtonText}>Track My Order</Text>
|
||||||
<Text style={styles.whatsNextTitle}>What's Next?</Text>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.stepItem}>
|
|
||||||
<View style={styles.stepNumber}>
|
|
||||||
<Text style={styles.stepNumberText}>1</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.stepContent}>
|
|
||||||
<Text style={styles.stepTitle}>Order Processing</Text>
|
|
||||||
<Text style={styles.stepDescription}>We'll prepare your kit for shipping</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.stepItem}>
|
|
||||||
<View style={styles.stepNumber}>
|
|
||||||
<Text style={styles.stepNumberText}>2</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.stepContent}>
|
|
||||||
<Text style={styles.stepTitle}>Shipping Notification</Text>
|
|
||||||
<Text style={styles.stepDescription}>You'll receive an email with tracking info</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.stepItem}>
|
|
||||||
<View style={[styles.stepNumber, styles.stepNumberLast]}>
|
|
||||||
<Text style={styles.stepNumberText}>3</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.stepContent}>
|
|
||||||
<Text style={styles.stepTitle}>Activate Your Kit</Text>
|
|
||||||
<Text style={styles.stepDescription}>Enter the serial number when delivered</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<View style={styles.orderPlacedActions}>
|
|
||||||
<TouchableOpacity style={styles.primaryButton} onPress={handleGoToEquipmentStatus}>
|
|
||||||
<Text style={styles.primaryButtonText}>Track My Kit</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@ -256,7 +235,7 @@ export default function PurchaseScreen() {
|
|||||||
|
|
||||||
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,50 +253,42 @@ 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} />
|
{/* Security Badge */}
|
||||||
<Text style={styles.featureText}>{feature}</Text>
|
<View style={styles.securityBadge}>
|
||||||
</View>
|
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
|
||||||
))}
|
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Security Badge */}
|
{/* Bottom Actions */}
|
||||||
<View style={styles.securityBadge}>
|
<View style={styles.bottomActions}>
|
||||||
<Ionicons name="shield-checkmark" size={20} color={AppColors.success} />
|
<TouchableOpacity
|
||||||
<Text style={styles.securityText}>
|
style={[styles.purchaseButton, isProcessing && styles.buttonDisabled]}
|
||||||
Secure payment powered by Stripe
|
onPress={handlePurchase}
|
||||||
</Text>
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<ActivityIndicator color={AppColors.white} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||||
|
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.skipButton} onPress={handleAlreadyHaveSensors}>
|
||||||
|
<Text style={styles.skipButtonText}>I already have sensors</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
|
||||||
<View style={styles.bottomActions}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.purchaseButton, isProcessing && styles.buttonDisabled]}
|
|
||||||
onPress={handlePurchase}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<ActivityIndicator color={AppColors.white} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
|
||||||
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
|
||||||
<Text style={styles.skipButtonText}>I already have a kit</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
|
||||||
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
|
|
||||||
<Text style={styles.featureText}>{feature}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</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>
|
||||||
|
|
||||||
|
{/* 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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,176 +403,126 @@ 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}>
|
<Text style={styles.proBadgeText}>WellNuo Subscription</Text>
|
||||||
<View style={styles.proBadge}>
|
<View style={styles.priceContainer}>
|
||||||
<Ionicons name="shield-checkmark" size={20} color={AppColors.primary} />
|
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</Text>
|
||||||
<Text style={styles.proBadgeText}>WellNuo</Text>
|
<Text style={styles.pricePeriod}>/month</Text>
|
||||||
</View>
|
|
||||||
<View style={styles.priceContainer}>
|
|
||||||
<Text style={styles.priceAmount}>${SUBSCRIPTION_PRICE}</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>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show Subscribe button when not active */}
|
{/* Security Badge */}
|
||||||
{!isActive && (
|
<View style={styles.securityBadge}>
|
||||||
<TouchableOpacity
|
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
|
||||||
style={[styles.subscribeButton, isProcessing && styles.buttonDisabled]}
|
<Text style={styles.securityText}>Secure payment by Stripe</Text>
|
||||||
onPress={handleSubscribe}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<ActivityIndicator color={AppColors.white} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
|
||||||
<Text style={styles.subscribeButtonText}>
|
|
||||||
Subscribe — ${SUBSCRIPTION_PRICE}/month
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show Reactivate button when subscription is canceled but still active */}
|
|
||||||
{isCanceledButActive && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.reactivateButton, isProcessing && styles.buttonDisabled]}
|
|
||||||
onPress={handleReactivateSubscription}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<ActivityIndicator color={AppColors.white} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ionicons name="refresh" size={20} color={AppColors.white} />
|
|
||||||
<Text style={styles.subscribeButtonText}>
|
|
||||||
Reactivate Subscription
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Secure Payment Badge */}
|
{/* Actions */}
|
||||||
<View style={styles.securityBadge}>
|
<View style={styles.actionsSection}>
|
||||||
<Ionicons name="lock-closed" size={16} color={AppColors.success} />
|
{/* Subscribe button when not active */}
|
||||||
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
|
{!isActive && (
|
||||||
</View>
|
<TouchableOpacity
|
||||||
|
style={[styles.subscribeButton, isProcessing && styles.buttonDisabled]}
|
||||||
|
onPress={handleSubscribe}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<ActivityIndicator color={AppColors.white} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||||
|
<Text style={styles.subscribeButtonText}>
|
||||||
|
Subscribe
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Links section */}
|
{/* Reactivate button when canceled but still active */}
|
||||||
<View style={styles.linksSection}>
|
{isCanceledButActive && (
|
||||||
<TouchableOpacity style={styles.linkButton} onPress={handleRestorePurchases}>
|
<TouchableOpacity
|
||||||
<Text style={styles.linkButtonText}>Restore Purchases</Text>
|
style={[styles.reactivateButton, isProcessing && styles.buttonDisabled]}
|
||||||
</TouchableOpacity>
|
onPress={handleReactivateSubscription}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<ActivityIndicator color={AppColors.white} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="refresh" size={20} color={AppColors.white} />
|
||||||
|
<Text style={styles.subscribeButtonText}>Reactivate Subscription</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel link - only show when active subscription */}
|
||||||
{isActive && !isCanceledButActive && (
|
{isActive && !isCanceledButActive && (
|
||||||
<>
|
<TouchableOpacity
|
||||||
<Text style={styles.linkDivider}>•</Text>
|
style={styles.linkButton}
|
||||||
<TouchableOpacity
|
onPress={handleCancelSubscription}
|
||||||
style={styles.linkButton}
|
disabled={isCanceling}
|
||||||
onPress={handleCancelSubscription}
|
>
|
||||||
disabled={isCanceling}
|
{isCanceling ? (
|
||||||
>
|
<ActivityIndicator size="small" color={AppColors.textMuted} />
|
||||||
{isCanceling ? (
|
) : (
|
||||||
<ActivityIndicator size="small" color={AppColors.textMuted} />
|
<Text style={styles.linkButtonTextMuted}>Cancel Subscription</Text>
|
||||||
) : (
|
)}
|
||||||
<Text style={styles.linkButtonTextMuted}>Cancel</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}
|
||||||
</Text>
|
animationType="fade"
|
||||||
</ScrollView>
|
onRequestClose={handleSuccessModalClose}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalIconContainer}>
|
||||||
|
<Ionicons name="checkmark-circle" size={64} color={AppColors.success} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Subscription Activated!</Text>
|
||||||
|
<Text style={styles.modalMessage}>
|
||||||
|
Subscription for {beneficiary?.name} is now active.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalButton}
|
||||||
|
onPress={handleSuccessModalClose}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalButtonText}>Continue</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</SafeAreaView>
|
</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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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';
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user