Update beneficiaries layout and screens

This commit is contained in:
Sergei 2026-01-01 13:21:25 -08:00
parent 7186f29f35
commit ad35dac850
4 changed files with 434 additions and 1072 deletions

View File

@ -60,10 +60,22 @@ export default function ActivateScreen() {
try {
// If we have an existing beneficiary, update them with device info
if (existingBeneficiaryId && existingBeneficiary) {
await updateLocalBeneficiary(existingBeneficiaryId, {
// Prepare update data
const updateData: any = {
hasDevices: true,
device_id: code,
});
equipmentStatus: 'active', // Clear awaiting state - sensors now active
};
// If beneficiary has pending subscription (from kit purchase), activate it
if (existingBeneficiary.subscription?.status === 'pending') {
updateData.subscription = {
...existingBeneficiary.subscription,
status: 'active',
};
}
await updateLocalBeneficiary(existingBeneficiaryId, updateData);
setBeneficiaryName(existingBeneficiary.name);
setStep('complete');
} else {

View File

@ -1,864 +0,0 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity, Modal, TextInput, Image, ScrollView, KeyboardAvoidingView, Platform, Alert, Animated } from 'react-native';
import { WebView } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router, useFocusEffect } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import * as ImagePicker from 'expo-image-picker';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { api } from '@/services/api';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows, AvatarSizes } from '@/constants/theme';
import { FullScreenError } from '@/components/ui/ErrorMessage';
import { useToast } from '@/components/ui/Toast';
import MockDashboard from '@/components/MockDashboard';
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
import type { Beneficiary } from '@/types';
// Dashboard URL with beneficiary ID (deployment_id)
const getDashboardUrl = (deploymentId: string) =>
`https://react.eluxnetworks.net/dashboard/${deploymentId}`;
// Local beneficiaries have timestamp-based IDs (>1000000000)
// Real deployments have small IDs (21, 38, 29, etc.)
const isLocalBeneficiary = (id: string | number): boolean => {
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
return numId > 1000000000;
};
export default function BeneficiaryDashboardScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary, setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary } = useBeneficiary();
const toast = useToast();
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [canGoBack, setCanGoBack] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [isTokenLoaded, setIsTokenLoaded] = useState(false);
const [isMenuVisible, setIsMenuVisible] = useState(false);
// Edit modal state
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [editForm, setEditForm] = useState({
name: '',
address: '',
avatar: '' as string | undefined,
});
const fadeAnim = useRef(new Animated.Value(0)).current;
// Beneficiary data for subscription check
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isBeneficiaryLoading, setIsBeneficiaryLoading] = useState(true);
// Check if this is a local (mock) beneficiary
const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]);
// Check subscription status
const hasActiveSubscription = useMemo(() => {
if (!beneficiary) return false;
const subscription = beneficiary.subscription;
return subscription && subscription.status === 'active';
}, [beneficiary]);
// Load beneficiary data to check subscription
const loadBeneficiary = useCallback(async () => {
if (!id) return;
setIsBeneficiaryLoading(true);
try {
if (isLocal) {
const localBeneficiary = localBeneficiaries.find(
(b) => b.id === parseInt(id, 10)
);
if (localBeneficiary) {
setBeneficiary(localBeneficiary);
}
} else {
const response = await api.getBeneficiary(parseInt(id, 10));
if (response.ok && response.data) {
setBeneficiary(response.data);
}
}
} catch (err) {
console.error('Failed to load beneficiary:', err);
} finally {
setIsBeneficiaryLoading(false);
}
}, [id, isLocal, localBeneficiaries]);
useEffect(() => {
loadBeneficiary();
}, [loadBeneficiary]);
// Edit modal animation
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: isEditModalVisible ? 1 : 0,
duration: 250,
useNativeDriver: true,
}).start();
}, [isEditModalVisible]);
// Hide menu when navigating away from page
useFocusEffect(
useCallback(() => {
return () => {
setIsMenuVisible(false);
setIsEditModalVisible(false);
};
}, [])
);
const handleEditPress = () => {
if (beneficiary) {
setEditForm({
name: beneficiary.name || '',
address: beneficiary.address || '',
avatar: beneficiary.avatar,
});
setIsEditModalVisible(true);
}
};
const handlePickAvatar = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission needed', 'Please allow access to your photo library.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.5,
});
if (!result.canceled && result.assets[0]) {
setEditForm(prev => ({ ...prev, avatar: result.assets[0].uri }));
}
};
const handleSaveEdit = async () => {
if (!editForm.name.trim()) {
toast.error('Error', 'Name is required');
return;
}
if (isLocal && id) {
const updated = await updateLocalBeneficiary(parseInt(id, 10), {
name: editForm.name.trim(),
address: editForm.address.trim() || undefined,
avatar: editForm.avatar,
});
if (updated) {
setBeneficiary(updated);
setCurrentBeneficiary(updated);
setIsEditModalVisible(false);
toast.success('Profile Updated', 'Changes saved successfully');
} else {
toast.error('Error', 'Failed to save changes.');
}
} else {
// For API beneficiaries - would call backend here
toast.info('Coming Soon', 'Editing requires backend API.');
setIsEditModalVisible(false);
}
};
// Build dashboard URL with beneficiary ID
const dashboardUrl = id ? getDashboardUrl(id) : 'https://react.eluxnetworks.net/dashboard';
const beneficiaryName = currentBeneficiary?.name || 'Dashboard';
// Load token, username, and userId from SecureStore
useEffect(() => {
const loadCredentials = async () => {
try {
const token = await SecureStore.getItemAsync('accessToken');
const user = await SecureStore.getItemAsync('userName');
const uid = await SecureStore.getItemAsync('userId');
setAuthToken(token);
setUserName(user);
setUserId(uid);
console.log('Loaded credentials for WebView:', { hasToken: !!token, user, uid });
} catch (err) {
console.error('Failed to load credentials:', err);
} finally {
setIsTokenLoaded(true);
}
};
loadCredentials();
}, []);
// JavaScript to inject token into localStorage before page loads
// Web app uses auth2 key with JSON object: {username, token, user_id}
const injectedJavaScript = authToken
? `
(function() {
try {
// Web app expects auth2 as JSON object with these exact fields
var authData = {
username: '${userName || ''}',
token: '${authToken}',
user_id: ${userId || 'null'}
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Auth data injected:', authData.username, 'user_id:', authData.user_id);
} catch(e) {
console.error('Failed to inject token:', e);
}
})();
true;
`
: '';
const handleRefresh = () => {
setError(null);
setIsLoading(true);
webViewRef.current?.reload();
};
const handleWebViewBack = () => {
if (canGoBack) {
webViewRef.current?.goBack();
}
};
const handleNavigationStateChange = (navState: any) => {
setCanGoBack(navState.canGoBack);
};
const handleError = () => {
setError('Failed to load dashboard. Please check your internet connection.');
setIsLoading(false);
};
const handleGoBack = () => {
router.back();
};
// Wait for beneficiary data and token to load
if (isBeneficiaryLoading || (!isTokenLoaded && !isLocal)) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Preparing dashboard...</Text>
</View>
</SafeAreaView>
);
}
// NO SUBSCRIPTION - Show payment screen with Stripe integration
if (!hasActiveSubscription && beneficiary) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
<View style={styles.placeholder} />
</View>
<SubscriptionPayment
beneficiary={beneficiary}
onSuccess={() => loadBeneficiary()}
/>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
<View style={styles.placeholder} />
</View>
<FullScreenError message={error} onRetry={handleRefresh} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<View style={styles.headerCenter}>
{currentBeneficiary && (
currentBeneficiary.avatar ? (
<Image
source={{ uri: currentBeneficiary.avatar }}
style={styles.avatarSmallImage}
/>
) : (
<View style={styles.avatarSmall}>
<Text style={styles.avatarText}>
{currentBeneficiary.name.charAt(0).toUpperCase()}
</Text>
</View>
)
)}
<View>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
{currentBeneficiary?.relationship && (
<Text style={styles.headerSubtitle}>{currentBeneficiary.relationship}</Text>
)}
</View>
</View>
<View style={styles.headerActions}>
{/* WebView navigation only for real beneficiaries */}
{!isLocal && canGoBack && (
<TouchableOpacity style={styles.actionButton} onPress={handleWebViewBack}>
<Ionicons name="chevron-back" size={22} color={AppColors.primary} />
</TouchableOpacity>
)}
{!isLocal && (
<TouchableOpacity style={styles.actionButton} onPress={handleRefresh}>
<Ionicons name="refresh" size={22} color={AppColors.primary} />
</TouchableOpacity>
)}
{/* Menu button */}
<TouchableOpacity style={styles.menuButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
{/* Dropdown Menu */}
{isMenuVisible && (
<View style={styles.dropdownMenu}>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
handleEditPress();
}}
>
<Ionicons name="create-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/share`);
}}
>
<Ionicons name="people-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Access</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/equipment`);
}}
>
<Ionicons name="hardware-chip-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Equipment</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
{/* Backdrop to close menu */}
{isMenuVisible && (
<TouchableOpacity
style={styles.menuBackdrop}
activeOpacity={1}
onPress={() => setIsMenuVisible(false)}
/>
)}
{/* Dashboard Content - Mock for local, WebView for real */}
{isLocal ? (
<MockDashboard beneficiaryName={beneficiaryName} />
) : (
<View style={styles.webViewContainer}>
<WebView
ref={webViewRef}
source={{ uri: dashboardUrl }}
style={styles.webView}
onLoadStart={() => setIsLoading(true)}
onLoadEnd={() => setIsLoading(false)}
onError={handleError}
onHttpError={handleError}
onNavigationStateChange={handleNavigationStateChange}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
scalesPageToFit={true}
allowsBackForwardNavigationGestures={true}
// Inject token into localStorage BEFORE content loads
injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
// Also inject after load in case page reads localStorage late
injectedJavaScript={injectedJavaScript}
renderLoading={() => (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Loading dashboard...</Text>
</View>
)}
/>
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={AppColors.primary} />
</View>
)}
</View>
)}
{/* Edit Modal */}
<Modal
visible={isEditModalVisible}
transparent
animationType="none"
onRequestClose={() => setIsEditModalVisible(false)}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<Animated.View style={[styles.modalOverlay, { opacity: fadeAnim }]}>
<TouchableOpacity
style={styles.modalBackdrop}
activeOpacity={1}
onPress={() => setIsEditModalVisible(false)}
/>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Edit Profile</Text>
<TouchableOpacity
style={styles.modalCloseButton}
onPress={() => setIsEditModalVisible(false)}
>
<Ionicons name="close" size={24} color={AppColors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Avatar Picker */}
<TouchableOpacity style={styles.avatarPicker} onPress={handlePickAvatar}>
{editForm.avatar ? (
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
) : (
<View style={styles.avatarPickerPlaceholder}>
<Text style={styles.avatarPickerLetter}>
{editForm.name.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
<View style={styles.avatarPickerBadge}>
<Ionicons name="camera" size={16} color={AppColors.white} />
</View>
</TouchableOpacity>
{/* Name Field */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Name *</Text>
<TextInput
style={styles.textInput}
value={editForm.name}
onChangeText={(text) => setEditForm(prev => ({ ...prev, name: text }))}
placeholder="Enter name"
placeholderTextColor={AppColors.textMuted}
/>
</View>
{/* Address Field */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Address</Text>
<TextInput
style={[styles.textInput, styles.textInputMultiline]}
value={editForm.address}
onChangeText={(text) => setEditForm(prev => ({ ...prev, address: text }))}
placeholder="Enter address (optional)"
placeholderTextColor={AppColors.textMuted}
multiline
numberOfLines={2}
/>
</View>
</ScrollView>
{/* Action Buttons */}
<View style={styles.modalActions}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => setIsEditModalVisible(false)}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.saveButton}
onPress={handleSaveEdit}
>
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
</View>
</Animated.View>
</KeyboardAvoidingView>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: AppColors.background,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
zIndex: 1001,
},
backButton: {
padding: Spacing.xs,
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginLeft: Spacing.sm,
},
avatarSmall: {
width: 36,
height: 36,
borderRadius: BorderRadius.full,
backgroundColor: AppColors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
avatarSmallImage: {
width: 36,
height: 36,
borderRadius: 18,
marginRight: Spacing.sm,
},
avatarText: {
fontSize: FontSizes.base,
fontWeight: '600',
color: AppColors.white,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: '700',
color: AppColors.textPrimary,
},
headerSubtitle: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
position: 'relative',
},
actionButton: {
padding: Spacing.xs,
marginLeft: Spacing.xs,
},
menuButton: {
padding: Spacing.xs,
marginLeft: Spacing.sm,
},
placeholder: {
width: 32,
},
// Dropdown Menu
dropdownMenu: {
position: 'absolute',
top: 40,
right: 0,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
minWidth: 160,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
zIndex: 1000,
},
dropdownItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
gap: Spacing.md,
},
dropdownItemText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
menuBackdrop: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999,
},
webViewContainer: {
flex: 1,
},
webView: {
flex: 1,
},
loadingContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.background,
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.8)',
},
loadingText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
// No Subscription Styles
noSubscriptionContainer: {
flex: 1,
padding: Spacing.xl,
alignItems: 'center',
justifyContent: 'center',
},
noSubIconContainer: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: AppColors.accentLight,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.lg,
},
noSubTitle: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
},
noSubSubtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 24,
marginBottom: Spacing.xl,
paddingHorizontal: Spacing.md,
},
noSubPriceCard: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.xl,
...Shadows.sm,
},
noSubPriceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.md,
},
noSubPlanName: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
noSubPlanDesc: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
noSubPriceBadge: {
flexDirection: 'row',
alignItems: 'baseline',
},
noSubPriceAmount: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
noSubPriceUnit: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginLeft: 2,
},
noSubFeatures: {
gap: Spacing.sm,
},
noSubFeatureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
noSubFeatureText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
// Edit Modal Styles
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
modalContent: {
backgroundColor: AppColors.surface,
borderTopLeftRadius: BorderRadius['2xl'],
borderTopRightRadius: BorderRadius['2xl'],
padding: Spacing.lg,
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.lg,
},
modalTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
modalCloseButton: {
padding: Spacing.xs,
},
avatarPicker: {
alignSelf: 'center',
marginBottom: Spacing.xl,
position: 'relative',
},
avatarPickerImage: {
width: AvatarSizes.xl,
height: AvatarSizes.xl,
borderRadius: AvatarSizes.xl / 2,
},
avatarPickerPlaceholder: {
width: AvatarSizes.xl,
height: AvatarSizes.xl,
borderRadius: AvatarSizes.xl / 2,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
},
avatarPickerLetter: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
avatarPickerBadge: {
position: 'absolute',
bottom: 0,
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: AppColors.surface,
},
inputGroup: {
marginBottom: Spacing.lg,
},
inputLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
marginBottom: Spacing.sm,
},
textInput: {
backgroundColor: AppColors.background,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
borderWidth: 1,
borderColor: AppColors.border,
},
textInputMultiline: {
minHeight: 80,
textAlignVertical: 'top',
},
modalActions: {
flexDirection: 'row',
gap: Spacing.md,
marginTop: Spacing.lg,
},
cancelButton: {
flex: 1,
backgroundColor: AppColors.surfaceSecondary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
},
cancelButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
},
saveButton: {
flex: 1,
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
},
saveButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
});

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
Text,
@ -14,7 +14,10 @@ import {
Platform,
Animated,
ActivityIndicator,
Switch,
} from 'react-native';
import { WebView } from 'react-native-webview';
import * as SecureStore from 'expo-secure-store';
import { useLocalSearchParams, router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
@ -49,29 +52,126 @@ const isLocalBeneficiary = (id: string | number): boolean => {
// Setup state types
type SetupState = 'loading' | 'awaiting_equipment' | 'no_devices' | 'no_subscription' | 'ready';
// Stripe API
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
// WebView Dashboard URL - uses test NDK account for demo data
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
const TEST_NDK_DEPLOYMENT_ID = '1'; // anandk test deployment with real sensor data
// Starter Kit info
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
price: '$249',
priceValue: 249,
features: [
'Motion sensor (PIR)',
'Door/window sensor',
'Temperature & humidity sensor',
'WellNuo Hub',
'Mobile app access',
'1 year subscription included',
],
};
// No Devices Screen Component - Primary: Buy Kit, Secondary: I have sensors
// No Devices Screen Component - Primary: Buy Kit with Stripe, Secondary: I have sensors
function NoDevicesScreen({
beneficiary,
beneficiaryId,
onActivate,
onGetSensors
onPurchaseSuccess,
userEmail,
userId,
}: {
beneficiary: Beneficiary;
beneficiaryId: string;
onActivate: () => void;
onGetSensors: () => void;
onPurchaseSuccess: () => void;
userEmail?: string;
userId?: string;
}) {
const [isProcessing, setIsProcessing] = useState(false);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const toast = useToast();
const handlePurchase = async () => {
setIsProcessing(true);
try {
// 1. Create Payment Sheet on server
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: userEmail || 'guest@wellnuo.com',
amount: STARTER_KIT.priceValue * 100, // Convert to cents ($249.00)
metadata: {
userId: userId || 'guest',
beneficiaryName: beneficiary.name,
beneficiaryId: beneficiaryId,
},
}),
});
const data = await response.json();
if (!data.paymentIntent) {
throw new Error(data.error || 'Failed to create payment sheet');
}
// 2. Initialize the Payment Sheet
const { error: initError } = await initPaymentSheet({
merchantDisplayName: 'WellNuo',
paymentIntentClientSecret: data.paymentIntent,
customerId: data.customer,
customerEphemeralKeySecret: data.ephemeralKey,
defaultBillingDetails: {
email: userEmail || '',
},
returnURL: 'wellnuo://stripe-redirect',
applePay: {
merchantCountryCode: 'US',
},
googlePay: {
merchantCountryCode: 'US',
testEnv: true,
},
});
if (initError) {
throw new Error(initError.message);
}
// 3. Present the Payment Sheet
const { error: presentError } = await presentPaymentSheet();
if (presentError) {
if (presentError.code === 'Canceled') {
// User cancelled - do nothing
setIsProcessing(false);
return;
}
throw new Error(presentError.message);
}
// 4. Payment successful!
toast.success('Order Placed!', 'Your WellNuo Starter Kit is on its way.');
onPurchaseSuccess();
} catch (error) {
console.error('Payment error:', error);
toast.error(
'Payment Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
}
setIsProcessing(false);
};
return (
<ScrollView contentContainerStyle={styles.setupScrollContent}>
<View style={styles.setupContainer}>
<View style={styles.setupIconContainer}>
<Ionicons name="hardware-chip-outline" size={48} color={AppColors.primary} />
@ -95,10 +195,26 @@ function NoDevicesScreen({
))}
</View>
<TouchableOpacity style={styles.buyKitButton} onPress={onGetSensors}>
<Ionicons name="cart" size={20} color={AppColors.white} />
<TouchableOpacity
style={[styles.buyKitButton, isProcessing && styles.buyKitButtonDisabled]}
onPress={handlePurchase}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.buyKitButtonText}>Buy Now - {STARTER_KIT.price}</Text>
</>
)}
</TouchableOpacity>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment by Stripe</Text>
</View>
</View>
{/* Secondary: I already have sensors */}
@ -107,6 +223,7 @@ function NoDevicesScreen({
<Ionicons name="arrow-forward" size={16} color={AppColors.textSecondary} />
</TouchableOpacity>
</View>
</ScrollView>
);
}
@ -241,12 +358,20 @@ function AwaitingEquipmentScreen({
export default function BeneficiaryDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary();
const { user } = useAuth();
const toast = useToast();
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Developer toggle for WebView
const [showWebView, setShowWebView] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const webViewRef = useRef<WebView>(null);
// Check if this is a local beneficiary
const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]);
@ -256,17 +381,25 @@ export default function BeneficiaryDetailScreen() {
if (isLoading) return 'loading';
if (!beneficiary) return 'loading';
// Check if awaiting equipment (ordered, shipped, or delivered but not activated)
const equipmentStatus = beneficiary.equipmentStatus;
if (equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus)) {
return 'awaiting_equipment';
}
// Check if has devices - required first step
// Check if has devices - used in multiple places
const hasDevices = beneficiary.hasDevices ||
(beneficiary.devices && beneficiary.devices.length > 0) ||
beneficiary.device_id;
// Check equipment status
const equipmentStatus = beneficiary.equipmentStatus;
// If equipment is ordered/shipped/delivered but NOT yet activated (no devices)
// show awaiting equipment screen
if (equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus)) {
// But if user already has devices (activated), skip to next step
if (!hasDevices) {
return 'awaiting_equipment';
}
// Has devices = already activated, continue to subscription check
}
// No devices and no equipment ordered = show purchase screen
if (!hasDevices) return 'no_devices';
// Check subscription - required after devices connected
@ -339,6 +472,23 @@ export default function BeneficiaryDetailScreen() {
loadBeneficiary();
}, [loadBeneficiary]);
// Load credentials for WebView
useEffect(() => {
const loadCredentials = async () => {
try {
const token = await SecureStore.getItemAsync('accessToken');
const user = await SecureStore.getItemAsync('userName');
const uid = await SecureStore.getItemAsync('userId');
setAuthToken(token);
setUserName(user);
setUserId(uid);
} catch (err) {
console.error('Failed to load credentials:', err);
}
};
loadCredentials();
}, []);
// Sync beneficiary data when localBeneficiaries changes (especially after avatar update)
useEffect(() => {
if (isLocal && id && beneficiary) {
@ -361,12 +511,26 @@ export default function BeneficiaryDetailScreen() {
});
};
const handleGetSensors = () => {
// Navigate to purchase screen with beneficiary info
router.push({
pathname: '/(auth)/purchase',
params: { beneficiaryId: id!, lovedOneName: beneficiary?.name },
const handlePurchaseSuccess = async () => {
// Update beneficiary with ordered status and subscription (kit includes 1 year)
if (id && isLocal) {
// Calculate subscription end date (1 year from now)
const subscriptionEnd = new Date();
subscriptionEnd.setFullYear(subscriptionEnd.getFullYear() + 1);
await updateLocalBeneficiary(parseInt(id, 10), {
equipmentStatus: 'ordered',
// Kit includes 1 year subscription
subscription: {
status: 'pending', // Will activate when sensors are connected
plan: 'yearly',
startDate: new Date().toISOString(),
endDate: subscriptionEnd.toISOString(),
},
});
}
// Reload beneficiary to show new state
loadBeneficiary(false);
};
const handleMarkReceived = async () => {
@ -454,6 +618,26 @@ export default function BeneficiaryDetailScreen() {
toast.info('Coming Soon', `${featureName} is currently in development.`);
};
// JavaScript to inject token into localStorage for WebView
const injectedJavaScript = authToken
? `
(function() {
try {
var authData = {
username: '${userName || ''}',
token: '${authToken}',
user_id: ${userId || 'null'}
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Auth data injected:', authData.username);
} catch(e) {
console.error('Failed to inject token:', e);
}
})();
true;
`
: '';
const handleDeleteBeneficiary = () => {
if (!isLocal || !id) return;
@ -509,8 +693,11 @@ export default function BeneficiaryDetailScreen() {
return (
<NoDevicesScreen
beneficiary={beneficiary}
beneficiaryId={id!}
onActivate={handleActivateSensors}
onGetSensors={handleGetSensors}
onPurchaseSuccess={handlePurchaseSuccess}
userEmail={user?.email}
userId={user?.user_id}
/>
);
@ -524,6 +711,51 @@ export default function BeneficiaryDetailScreen() {
case 'ready':
default:
// WebView mode - uses test NDK deployment for demo data
if (showWebView) {
const webViewUrl = `${DASHBOARD_URL}?deployment_id=${TEST_NDK_DEPLOYMENT_ID}`;
return (
<View style={styles.webViewContainer}>
<WebView
ref={webViewRef}
source={{ uri: webViewUrl }}
style={styles.webView}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
allowsBackForwardNavigationGestures={true}
injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
injectedJavaScript={injectedJavaScript}
renderLoading={() => (
<View style={styles.webViewLoading}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.webViewLoadingText}>Loading dashboard...</Text>
</View>
)}
/>
{/* Developer Toggle - always visible to switch back */}
<View style={styles.webViewToggleOverlay}>
<View style={styles.devToggleCard}>
<View style={styles.devToggleLeft}>
<Ionicons name="code-slash" size={20} color={AppColors.warning} />
<View>
<Text style={styles.devToggleLabel}>Developer Mode</Text>
<Text style={styles.devToggleHint}>Show WebView dashboard</Text>
</View>
</View>
<Switch
value={showWebView}
onValueChange={setShowWebView}
trackColor={{ false: AppColors.border, true: AppColors.primaryLight }}
thumbColor={showWebView ? AppColors.primary : AppColors.textMuted}
/>
</View>
</View>
</View>
);
}
// Native mode
return (
<ScrollView
style={styles.scrollView}
@ -537,164 +769,22 @@ export default function BeneficiaryDetailScreen() {
/>
}
>
{/* Profile Card */}
<View style={styles.profileCard}>
<View style={styles.avatarWrapper}>
{beneficiary.avatar ? (
<Image source={{ uri: beneficiary.avatar }} style={styles.avatarImage} />
) : (
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{beneficiary.name.charAt(0).toUpperCase()}
</Text>
</View>
)}
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
</View>
<Text style={styles.beneficiaryName}>{beneficiary.name}</Text>
{beneficiary.address && (
<View style={styles.locationRow}>
<Ionicons name="location" size={14} color={AppColors.textMuted} />
<Text style={styles.locationText}>{beneficiary.address}</Text>
</View>
)}
<View style={styles.statusBadge}>
<View style={[styles.statusIndicator, { backgroundColor: statusColor }]} />
<Text style={styles.statusText}>
{beneficiary.status === 'online' ? 'Online now' : 'Offline'}
</Text>
</View>
{beneficiary.last_activity && (
<Text style={styles.lastActivity}>
Last activity: {beneficiary.last_activity}
</Text>
)}
</View>
{/* Quick Actions */}
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.actionsRow}>
<TouchableOpacity
style={styles.actionButton}
onPress={() => {
setCurrentBeneficiary(beneficiary);
router.push('/(tabs)/chat');
}}
>
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.accentLight }]}>
<Ionicons name="chatbubbles" size={22} color={AppColors.accent} />
</View>
<Text style={styles.actionButtonText}>Chat with Julia</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionButton}
onPress={() => router.push(`/(tabs)/beneficiaries/${id}/equipment`)}
>
<View style={[styles.actionButtonIcon, { backgroundColor: AppColors.primaryLighter }]}>
<Ionicons name="hardware-chip" size={22} color={AppColors.primary} />
</View>
<Text style={styles.actionButtonText}>Equipment</Text>
</TouchableOpacity>
</View>
{/* Subscription Section */}
<Text style={styles.sectionTitle}>Subscription</Text>
<View style={styles.subscriptionCard}>
<View style={styles.subscriptionHeader}>
<View style={styles.subscriptionBadgeContainer}>
<View style={styles.subscriptionIconBg}>
<Ionicons name="diamond" size={18} color={AppColors.accent} />
</View>
{/* Developer Toggle for WebView */}
<View style={styles.devToggleCard}>
<View style={styles.devToggleLeft}>
<Ionicons name="code-slash" size={20} color={AppColors.warning} />
<View>
<Text style={styles.subscriptionPlan}>WellNuo Pro</Text>
<Text style={styles.subscriptionStatus}>Active</Text>
<Text style={styles.devToggleLabel}>Developer Mode</Text>
<Text style={styles.devToggleHint}>Show WebView dashboard</Text>
</View>
</View>
<View style={styles.priceBadge}>
<Text style={styles.priceText}>$49</Text>
<Text style={styles.priceUnit}>/mo</Text>
<Switch
value={showWebView}
onValueChange={setShowWebView}
trackColor={{ false: AppColors.border, true: AppColors.primaryLight }}
thumbColor={showWebView ? AppColors.primary : AppColors.textMuted}
/>
</View>
</View>
<View style={styles.subscriptionDivider} />
<View style={styles.subscriptionFeatures}>
<View style={styles.featureItem}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureText}>24/7 AI monitoring</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureText}>Unlimited chat with Julia</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.featureText}>Activity reports</Text>
</View>
</View>
<TouchableOpacity
style={styles.manageButton}
onPress={() => showComingSoon('Subscription Management')}
>
<Text style={styles.manageButtonText}>Manage Subscription</Text>
<Ionicons name="chevron-forward" size={18} color={AppColors.primary} />
</TouchableOpacity>
</View>
{/* Settings */}
<Text style={styles.sectionTitle}>Settings</Text>
<View style={styles.settingsCard}>
<TouchableOpacity style={styles.settingsRow} onPress={() => showComingSoon('Notification Settings')}>
<View style={styles.settingsRowLeft}>
<View style={[styles.settingsIcon, { backgroundColor: AppColors.primarySubtle }]}>
<Ionicons name="notifications-outline" size={20} color={AppColors.primary} />
</View>
<Text style={styles.settingsRowText}>Notifications</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
<View style={styles.settingsDivider} />
<TouchableOpacity style={styles.settingsRow} onPress={() => showComingSoon('Alert Rules')}>
<View style={styles.settingsRowLeft}>
<View style={[styles.settingsIcon, { backgroundColor: AppColors.warningLight }]}>
<Ionicons name="alert-circle-outline" size={20} color={AppColors.warning} />
</View>
<Text style={styles.settingsRowText}>Alert Rules</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
<View style={styles.settingsDivider} />
<TouchableOpacity style={styles.settingsRow} onPress={() => showComingSoon('Connected Sensors')}>
<View style={styles.settingsRowLeft}>
<View style={[styles.settingsIcon, { backgroundColor: AppColors.infoLight }]}>
<Ionicons name="hardware-chip-outline" size={20} color={AppColors.info} />
</View>
<Text style={styles.settingsRowText}>Connected Sensors</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
</View>
{/* Danger Zone - only for local beneficiaries */}
{isLocal && (
<>
<Text style={[styles.sectionTitle, styles.dangerTitle]}>Danger Zone</Text>
<TouchableOpacity style={styles.dangerButton} onPress={handleDeleteBeneficiary}>
<Ionicons name="trash-outline" size={20} color={AppColors.error} />
<Text style={styles.dangerButtonText}>Remove Beneficiary</Text>
</TouchableOpacity>
</>
)}
{/* Activity Dashboard */}
<MockDashboard beneficiaryName={beneficiary.name} />
@ -710,7 +800,24 @@ export default function BeneficiaryDetailScreen() {
<TouchableOpacity style={styles.headerButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
{/* Avatar + Name */}
<View style={styles.headerCenter}>
<View style={styles.headerAvatarWrapper}>
{beneficiary.avatar ? (
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
) : (
<View style={styles.headerAvatar}>
<Text style={styles.headerAvatarText}>
{beneficiary.name.charAt(0).toUpperCase()}
</Text>
</View>
)}
<View style={[styles.headerStatusDot, { backgroundColor: statusColor }]} />
</View>
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
</View>
<View>
<TouchableOpacity style={styles.headerButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
@ -926,6 +1033,98 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: Spacing.md,
gap: Spacing.sm,
},
headerAvatarWrapper: {
position: 'relative',
},
headerAvatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
},
headerAvatarImage: {
width: 36,
height: 36,
borderRadius: 18,
},
headerAvatarText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.bold,
color: AppColors.white,
},
headerStatusDot: {
position: 'absolute',
bottom: 0,
right: 0,
width: 12,
height: 12,
borderRadius: 6,
borderWidth: 2,
borderColor: AppColors.background,
},
// Developer Toggle
devToggleCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: AppColors.warningLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.lg,
borderWidth: 1,
borderColor: AppColors.warning,
},
devToggleLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
devToggleLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
devToggleHint: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
},
// WebView
webViewContainer: {
flex: 1,
},
webViewToggleOverlay: {
position: 'absolute',
bottom: 100, // Above tab bar
left: Spacing.md,
right: Spacing.md,
},
webView: {
flex: 1,
},
webViewLoading: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.background,
},
webViewLoadingText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
scrollView: {
flex: 1,
},
@ -934,11 +1133,13 @@ const styles = StyleSheet.create({
paddingBottom: Spacing.xxl,
},
// Setup Screens
setupScrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
setupContainer: {
flex: 1,
padding: Spacing.xl,
alignItems: 'center',
justifyContent: 'center',
},
setupIconContainer: {
width: 96,
@ -1057,6 +1258,20 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
buyKitButtonDisabled: {
opacity: 0.7,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.xs,
marginTop: Spacing.md,
},
securityText: {
fontSize: FontSizes.xs,
color: AppColors.success,
},
alreadyHaveLink: {
flexDirection: 'row',
alignItems: 'center',

View File

@ -10,7 +10,6 @@ export default function BeneficiariesLayout() {
}}
>
<Stack.Screen name="[id]/index" />
<Stack.Screen name="[id]/dashboard" />
</Stack>
);
}