489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
import React, { useState, useEffect, useCallback } 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 { api } from '@/services/api';
|
|
import {
|
|
AppColors,
|
|
BorderRadius,
|
|
FontSizes,
|
|
FontWeights,
|
|
Spacing,
|
|
} from '@/constants/theme';
|
|
|
|
type Role = 'caretaker' | 'guardian';
|
|
|
|
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 } = useBeneficiary();
|
|
const { user } = useAuth();
|
|
|
|
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();
|
|
|
|
// Load invitations on mount
|
|
useEffect(() => {
|
|
if (id) {
|
|
loadInvitations();
|
|
}
|
|
}, [id]);
|
|
|
|
const loadInvitations = async () => {
|
|
if (!id) return;
|
|
|
|
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);
|
|
|
|
try {
|
|
const response = await api.sendInvitation({
|
|
beneficiaryId: id!,
|
|
email: trimmedEmail,
|
|
role: role,
|
|
});
|
|
|
|
if (response.ok) {
|
|
setEmail('');
|
|
setRole('caretaker');
|
|
loadInvitations();
|
|
Alert.alert('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 () => {
|
|
try {
|
|
const response = await api.deleteInvitation(invitation.id);
|
|
if (response.ok) {
|
|
loadInvitations();
|
|
} else {
|
|
Alert.alert('Error', 'Failed to remove access');
|
|
}
|
|
} catch (error) {
|
|
Alert.alert('Error', 'Failed to remove access');
|
|
}
|
|
},
|
|
},
|
|
]
|
|
);
|
|
};
|
|
|
|
const getRoleLabel = (role: string): string => {
|
|
if (role === 'owner' || role === 'guardian') return 'Guardian';
|
|
return 'Caretaker';
|
|
};
|
|
|
|
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>
|
|
<View style={styles.placeholder} />
|
|
</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>
|
|
|
|
<View style={styles.inputRow}>
|
|
<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>
|
|
|
|
{/* Role Toggle */}
|
|
<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>
|
|
|
|
{/* 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}>
|
|
<Text style={styles.invitationRole}>
|
|
{getRoleLabel(invitation.role)}
|
|
</Text>
|
|
<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,
|
|
},
|
|
placeholder: {
|
|
width: 32,
|
|
},
|
|
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,
|
|
},
|
|
invitationRole: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
statusDot: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
marginHorizontal: Spacing.xs,
|
|
},
|
|
invitationStatus: {
|
|
fontSize: FontSizes.xs,
|
|
textTransform: 'capitalize',
|
|
},
|
|
removeButton: {
|
|
padding: Spacing.xs,
|
|
},
|
|
});
|