@sudobility/entity-components-rn 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1509 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +63 -0
- package/src/EntityCard.tsx +137 -0
- package/src/EntityList.tsx +96 -0
- package/src/EntitySelector.tsx +200 -0
- package/src/InvitationForm.tsx +163 -0
- package/src/InvitationList.tsx +240 -0
- package/src/MemberList.tsx +254 -0
- package/src/MemberRoleSelector.tsx +174 -0
- package/src/__tests__/EntityCard.test.tsx +103 -0
- package/src/__tests__/InvitationForm.test.tsx +153 -0
- package/src/__tests__/MemberRoleSelector.test.tsx +125 -0
- package/src/index.ts +29 -0
- package/src/nativewind.d.ts +27 -0
- package/src/types.ts +226 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View, Text, Pressable, Modal, FlatList, Image } from 'react-native';
|
|
3
|
+
import type { EntitySelectorProps, Entity } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* EntitySelector - Dropdown/Pressable selector for switching entities
|
|
7
|
+
*/
|
|
8
|
+
export const EntitySelector: React.FC<EntitySelectorProps> = ({
|
|
9
|
+
entities,
|
|
10
|
+
selectedEntity,
|
|
11
|
+
onSelect,
|
|
12
|
+
placeholder = 'Select an entity',
|
|
13
|
+
disabled = false,
|
|
14
|
+
loading = false,
|
|
15
|
+
showAvatars = true,
|
|
16
|
+
showRoles = false,
|
|
17
|
+
className = '',
|
|
18
|
+
style,
|
|
19
|
+
testID,
|
|
20
|
+
}) => {
|
|
21
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
22
|
+
|
|
23
|
+
const handleOpen = () => {
|
|
24
|
+
if (!disabled && !loading) {
|
|
25
|
+
setIsOpen(true);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleClose = () => {
|
|
30
|
+
setIsOpen(false);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleSelect = (entity: Entity) => {
|
|
34
|
+
onSelect(entity);
|
|
35
|
+
handleClose();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const renderAvatar = (entity: Entity, size: 'sm' | 'md' = 'md') => {
|
|
39
|
+
const sizeClasses = size === 'sm' ? 'w-8 h-8' : 'w-10 h-10';
|
|
40
|
+
const textSize = size === 'sm' ? 'text-sm' : 'text-base';
|
|
41
|
+
|
|
42
|
+
if (entity.avatarUrl) {
|
|
43
|
+
return (
|
|
44
|
+
<Image
|
|
45
|
+
source={{ uri: entity.avatarUrl }}
|
|
46
|
+
className={`${sizeClasses} rounded-full bg-gray-200 dark:bg-gray-600`}
|
|
47
|
+
accessibilityIgnoresInvertColors
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View
|
|
54
|
+
className={`${sizeClasses} rounded-full bg-gray-200 dark:bg-gray-600 items-center justify-center`}
|
|
55
|
+
>
|
|
56
|
+
<Text
|
|
57
|
+
className={`${textSize} font-semibold text-gray-600 dark:text-gray-300`}
|
|
58
|
+
>
|
|
59
|
+
{entity.name.charAt(0).toUpperCase()}
|
|
60
|
+
</Text>
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const renderItem = ({ item }: { item: Entity }) => (
|
|
66
|
+
<Pressable
|
|
67
|
+
onPress={() => handleSelect(item)}
|
|
68
|
+
className={`
|
|
69
|
+
flex-row items-center px-4 py-3
|
|
70
|
+
${selectedEntity?.id === item.id ? 'bg-blue-50 dark:bg-blue-900/30' : ''}
|
|
71
|
+
active:bg-gray-100 dark:active:bg-gray-700
|
|
72
|
+
`}
|
|
73
|
+
accessibilityRole='button'
|
|
74
|
+
accessibilityLabel={`Select ${item.name}`}
|
|
75
|
+
accessibilityState={{ selected: selectedEntity?.id === item.id }}
|
|
76
|
+
>
|
|
77
|
+
{showAvatars && <View className='mr-3'>{renderAvatar(item, 'sm')}</View>}
|
|
78
|
+
<View className='flex-1'>
|
|
79
|
+
<Text
|
|
80
|
+
className='text-base text-gray-900 dark:text-white'
|
|
81
|
+
numberOfLines={1}
|
|
82
|
+
>
|
|
83
|
+
{item.name}
|
|
84
|
+
</Text>
|
|
85
|
+
{showRoles && item.role && (
|
|
86
|
+
<Text className='text-xs text-gray-500 dark:text-gray-400 capitalize'>
|
|
87
|
+
{item.role}
|
|
88
|
+
</Text>
|
|
89
|
+
)}
|
|
90
|
+
</View>
|
|
91
|
+
{selectedEntity?.id === item.id && (
|
|
92
|
+
<Text className='text-blue-500 dark:text-blue-400 text-lg'>✓</Text>
|
|
93
|
+
)}
|
|
94
|
+
</Pressable>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<View className={className} style={style} testID={testID}>
|
|
99
|
+
{/* Trigger Button */}
|
|
100
|
+
<Pressable
|
|
101
|
+
onPress={handleOpen}
|
|
102
|
+
disabled={disabled || loading}
|
|
103
|
+
className={`
|
|
104
|
+
flex-row items-center px-4 py-3 rounded-xl
|
|
105
|
+
bg-white dark:bg-gray-800
|
|
106
|
+
border border-gray-200 dark:border-gray-700
|
|
107
|
+
${disabled ? 'opacity-50' : ''}
|
|
108
|
+
active:opacity-80
|
|
109
|
+
`}
|
|
110
|
+
accessibilityRole='button'
|
|
111
|
+
accessibilityLabel={
|
|
112
|
+
selectedEntity ? `Selected: ${selectedEntity.name}` : placeholder
|
|
113
|
+
}
|
|
114
|
+
accessibilityState={{ disabled }}
|
|
115
|
+
>
|
|
116
|
+
{loading ? (
|
|
117
|
+
<View className='flex-row items-center'>
|
|
118
|
+
<Text className='text-gray-500 dark:text-gray-400'>Loading...</Text>
|
|
119
|
+
</View>
|
|
120
|
+
) : selectedEntity ? (
|
|
121
|
+
<>
|
|
122
|
+
{showAvatars && (
|
|
123
|
+
<View className='mr-3'>{renderAvatar(selectedEntity)}</View>
|
|
124
|
+
)}
|
|
125
|
+
<View className='flex-1'>
|
|
126
|
+
<Text
|
|
127
|
+
className='text-base font-medium text-gray-900 dark:text-white'
|
|
128
|
+
numberOfLines={1}
|
|
129
|
+
>
|
|
130
|
+
{selectedEntity.name}
|
|
131
|
+
</Text>
|
|
132
|
+
{showRoles && selectedEntity.role && (
|
|
133
|
+
<Text className='text-xs text-gray-500 dark:text-gray-400 capitalize'>
|
|
134
|
+
{selectedEntity.role}
|
|
135
|
+
</Text>
|
|
136
|
+
)}
|
|
137
|
+
</View>
|
|
138
|
+
</>
|
|
139
|
+
) : (
|
|
140
|
+
<Text className='flex-1 text-gray-500 dark:text-gray-400'>
|
|
141
|
+
{placeholder}
|
|
142
|
+
</Text>
|
|
143
|
+
)}
|
|
144
|
+
<Text className='text-gray-400 dark:text-gray-500 text-lg ml-2'>▼</Text>
|
|
145
|
+
</Pressable>
|
|
146
|
+
|
|
147
|
+
{/* Dropdown Modal */}
|
|
148
|
+
<Modal
|
|
149
|
+
visible={isOpen}
|
|
150
|
+
transparent
|
|
151
|
+
animationType='fade'
|
|
152
|
+
onRequestClose={handleClose}
|
|
153
|
+
>
|
|
154
|
+
<Pressable
|
|
155
|
+
onPress={handleClose}
|
|
156
|
+
className='flex-1 bg-black/50 justify-center px-4'
|
|
157
|
+
>
|
|
158
|
+
<Pressable
|
|
159
|
+
onPress={e => e.stopPropagation()}
|
|
160
|
+
className='bg-white dark:bg-gray-800 rounded-2xl overflow-hidden max-h-96'
|
|
161
|
+
>
|
|
162
|
+
{/* Header */}
|
|
163
|
+
<View className='px-4 py-3 border-b border-gray-200 dark:border-gray-700'>
|
|
164
|
+
<Text className='text-lg font-semibold text-gray-900 dark:text-white'>
|
|
165
|
+
Select Entity
|
|
166
|
+
</Text>
|
|
167
|
+
</View>
|
|
168
|
+
|
|
169
|
+
{/* List */}
|
|
170
|
+
<FlatList
|
|
171
|
+
data={entities}
|
|
172
|
+
renderItem={renderItem}
|
|
173
|
+
keyExtractor={item => item.id}
|
|
174
|
+
showsVerticalScrollIndicator={false}
|
|
175
|
+
ListEmptyComponent={
|
|
176
|
+
<View className='py-8 items-center'>
|
|
177
|
+
<Text className='text-gray-500 dark:text-gray-400'>
|
|
178
|
+
No entities available
|
|
179
|
+
</Text>
|
|
180
|
+
</View>
|
|
181
|
+
}
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
{/* Cancel Button */}
|
|
185
|
+
<Pressable
|
|
186
|
+
onPress={handleClose}
|
|
187
|
+
className='px-4 py-3 border-t border-gray-200 dark:border-gray-700 items-center active:bg-gray-100 dark:active:bg-gray-700'
|
|
188
|
+
>
|
|
189
|
+
<Text className='text-blue-500 dark:text-blue-400 font-medium'>
|
|
190
|
+
Cancel
|
|
191
|
+
</Text>
|
|
192
|
+
</Pressable>
|
|
193
|
+
</Pressable>
|
|
194
|
+
</Pressable>
|
|
195
|
+
</Modal>
|
|
196
|
+
</View>
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export default EntitySelector;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TextInput,
|
|
6
|
+
Pressable,
|
|
7
|
+
ActivityIndicator,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import type { InvitationFormProps, EntityRole } from './types';
|
|
10
|
+
import { MemberRoleSelector } from './MemberRoleSelector';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate email format
|
|
14
|
+
*/
|
|
15
|
+
const isValidEmail = (email: string): boolean => {
|
|
16
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
17
|
+
return emailRegex.test(email);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* InvitationForm - Form for inviting members with email input and role selector
|
|
22
|
+
*/
|
|
23
|
+
export const InvitationForm: React.FC<InvitationFormProps> = ({
|
|
24
|
+
onSubmit,
|
|
25
|
+
availableRoles = ['admin', 'member', 'viewer', 'guest'],
|
|
26
|
+
defaultRole = 'member',
|
|
27
|
+
loading = false,
|
|
28
|
+
disabled = false,
|
|
29
|
+
placeholder = 'Enter email address',
|
|
30
|
+
submitLabel = 'Send Invitation',
|
|
31
|
+
className = '',
|
|
32
|
+
style,
|
|
33
|
+
testID,
|
|
34
|
+
}) => {
|
|
35
|
+
const [email, setEmail] = useState('');
|
|
36
|
+
const [selectedRole, setSelectedRole] = useState<EntityRole>(defaultRole);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
39
|
+
|
|
40
|
+
const handleEmailChange = (text: string) => {
|
|
41
|
+
setEmail(text);
|
|
42
|
+
if (error) {
|
|
43
|
+
setError(null);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleSubmit = async () => {
|
|
48
|
+
// Validate email
|
|
49
|
+
const trimmedEmail = email.trim();
|
|
50
|
+
|
|
51
|
+
if (!trimmedEmail) {
|
|
52
|
+
setError('Email address is required');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!isValidEmail(trimmedEmail)) {
|
|
57
|
+
setError('Please enter a valid email address');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setIsSubmitting(true);
|
|
62
|
+
setError(null);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await onSubmit(trimmedEmail, selectedRole);
|
|
66
|
+
// Clear form on success
|
|
67
|
+
setEmail('');
|
|
68
|
+
setSelectedRole(defaultRole);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
setError(
|
|
71
|
+
err instanceof Error ? err.message : 'Failed to send invitation'
|
|
72
|
+
);
|
|
73
|
+
} finally {
|
|
74
|
+
setIsSubmitting(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const isDisabled = disabled || loading || isSubmitting;
|
|
79
|
+
const canSubmit = email.trim().length > 0 && !isDisabled;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<View className={`${className}`} style={style} testID={testID}>
|
|
83
|
+
{/* Email Input */}
|
|
84
|
+
<View className='mb-4'>
|
|
85
|
+
<Text className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
|
86
|
+
Email Address
|
|
87
|
+
</Text>
|
|
88
|
+
<TextInput
|
|
89
|
+
value={email}
|
|
90
|
+
onChangeText={handleEmailChange}
|
|
91
|
+
placeholder={placeholder}
|
|
92
|
+
placeholderTextColor='#9CA3AF'
|
|
93
|
+
keyboardType='email-address'
|
|
94
|
+
autoCapitalize='none'
|
|
95
|
+
autoCorrect={false}
|
|
96
|
+
autoComplete='email'
|
|
97
|
+
editable={!isDisabled}
|
|
98
|
+
className={`
|
|
99
|
+
px-4 py-3 rounded-xl
|
|
100
|
+
bg-white dark:bg-gray-800
|
|
101
|
+
border ${error ? 'border-red-500' : 'border-gray-200 dark:border-gray-700'}
|
|
102
|
+
text-gray-900 dark:text-white
|
|
103
|
+
${isDisabled ? 'opacity-50' : ''}
|
|
104
|
+
`}
|
|
105
|
+
accessibilityLabel='Email address input'
|
|
106
|
+
accessibilityHint='Enter the email address of the person you want to invite'
|
|
107
|
+
/>
|
|
108
|
+
{error && (
|
|
109
|
+
<Text className='text-sm text-red-500 dark:text-red-400 mt-1'>
|
|
110
|
+
{error}
|
|
111
|
+
</Text>
|
|
112
|
+
)}
|
|
113
|
+
</View>
|
|
114
|
+
|
|
115
|
+
{/* Role Selector */}
|
|
116
|
+
<View className='mb-4'>
|
|
117
|
+
<Text className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
|
118
|
+
Role
|
|
119
|
+
</Text>
|
|
120
|
+
<MemberRoleSelector
|
|
121
|
+
selectedRole={selectedRole}
|
|
122
|
+
onRoleChange={setSelectedRole}
|
|
123
|
+
availableRoles={availableRoles}
|
|
124
|
+
disabled={isDisabled}
|
|
125
|
+
showDescriptions
|
|
126
|
+
/>
|
|
127
|
+
</View>
|
|
128
|
+
|
|
129
|
+
{/* Submit Button */}
|
|
130
|
+
<Pressable
|
|
131
|
+
onPress={handleSubmit}
|
|
132
|
+
disabled={!canSubmit}
|
|
133
|
+
className={`
|
|
134
|
+
flex-row items-center justify-center px-6 py-4 rounded-xl
|
|
135
|
+
${canSubmit ? 'bg-blue-500 dark:bg-blue-600 active:bg-blue-600 dark:active:bg-blue-700' : 'bg-gray-300 dark:bg-gray-600'}
|
|
136
|
+
`}
|
|
137
|
+
accessibilityRole='button'
|
|
138
|
+
accessibilityLabel={submitLabel}
|
|
139
|
+
accessibilityState={{ disabled: !canSubmit }}
|
|
140
|
+
>
|
|
141
|
+
{isSubmitting ? (
|
|
142
|
+
<>
|
|
143
|
+
<ActivityIndicator size='small' color='#FFFFFF' />
|
|
144
|
+
<Text className='text-white font-semibold ml-2'>Sending...</Text>
|
|
145
|
+
</>
|
|
146
|
+
) : (
|
|
147
|
+
<Text
|
|
148
|
+
className={`font-semibold ${canSubmit ? 'text-white' : 'text-gray-500 dark:text-gray-400'}`}
|
|
149
|
+
>
|
|
150
|
+
{submitLabel}
|
|
151
|
+
</Text>
|
|
152
|
+
)}
|
|
153
|
+
</Pressable>
|
|
154
|
+
|
|
155
|
+
{/* Help Text */}
|
|
156
|
+
<Text className='text-xs text-gray-500 dark:text-gray-400 mt-3 text-center'>
|
|
157
|
+
The invitee will receive an email with instructions to join.
|
|
158
|
+
</Text>
|
|
159
|
+
</View>
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export default InvitationForm;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
FlatList,
|
|
6
|
+
Pressable,
|
|
7
|
+
ActivityIndicator,
|
|
8
|
+
RefreshControl,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import type { InvitationListProps, Invitation, EntityRole } from './types';
|
|
11
|
+
import { DEFAULT_ROLE_CONFIGS } from './types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get role badge color classes
|
|
15
|
+
*/
|
|
16
|
+
const getRoleBadgeClasses = (role: EntityRole): string => {
|
|
17
|
+
const colorMap: Record<EntityRole, string> = {
|
|
18
|
+
owner:
|
|
19
|
+
'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
|
|
20
|
+
admin: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
|
|
21
|
+
member: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
|
|
22
|
+
viewer: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
|
|
23
|
+
guest:
|
|
24
|
+
'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
|
|
25
|
+
};
|
|
26
|
+
return colorMap[role] || colorMap.member;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get role label
|
|
31
|
+
*/
|
|
32
|
+
const getRoleLabel = (role: EntityRole): string => {
|
|
33
|
+
const config = DEFAULT_ROLE_CONFIGS.find(c => c.role === role);
|
|
34
|
+
return config?.label || role;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get status badge classes
|
|
39
|
+
*/
|
|
40
|
+
const getStatusBadgeClasses = (status: Invitation['status']): string => {
|
|
41
|
+
const colorMap: Record<Invitation['status'], string> = {
|
|
42
|
+
pending:
|
|
43
|
+
'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
|
|
44
|
+
accepted:
|
|
45
|
+
'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
|
|
46
|
+
declined: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
|
|
47
|
+
expired: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
|
|
48
|
+
};
|
|
49
|
+
return colorMap[status] || colorMap.pending;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get status label
|
|
54
|
+
*/
|
|
55
|
+
const getStatusLabel = (status: Invitation['status']): string => {
|
|
56
|
+
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format relative time
|
|
61
|
+
*/
|
|
62
|
+
const formatRelativeTime = (dateString: string): string => {
|
|
63
|
+
const date = new Date(dateString);
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const diffMs = now.getTime() - date.getTime();
|
|
66
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
67
|
+
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
68
|
+
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
69
|
+
|
|
70
|
+
if (diffDays > 0) {
|
|
71
|
+
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
|
72
|
+
} else if (diffHours > 0) {
|
|
73
|
+
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
|
74
|
+
} else if (diffMinutes > 0) {
|
|
75
|
+
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
|
|
76
|
+
} else {
|
|
77
|
+
return 'Just now';
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* InvitationList - List of pending invitations
|
|
83
|
+
*/
|
|
84
|
+
export const InvitationList: React.FC<InvitationListProps> = ({
|
|
85
|
+
invitations,
|
|
86
|
+
onResend,
|
|
87
|
+
onCancel,
|
|
88
|
+
canResend = true,
|
|
89
|
+
canCancel = true,
|
|
90
|
+
emptyMessage = 'No pending invitations',
|
|
91
|
+
emptyIcon,
|
|
92
|
+
loading = false,
|
|
93
|
+
refreshing = false,
|
|
94
|
+
onRefresh,
|
|
95
|
+
ListHeaderComponent,
|
|
96
|
+
ListFooterComponent,
|
|
97
|
+
className = '',
|
|
98
|
+
style,
|
|
99
|
+
testID,
|
|
100
|
+
}) => {
|
|
101
|
+
const renderItem = ({ item }: { item: Invitation }) => {
|
|
102
|
+
const isPending = item.status === 'pending';
|
|
103
|
+
const showResend = canResend && onResend && isPending;
|
|
104
|
+
const showCancel = canCancel && onCancel && isPending;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<View
|
|
108
|
+
className={`
|
|
109
|
+
p-4 rounded-xl mb-3
|
|
110
|
+
bg-white dark:bg-gray-800
|
|
111
|
+
border border-gray-200 dark:border-gray-700
|
|
112
|
+
`}
|
|
113
|
+
accessibilityLabel={`Invitation to ${item.email}, Status: ${item.status}`}
|
|
114
|
+
>
|
|
115
|
+
{/* Header */}
|
|
116
|
+
<View className='flex-row items-center justify-between mb-2'>
|
|
117
|
+
<Text
|
|
118
|
+
className='text-base font-semibold text-gray-900 dark:text-white flex-1'
|
|
119
|
+
numberOfLines={1}
|
|
120
|
+
>
|
|
121
|
+
{item.email}
|
|
122
|
+
</Text>
|
|
123
|
+
<View
|
|
124
|
+
className={`px-2 py-0.5 rounded-full ${getStatusBadgeClasses(item.status)}`}
|
|
125
|
+
>
|
|
126
|
+
<Text className='text-xs font-medium'>
|
|
127
|
+
{getStatusLabel(item.status)}
|
|
128
|
+
</Text>
|
|
129
|
+
</View>
|
|
130
|
+
</View>
|
|
131
|
+
|
|
132
|
+
{/* Role and Time */}
|
|
133
|
+
<View className='flex-row items-center mb-2'>
|
|
134
|
+
<View
|
|
135
|
+
className={`px-2 py-0.5 rounded-full mr-2 ${getRoleBadgeClasses(item.role)}`}
|
|
136
|
+
>
|
|
137
|
+
<Text className='text-xs font-medium'>
|
|
138
|
+
{getRoleLabel(item.role)}
|
|
139
|
+
</Text>
|
|
140
|
+
</View>
|
|
141
|
+
<Text className='text-xs text-gray-500 dark:text-gray-400'>
|
|
142
|
+
Invited {formatRelativeTime(item.invitedAt)}
|
|
143
|
+
</Text>
|
|
144
|
+
</View>
|
|
145
|
+
|
|
146
|
+
{/* Invited by */}
|
|
147
|
+
{item.invitedBy && (
|
|
148
|
+
<Text className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
|
|
149
|
+
by {item.invitedBy}
|
|
150
|
+
</Text>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Expiration warning */}
|
|
154
|
+
{item.expiresAt && isPending && (
|
|
155
|
+
<Text className='text-xs text-yellow-600 dark:text-yellow-400 mb-2'>
|
|
156
|
+
Expires: {new Date(item.expiresAt).toLocaleDateString()}
|
|
157
|
+
</Text>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{/* Actions */}
|
|
161
|
+
{(showResend || showCancel) && (
|
|
162
|
+
<View className='flex-row items-center mt-2 pt-2 border-t border-gray-100 dark:border-gray-700'>
|
|
163
|
+
{showResend && (
|
|
164
|
+
<Pressable
|
|
165
|
+
onPress={() => onResend(item)}
|
|
166
|
+
className='flex-row items-center mr-4 py-1 active:opacity-60'
|
|
167
|
+
accessibilityRole='button'
|
|
168
|
+
accessibilityLabel='Resend invitation'
|
|
169
|
+
>
|
|
170
|
+
<Text className='text-blue-500 dark:text-blue-400 text-sm font-medium'>
|
|
171
|
+
Resend
|
|
172
|
+
</Text>
|
|
173
|
+
</Pressable>
|
|
174
|
+
)}
|
|
175
|
+
{showCancel && (
|
|
176
|
+
<Pressable
|
|
177
|
+
onPress={() => onCancel(item)}
|
|
178
|
+
className='flex-row items-center py-1 active:opacity-60'
|
|
179
|
+
accessibilityRole='button'
|
|
180
|
+
accessibilityLabel='Cancel invitation'
|
|
181
|
+
>
|
|
182
|
+
<Text className='text-red-500 dark:text-red-400 text-sm font-medium'>
|
|
183
|
+
Cancel
|
|
184
|
+
</Text>
|
|
185
|
+
</Pressable>
|
|
186
|
+
)}
|
|
187
|
+
</View>
|
|
188
|
+
)}
|
|
189
|
+
</View>
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const renderEmpty = () => {
|
|
194
|
+
if (loading) {
|
|
195
|
+
return (
|
|
196
|
+
<View className='flex-1 items-center justify-center py-12'>
|
|
197
|
+
<ActivityIndicator size='large' color='#3B82F6' />
|
|
198
|
+
<Text className='text-gray-500 dark:text-gray-400 mt-4'>
|
|
199
|
+
Loading...
|
|
200
|
+
</Text>
|
|
201
|
+
</View>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<View className='flex-1 items-center justify-center py-12'>
|
|
207
|
+
{emptyIcon}
|
|
208
|
+
<Text className='text-gray-500 dark:text-gray-400 text-center mt-2'>
|
|
209
|
+
{emptyMessage}
|
|
210
|
+
</Text>
|
|
211
|
+
</View>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<View className={`flex-1 ${className}`} style={style} testID={testID}>
|
|
217
|
+
<FlatList
|
|
218
|
+
data={invitations}
|
|
219
|
+
renderItem={renderItem}
|
|
220
|
+
keyExtractor={item => item.id}
|
|
221
|
+
ListHeaderComponent={ListHeaderComponent}
|
|
222
|
+
ListFooterComponent={ListFooterComponent}
|
|
223
|
+
ListEmptyComponent={renderEmpty}
|
|
224
|
+
contentContainerStyle={{ flexGrow: 1, padding: 16 }}
|
|
225
|
+
showsVerticalScrollIndicator={false}
|
|
226
|
+
refreshControl={
|
|
227
|
+
onRefresh ? (
|
|
228
|
+
<RefreshControl
|
|
229
|
+
refreshing={refreshing}
|
|
230
|
+
onRefresh={onRefresh}
|
|
231
|
+
tintColor='#3B82F6'
|
|
232
|
+
/>
|
|
233
|
+
) : undefined
|
|
234
|
+
}
|
|
235
|
+
/>
|
|
236
|
+
</View>
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export default InvitationList;
|