TransactionConfirmModal.tsx
1 /** 2 * Transaction Confirmation Modal 3 * Shows transaction details and requires biometric/PIN confirmation 4 */ 5 import React, { useState } from 'react'; 6 import { 7 View, 8 Text, 9 StyleSheet, 10 Modal, 11 TouchableOpacity, 12 ActivityIndicator, 13 Platform, 14 } from 'react-native'; 15 import * as LocalAuthentication from 'expo-local-authentication'; 16 import * as Haptics from 'expo-haptics'; 17 import { colors } from '../theme/colors'; 18 19 interface TransactionConfirmModalProps { 20 visible: boolean; 21 recipient: string; 22 amount: string; 23 fee: string; 24 memo?: string; 25 tokenSymbol: string; 26 onConfirm: () => Promise<void>; 27 onCancel: () => void; 28 } 29 30 export function TransactionConfirmModal({ 31 visible, 32 recipient, 33 amount, 34 fee, 35 memo, 36 tokenSymbol, 37 onConfirm, 38 onCancel, 39 }: TransactionConfirmModalProps) { 40 const [isAuthenticating, setIsAuthenticating] = useState(false); 41 const [isSubmitting, setIsSubmitting] = useState(false); 42 const [error, setError] = useState<string | null>(null); 43 44 const totalAmount = (parseFloat(amount) + parseFloat(fee)).toFixed(6); 45 46 const handleConfirm = async () => { 47 setError(null); 48 setIsAuthenticating(true); 49 50 try { 51 // Check if biometric authentication is available 52 const hasHardware = await LocalAuthentication.hasHardwareAsync(); 53 const isEnrolled = await LocalAuthentication.isEnrolledAsync(); 54 55 if (hasHardware && isEnrolled) { 56 const result = await LocalAuthentication.authenticateAsync({ 57 promptMessage: 'Confirm Transaction', 58 fallbackLabel: 'Use PIN', 59 cancelLabel: 'Cancel', 60 disableDeviceFallback: false, 61 }); 62 63 if (!result.success) { 64 if (result.error === 'user_cancel') { 65 setIsAuthenticating(false); 66 return; 67 } 68 setError('Authentication failed. Please try again.'); 69 setIsAuthenticating(false); 70 return; 71 } 72 } 73 74 // Authentication successful, submit transaction 75 setIsAuthenticating(false); 76 setIsSubmitting(true); 77 78 await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 79 await onConfirm(); 80 81 await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); 82 } catch (err) { 83 setError(err instanceof Error ? err.message : 'Transaction failed'); 84 await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); 85 } finally { 86 setIsAuthenticating(false); 87 setIsSubmitting(false); 88 } 89 }; 90 91 const formatAddress = (address: string): string => { 92 if (address.length <= 20) return address; 93 return `${address.slice(0, 12)}...${address.slice(-8)}`; 94 }; 95 96 return ( 97 <Modal 98 visible={visible} 99 animationType="slide" 100 transparent 101 onRequestClose={onCancel} 102 > 103 <View style={styles.overlay}> 104 <View style={styles.modal}> 105 {/* Header */} 106 <View style={styles.header}> 107 <Text style={styles.title}>Confirm Transaction</Text> 108 <TouchableOpacity 109 style={styles.closeButton} 110 onPress={onCancel} 111 disabled={isSubmitting} 112 > 113 <Text style={styles.closeButtonText}>X</Text> 114 </TouchableOpacity> 115 </View> 116 117 {/* Transaction Details */} 118 <View style={styles.details}> 119 {/* Recipient */} 120 <View style={styles.row}> 121 <Text style={styles.label}>To</Text> 122 <Text style={styles.value} numberOfLines={1}> 123 {formatAddress(recipient)} 124 </Text> 125 </View> 126 127 {/* Amount */} 128 <View style={styles.row}> 129 <Text style={styles.label}>Amount</Text> 130 <Text style={styles.valueHighlight}> 131 {amount} {tokenSymbol} 132 </Text> 133 </View> 134 135 {/* Fee */} 136 <View style={styles.row}> 137 <Text style={styles.label}>Network Fee</Text> 138 <Text style={styles.value}> 139 {fee} {tokenSymbol} 140 </Text> 141 </View> 142 143 {/* Memo */} 144 {memo && ( 145 <View style={styles.row}> 146 <Text style={styles.label}>Memo</Text> 147 <Text style={styles.value} numberOfLines={2}> 148 {memo} 149 </Text> 150 </View> 151 )} 152 153 {/* Divider */} 154 <View style={styles.divider} /> 155 156 {/* Total */} 157 <View style={styles.row}> 158 <Text style={styles.totalLabel}>Total</Text> 159 <Text style={styles.totalValue}> 160 {totalAmount} {tokenSymbol} 161 </Text> 162 </View> 163 </View> 164 165 {/* Error Message */} 166 {error && ( 167 <View style={styles.errorContainer}> 168 <Text style={styles.errorText}>{error}</Text> 169 </View> 170 )} 171 172 {/* Actions */} 173 <View style={styles.actions}> 174 <TouchableOpacity 175 style={styles.cancelButton} 176 onPress={onCancel} 177 disabled={isSubmitting || isAuthenticating} 178 > 179 <Text style={styles.cancelButtonText}>Cancel</Text> 180 </TouchableOpacity> 181 182 <TouchableOpacity 183 style={[ 184 styles.confirmButton, 185 (isSubmitting || isAuthenticating) && styles.confirmButtonDisabled, 186 ]} 187 onPress={handleConfirm} 188 disabled={isSubmitting || isAuthenticating} 189 > 190 {isSubmitting || isAuthenticating ? ( 191 <ActivityIndicator color={colors.white} size="small" /> 192 ) : ( 193 <Text style={styles.confirmButtonText}> 194 {isAuthenticating ? 'Authenticating...' : 'Confirm & Send'} 195 </Text> 196 )} 197 </TouchableOpacity> 198 </View> 199 200 {/* Security Notice */} 201 <View style={styles.securityNotice}> 202 <Text style={styles.securityIcon}> 203 {Platform.OS === 'ios' ? 'Face ID / Touch ID' : 'Biometric'} 204 </Text> 205 <Text style={styles.securityText}> 206 Authentication required to confirm 207 </Text> 208 </View> 209 </View> 210 </View> 211 </Modal> 212 ); 213 } 214 215 const styles = StyleSheet.create({ 216 overlay: { 217 flex: 1, 218 backgroundColor: 'rgba(0, 0, 0, 0.7)', 219 justifyContent: 'flex-end', 220 }, 221 modal: { 222 backgroundColor: colors.background.secondary, 223 borderTopLeftRadius: 24, 224 borderTopRightRadius: 24, 225 paddingTop: 16, 226 paddingBottom: 34, 227 paddingHorizontal: 20, 228 }, 229 header: { 230 flexDirection: 'row', 231 justifyContent: 'space-between', 232 alignItems: 'center', 233 marginBottom: 20, 234 }, 235 title: { 236 color: colors.white, 237 fontSize: 20, 238 fontWeight: '700', 239 }, 240 closeButton: { 241 width: 32, 242 height: 32, 243 borderRadius: 16, 244 backgroundColor: colors.background.tertiary, 245 alignItems: 'center', 246 justifyContent: 'center', 247 }, 248 closeButtonText: { 249 color: colors.text.secondary, 250 fontSize: 14, 251 fontWeight: '600', 252 }, 253 details: { 254 backgroundColor: colors.background.tertiary, 255 borderRadius: 16, 256 padding: 16, 257 marginBottom: 16, 258 }, 259 row: { 260 flexDirection: 'row', 261 justifyContent: 'space-between', 262 alignItems: 'center', 263 paddingVertical: 12, 264 }, 265 label: { 266 color: colors.text.secondary, 267 fontSize: 14, 268 }, 269 value: { 270 color: colors.white, 271 fontSize: 14, 272 maxWidth: '60%', 273 textAlign: 'right', 274 }, 275 valueHighlight: { 276 color: colors.white, 277 fontSize: 18, 278 fontWeight: '600', 279 }, 280 divider: { 281 height: 1, 282 backgroundColor: colors.border.strong, 283 marginVertical: 8, 284 }, 285 totalLabel: { 286 color: colors.white, 287 fontSize: 16, 288 fontWeight: '600', 289 }, 290 totalValue: { 291 color: colors.accent.alpha, 292 fontSize: 18, 293 fontWeight: '700', 294 }, 295 errorContainer: { 296 backgroundColor: 'rgba(239, 68, 68, 0.1)', 297 borderRadius: 12, 298 padding: 12, 299 marginBottom: 16, 300 }, 301 errorText: { 302 color: colors.semantic.error, 303 fontSize: 14, 304 textAlign: 'center', 305 }, 306 actions: { 307 flexDirection: 'row', 308 gap: 12, 309 marginBottom: 16, 310 }, 311 cancelButton: { 312 flex: 1, 313 backgroundColor: colors.background.tertiary, 314 borderRadius: 12, 315 padding: 16, 316 alignItems: 'center', 317 }, 318 cancelButtonText: { 319 color: colors.white, 320 fontSize: 16, 321 fontWeight: '600', 322 }, 323 confirmButton: { 324 flex: 2, 325 backgroundColor: colors.accent.alpha, 326 borderRadius: 12, 327 padding: 16, 328 alignItems: 'center', 329 justifyContent: 'center', 330 minHeight: 52, 331 }, 332 confirmButtonDisabled: { 333 backgroundColor: '#4B4B6D', 334 }, 335 confirmButtonText: { 336 color: colors.white, 337 fontSize: 16, 338 fontWeight: '600', 339 }, 340 securityNotice: { 341 flexDirection: 'row', 342 alignItems: 'center', 343 justifyContent: 'center', 344 gap: 8, 345 }, 346 securityIcon: { 347 color: colors.text.secondary, 348 fontSize: 12, 349 }, 350 securityText: { 351 color: colors.text.secondary, 352 fontSize: 12, 353 }, 354 });