Sergei 657737e5a4 Add status badges for beneficiaries list
- Monitoring badge: equipment active + subscription active
- Get kit badge: user hasn't ordered equipment yet
- Equipment status badges: ordered, shipped, delivered
- No subscription warning when equipment works but no sub
- Stripe subscription caching in backend (hourly sync)
- BeneficiaryMenu with edit/share/archive/delete actions
2026-01-09 19:49:07 -08:00

629 lines
19 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
ScrollView,
KeyboardAvoidingView,
Platform,
RefreshControl,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useAuth } from '@/contexts/AuthContext';
import { useToast } from '@/components/ui/Toast';
import { api } from '@/services/api';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
} from '@/constants/theme';
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
type Role = 'caretaker' | 'guardian';
// Local beneficiaries have timestamp-based IDs (>1000000000)
const isLocalBeneficiary = (id: string | number): boolean => {
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
return numId > 1000000000;
};
interface Invitation {
id: string;
email: string;
role: string;
label?: string;
status: string;
created_at: string;
}
export default function ShareAccessScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary, localBeneficiaries, updateLocalBeneficiary } = useBeneficiary();
const { user } = useAuth();
const toast = useToast();
const [email, setEmail] = useState('');
const [role, setRole] = useState<Role>('caretaker');
const [isLoading, setIsLoading] = useState(false);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [isLoadingInvitations, setIsLoadingInvitations] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const beneficiaryName = currentBeneficiary?.name || 'this person';
const currentUserEmail = user?.email?.toLowerCase();
// Check if this is a local beneficiary
const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]);
// Get local beneficiary data
const localBeneficiary = useMemo(() => {
if (!isLocal || !id) return null;
return localBeneficiaries.find(b => b.id === parseInt(id, 10));
}, [isLocal, id, localBeneficiaries]);
// Load invitations on mount
useEffect(() => {
if (id) {
loadInvitations();
}
}, [id, isLocal, localBeneficiary]);
const loadInvitations = async () => {
if (!id) return;
// For local beneficiaries, use stored invitations
if (isLocal && localBeneficiary) {
setInvitations(localBeneficiary.invitations || []);
setIsLoadingInvitations(false);
setRefreshing(false);
return;
}
// For API beneficiaries
try {
const response = await api.getInvitations(id);
if (response.ok && response.data) {
setInvitations(response.data.invitations || []);
}
} catch (error) {
console.error('Failed to load invitations:', error);
} finally {
setIsLoadingInvitations(false);
setRefreshing(false);
}
};
const onRefresh = useCallback(() => {
setRefreshing(true);
loadInvitations();
}, [id]);
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleSendInvite = async () => {
const trimmedEmail = email.trim().toLowerCase();
if (!trimmedEmail) {
Alert.alert('Error', 'Please enter an email address.');
return;
}
if (!validateEmail(trimmedEmail)) {
Alert.alert('Error', 'Please enter a valid email address.');
return;
}
// Check if inviting self
if (currentUserEmail && trimmedEmail === currentUserEmail) {
Alert.alert('Error', 'You cannot invite yourself.');
return;
}
// Check if already invited
const alreadyInvited = invitations.some(inv => inv.email.toLowerCase() === trimmedEmail);
if (alreadyInvited) {
Alert.alert('Error', 'This person has already been invited.');
return;
}
setIsLoading(true);
// For local beneficiaries, store invitation locally
if (isLocal && id) {
try {
const newInvitation: Invitation = {
id: `local_${Date.now()}`,
email: trimmedEmail,
role: role,
status: 'pending',
created_at: new Date().toISOString(),
};
const currentInvitations = localBeneficiary?.invitations || [];
await updateLocalBeneficiary(parseInt(id, 10), {
invitations: [...currentInvitations, newInvitation],
});
setEmail('');
setRole('caretaker');
setInvitations([...currentInvitations, newInvitation]);
toast.success('Invitation Sent', `Invitation sent to ${trimmedEmail}`);
} catch (error) {
toast.error('Error', 'Failed to send invitation');
} finally {
setIsLoading(false);
}
return;
}
// For API beneficiaries
try {
const response = await api.sendInvitation({
beneficiaryId: id!,
email: trimmedEmail,
role: role,
});
if (response.ok) {
setEmail('');
setRole('caretaker');
loadInvitations();
toast.success('Success', `Invitation sent to ${trimmedEmail}`);
} else {
Alert.alert('Error', response.error?.message || 'Failed to send invitation');
}
} catch (error) {
console.error('Failed to send invitation:', error);
Alert.alert('Error', 'Failed to send invitation. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleRemoveInvitation = (invitation: Invitation) => {
Alert.alert(
'Remove Access',
`Remove ${invitation.email} from accessing ${beneficiaryName}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
// For local beneficiaries
if (isLocal && id) {
try {
const currentInvitations = localBeneficiary?.invitations || [];
const updatedInvitations = currentInvitations.filter(inv => inv.id !== invitation.id);
await updateLocalBeneficiary(parseInt(id, 10), {
invitations: updatedInvitations,
});
setInvitations(updatedInvitations);
toast.success('Removed', 'Access removed successfully');
} catch (error) {
toast.error('Error', 'Failed to remove access');
}
return;
}
// For API beneficiaries
try {
const response = await api.deleteInvitation(invitation.id);
if (response.ok) {
loadInvitations();
toast.success('Removed', 'Access removed successfully');
} else {
Alert.alert('Error', 'Failed to remove access');
}
} catch (error) {
Alert.alert('Error', 'Failed to remove access');
}
},
},
]
);
};
const getRoleLabel = (role: string): string => {
if (role === 'custodian') return 'Custodian';
if (role === 'guardian') return 'Guardian';
return 'Caretaker';
};
const handleChangeRole = (invitation: Invitation) => {
const newRole = invitation.role === 'caretaker' ? 'guardian' : 'caretaker';
const newRoleLabel = newRole === 'guardian' ? 'Guardian' : 'Caretaker';
Alert.alert(
'Change Role',
`Change ${invitation.email}'s role to ${newRoleLabel}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Change',
onPress: async () => {
// For local beneficiaries
if (isLocal && id) {
try {
const currentInvitations = localBeneficiary?.invitations || [];
const updatedInvitations = currentInvitations.map(inv =>
inv.id === invitation.id ? { ...inv, role: newRole } : inv
);
await updateLocalBeneficiary(parseInt(id, 10), {
invitations: updatedInvitations,
});
setInvitations(updatedInvitations);
toast.success('Updated', `Role changed to ${newRoleLabel}`);
} catch (error) {
toast.error('Error', 'Failed to change role');
}
return;
}
// For API beneficiaries
try {
const response = await api.updateInvitation(invitation.id, newRole as 'caretaker' | 'guardian');
if (response.ok) {
loadInvitations();
toast.success('Updated', `Role changed to ${newRoleLabel}`);
} else {
Alert.alert('Error', response.error?.message || 'Failed to change role');
}
} catch (error) {
Alert.alert('Error', 'Failed to change role');
}
},
},
]
);
};
const getStatusColor = (status: string): string => {
switch (status) {
case 'accepted': return AppColors.success;
case 'pending': return AppColors.warning;
case 'rejected': return AppColors.error;
default: return AppColors.textMuted;
}
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Access</Text>
<BeneficiaryMenu
beneficiaryId={id || ''}
userRole={currentBeneficiary?.role}
currentPage="access"
/>
</View>
<KeyboardAvoidingView
style={styles.content}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{/* Invite Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Invite Someone</Text>
{/* Role Toggle - above email */}
<View style={styles.roleToggle}>
<TouchableOpacity
style={[styles.roleButton, role === 'caretaker' && styles.roleButtonActive]}
onPress={() => setRole('caretaker')}
>
<Text style={[styles.roleButtonText, role === 'caretaker' && styles.roleButtonTextActive]}>
Caretaker
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleButton, role === 'guardian' && styles.roleButtonActive]}
onPress={() => setRole('guardian')}
>
<Text style={[styles.roleButtonText, role === 'guardian' && styles.roleButtonTextActive]}>
Guardian
</Text>
</TouchableOpacity>
</View>
<Text style={styles.roleHint}>
{role === 'caretaker'
? 'Can view activity and chat with Julia'
: 'Full access: edit info, manage subscription'}
</Text>
<View style={[styles.inputRow, { marginTop: Spacing.md }]}>
<TextInput
style={styles.input}
placeholder="Email address"
placeholderTextColor={AppColors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
editable={!isLoading}
/>
<TouchableOpacity
style={[styles.sendButton, isLoading && styles.sendButtonDisabled]}
onPress={handleSendInvite}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color={AppColors.white} />
) : (
<Ionicons name="send" size={18} color={AppColors.white} />
)}
</TouchableOpacity>
</View>
</View>
{/* People with Access */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>People with Access</Text>
{isLoadingInvitations ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={AppColors.primary} />
</View>
) : invitations.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="people-outline" size={32} color={AppColors.textMuted} />
<Text style={styles.emptyText}>No one else has access yet</Text>
</View>
) : (
<View style={styles.invitationsList}>
{invitations.map((invitation) => (
<View key={invitation.id} style={styles.invitationItem}>
<View style={styles.invitationInfo}>
<View style={styles.invitationAvatar}>
<Text style={styles.avatarText}>
{invitation.email.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.invitationDetails}>
<Text style={styles.invitationEmail} numberOfLines={1}>
{invitation.email}
</Text>
<View style={styles.invitationMeta}>
<TouchableOpacity
style={styles.roleChip}
onPress={() => handleChangeRole(invitation)}
>
<Text style={styles.invitationRole}>
{getRoleLabel(invitation.role)}
</Text>
<Ionicons name="chevron-down" size={12} color={AppColors.primary} />
</TouchableOpacity>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(invitation.status) }]} />
<Text style={[styles.invitationStatus, { color: getStatusColor(invitation.status) }]}>
{invitation.status}
</Text>
</View>
</View>
</View>
<TouchableOpacity
style={styles.removeButton}
onPress={() => handleRemoveInvitation(invitation)}
>
<Ionicons name="close" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
</View>
))}
</View>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
content: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
section: {
marginBottom: Spacing.xl,
},
sectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
marginBottom: Spacing.md,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
inputRow: {
flexDirection: 'row',
gap: Spacing.sm,
},
input: {
flex: 1,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
borderWidth: 1,
borderColor: AppColors.border,
},
sendButton: {
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
},
sendButtonDisabled: {
opacity: 0.7,
},
roleToggle: {
flexDirection: 'row',
backgroundColor: AppColors.surfaceSecondary,
borderRadius: BorderRadius.lg,
padding: 4,
marginTop: Spacing.md,
},
roleButton: {
flex: 1,
paddingVertical: Spacing.sm,
alignItems: 'center',
borderRadius: BorderRadius.md,
},
roleButtonActive: {
backgroundColor: AppColors.surface,
},
roleButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
},
roleButtonTextActive: {
color: AppColors.primary,
fontWeight: FontWeights.semibold,
},
roleHint: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: Spacing.sm,
textAlign: 'center',
},
loadingContainer: {
padding: Spacing.xl,
alignItems: 'center',
},
emptyState: {
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
},
emptyText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginTop: Spacing.sm,
},
invitationsList: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
overflow: 'hidden',
},
invitationItem: {
flexDirection: 'row',
alignItems: 'center',
padding: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
invitationInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
invitationAvatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: AppColors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
avatarText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
invitationDetails: {
flex: 1,
},
invitationEmail: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
invitationMeta: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
roleChip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
paddingHorizontal: Spacing.sm,
paddingVertical: 2,
borderRadius: BorderRadius.full,
gap: 2,
},
invitationRole: {
fontSize: FontSizes.xs,
color: AppColors.primary,
fontWeight: FontWeights.medium,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
marginHorizontal: Spacing.xs,
},
invitationStatus: {
fontSize: FontSizes.xs,
textTransform: 'capitalize',
},
removeButton: {
padding: Spacing.xs,
},
});