import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, Animated, Image, ToastAndroid, Modal, Easing, } from 'react-native'; import { useNavigation, useRoute } from '@react-navigation/native'; import { responsiveFontSize } from 'react-native-responsive-dimensions'; import LucideIcon from '../../components/common/LucideIcon'; import AppView from '../../components/AppView'; import { useTheme } from '../../hooks/ui/useTheme'; import { apiCall } from '../../utils/ApiHelper'; import { BASE_URL, CONTRIBUTE, PAYMENT_MODES_URL } from '../../utils/ApiUrls'; import FontsFamily from '../../constents/FontsFamily'; import Header from '../../components/common/Header'; import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors'; interface PaymentMode { id: number; modeCode: string; modeName: string; provider: string; countryCode: string; currencyCode: string; minAmount: number; maxAmount: number; isActive: boolean; isMaintenance: boolean; displayOrder: number; iconUrl: string; } // --- Extracted ModeCard Component (hooks must be in a component, not a render function) --- const getBrandColors = (modeCode: string): { bg: string; text: string } => { switch (modeCode) { case 'PAISAPAL': return { bg: '#2BAF2B', text: '#fff' }; // Pesapal green case 'MTN': return { bg: '#FFCC00', text: '#003087' }; // MTN yellow + blue case 'AIRTEL': return { bg: '#ED1C24', text: '#fff' }; // Airtel red default: return { bg: '#6C63FF', text: '#fff' }; } }; interface ModeCardProps { item: PaymentMode; index: number; isSelected: boolean; isDisabled: boolean; onSelect: (id: number) => void; } const ModeCard: React.FC = ({ item, index, isSelected, isDisabled, onSelect }) => { const theme = useTheme(); const cardAnim = useRef(new Animated.Value(0)).current; useEffect(() => { Animated.timing(cardAnim, { toValue: 1, duration: 400, delay: index * 100, useNativeDriver: true, }).start(); }, []); const brandColors = getBrandColors(item.modeCode); return ( { if (!isDisabled) onSelect(item.id); }} style={[ styles.modeCard, { backgroundColor: isSelected ? theme.colors.primary + '12' : theme.colors.card, borderColor: isSelected ? theme.colors.primary : theme.colors.border, opacity: isDisabled ? 0.5 : 1, }, ]} > {/* Selection Indicator */} {isSelected && } {/* Provider Brand Icon */} {item.provider.charAt(0).toUpperCase()} {/* Info */} {item.modeName} {item.provider} • {item.currencyCode} {item.currencyCode} {item.minAmount.toLocaleString()} – {item.maxAmount.toLocaleString()} {/* Badges */} {item.isMaintenance && ( Maintenance )} {!item.isActive && !item.isMaintenance && ( Unavailable )} ); }; // --- Main Screen --- const PaymentModeScreen: React.FC = () => { const theme = useTheme(); const navigation = useNavigation(); const route = useRoute(); const { groupId, routeFrom, routeData = null } = route.params || {}; const user = useSelector((state: any) => state.user); const [modes, setModes] = useState([]); const [loading, setLoading] = useState(true); const [selectedMode, setSelectedMode] = useState(null); const [paying, setPaying] = useState(false); const [payable, setPayable] = useState(); // --- Payment processing modal state --- const [processingVisible, setProcessingVisible] = useState(false); const [processingStatus, setProcessingStatus] = useState<'polling' | 'success' | 'failed' | 'timeout'>('polling'); const [processingMessage, setProcessingMessage] = useState('Payment request initiated. Awaiting approval...'); const [timeRemaining, setTimeRemaining] = useState(360); // 6 minutes in seconds const pollingRef = useRef | null>(null); const countdownRef = useRef | null>(null); const spinAnim = useRef(new Animated.Value(0)).current; const spinLoop = useRef(null); // Animations const fadeAnim = useRef(new Animated.Value(0)).current; const slideAnim = useRef(new Animated.Value(30)).current; useEffect(() => { Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }), Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }), ]).start(); }, []); useEffect(() => { { routeFrom === 'GroupDetails' && handleContribution(); } console.log("data", payable); }, []); const handleContribution = async () => { try { const res = await apiCall(`${CONTRIBUTE}${groupId || route?.params?.groupId}/calculate-contribution`, 'GET') as any; setPayable(res.data); } catch (error) { console.log("Error in handleContribution: ", error); } }; // Spin animation for processing modal const startSpinAnimation = () => { spinAnim.setValue(0); spinLoop.current = Animated.loop( Animated.timing(spinAnim, { toValue: 1, duration: 1500, easing: Easing.linear, useNativeDriver: true, }) ); spinLoop.current.start(); }; const stopSpinAnimation = () => { spinLoop.current?.stop(); }; const cleanupPolling = () => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; } stopSpinAnimation(); }; // Cleanup on unmount useEffect(() => { return () => cleanupPolling(); }, []); const pollTransactionStatus = (pgOrderNo: string) => { setProcessingVisible(true); setProcessingStatus('polling'); setProcessingMessage('Payment request initiated. Awaiting approval...'); setTimeRemaining(360); startSpinAnimation(); const startTime = Date.now(); const MAX_DURATION = 6 * 60 * 1000; // 6 minutes const POLL_INTERVAL = 5000; // 5 seconds // Countdown timer countdownRef.current = setInterval(() => { const elapsed = Date.now() - startTime; const remaining = Math.max(0, Math.ceil((MAX_DURATION - elapsed) / 1000)); setTimeRemaining(remaining); }, 1000); // Polling interval pollingRef.current = setInterval(async () => { const elapsed = Date.now() - startTime; // Timeout after 6 minutes if (elapsed >= MAX_DURATION) { cleanupPolling(); setProcessingStatus('timeout'); setProcessingMessage('Payment verification timed out. Please check your transaction history.'); return; } try { const statusRes = await apiCall( `${BASE_URL}payments/pg/check-status/${pgOrderNo}`, 'GET' ) as any; console.log('Poll status response:', JSON.stringify(statusRes)); const txnStatus = statusRes?.data?.txnStatus; if (txnStatus === 'SUCCESS') { cleanupPolling(); setProcessingStatus('success'); setProcessingMessage('Payment completed successfully!'); setTimeout(() => { setProcessingVisible(false); setPaying(false); ToastAndroid.show('Payment successful!', ToastAndroid.SHORT); navigation.goBack(); }, 2000); return; } if (txnStatus === 'FAILED' || txnStatus === 'REJECTED' || txnStatus === 'CANCELLED') { cleanupPolling(); setProcessingStatus('failed'); setProcessingMessage(statusRes?.data?.message || 'Payment failed. Please try again.'); return; } // Still pending — update message setProcessingMessage(statusRes?.message || 'Waiting for payment confirmation...'); } catch (err) { console.log('Poll error:', err); // Don't stop polling on network errors, keep trying } }, POLL_INTERVAL); }; const handleCloseProcessingModal = () => { cleanupPolling(); setProcessingVisible(false); setPaying(false); }; const fetchPaymentModes = useCallback(async () => { try { setLoading(true); const res = await apiCall(PAYMENT_MODES_URL, 'GET') as any; if (res?.status === 'success' && Array.isArray(res.data)) { const sorted = res.data.sort((a: PaymentMode, b: PaymentMode) => a.displayOrder - b.displayOrder); setModes(sorted); } else { ToastAndroid.show('Failed to load payment methods', ToastAndroid.SHORT); } } catch (error) { ToastAndroid.show('Error loading payment methods', ToastAndroid.SHORT); } finally { setLoading(false); } }, []); useEffect(() => { fetchPaymentModes(); }, [fetchPaymentModes]); const handlePaymentForRouteFormGroupScreen = async () => { try { setPaying(true); const payload = { groupId: groupId || 0, paymentId: selectedMode || 0, mobileNumber: '', }; console.log('payload', JSON.stringify(payload)); const res = await apiCall( `${BASE_URL}payments/pg/entry-fee/${groupId}?groupId=${groupId}&paymentId=${selectedMode}&mobileNumber=`, 'POST', payload ) as any; console.log("payment res",res) if (res?.status === 'success' || res?.statusCode === 200) { const pgOrderNo = res?.data?.pgOrderNo; if (pgOrderNo) { // Start polling for transaction status pollTransactionStatus(pgOrderNo); } else { ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT); setPaying(false); } } else { ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT); setPaying(false); } } catch (error) { ToastAndroid.show('An error occurred during payment', ToastAndroid.SHORT); setPaying(false); } } const handlePaymentForRouteFormGroupDetails = async () => { try { setPaying(true); const payload = { groupId: groupId || 0, amount: payable.totalPayable || 0, paymentId: selectedMode || 0, mobileNumber: '', }; console.log('payload', JSON.stringify(payload)); const res = await apiCall( `${BASE_URL}payments/contribute`, 'POST', payload ) as any; console.log("payment payload ",payload) if (res?.status === 'success' || res?.statusCode === 200) { const pgOrderNo = res?.data?.pgOrderNo; if (pgOrderNo) { // Start polling for transaction status pollTransactionStatus(pgOrderNo); } else { ToastAndroid.show('Payment initiated but no order number received', ToastAndroid.SHORT); setPaying(false); } } else { ToastAndroid.show(res?.data?.message || res?.message || 'Payment failed', ToastAndroid.SHORT); setPaying(false); } } catch (error) { ToastAndroid.show('An error occurred during payment', ToastAndroid.SHORT); setPaying(false); } } const handleConfirmPayment = async () => { if (!selectedMode) { ToastAndroid.show('Please select a payment method', ToastAndroid.SHORT); return; } switch (routeFrom) { case 'GroupDetails': handlePaymentForRouteFormGroupDetails(); break; case 'GroupScreen': handlePaymentForRouteFormGroupScreen(); break; default: break; } }; return (
{/* Amount Summary */} {routeFrom === 'GroupDetails' && Total Payable {payable?.currencyCode || 'UGX'} {Number(payable?.totalPayable || 0).toLocaleString()} } {routeFrom === 'GroupScreen' && Entry Fee {payable?.currencyCode || 'UGX'} {Number(routeData?.entryFee || 0).toLocaleString()} } {/* Payment Methods */} {loading ? ( Loading payment methods... ) : modes.length === 0 ? ( No payment methods available ) : ( String(item.id)} renderItem={({ item, index }) => ( )} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} /> )} {/* Bottom CTA */} {!loading && modes.length > 0 && ( {paying ? ( ) : ( <> Confirm & Pay )} )} {/* Processing Modal — non-dismissable while polling */} { // Only allow closing if not actively polling if (processingStatus !== 'polling') handleCloseProcessingModal(); }} > {/* Icon / Spinner */} {processingStatus === 'polling' ? ( ) : processingStatus === 'success' ? ( ) : ( )} {/* Title */} {processingStatus === 'polling' ? 'Processing Payment' : processingStatus === 'success' ? 'Payment Successful' : processingStatus === 'timeout' ? 'Verification Timed Out' : 'Payment Failed'} {/* Message */} {processingMessage} {/* Countdown timer while polling */} {processingStatus === 'polling' && ( {Math.floor(timeRemaining / 60)}:{String(timeRemaining % 60).padStart(2, '0')} remaining )} {/* Close button — only visible when NOT polling */} {processingStatus !== 'polling' && ( {processingStatus === 'success' ? 'Done' : 'Close'} )} ); }; export default PaymentModeScreen; const styles = StyleSheet.create({ container: { flex: 1, paddingHorizontal: 16, }, amountCard: { padding: 16, borderRadius: 16, borderWidth: 1, marginBottom: 20, }, amountRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6, }, amountLabel: { fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoSemiBold, }, amountValue: { fontSize: responsiveFontSize(3), fontFamily: FontsFamily.nunitoExtraBold, }, listContent: { paddingBottom: 100, gap: 10, }, modeCard: { flexDirection: 'row', alignItems: 'center', padding: 16, borderRadius: 16, borderWidth: 1.5, }, radioOuter: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, justifyContent: 'center', alignItems: 'center', marginRight: 12, }, radioInner: { width: 12, height: 12, borderRadius: 6, }, modeIconContainer: { width: 48, height: 48, borderRadius: 12, justifyContent: 'center', alignItems: 'center', marginRight: 12, }, modeIconImage: { width: 32, height: 32, borderRadius: 6, }, modeInfo: { flex: 1, }, modeName: { fontSize: responsiveFontSize(1.7), fontFamily: FontsFamily.nunitoBold, }, modeProvider: { fontSize: responsiveFontSize(1.3), fontFamily: FontsFamily.nunitoSemiBold, marginTop: 2, }, modeLimits: { fontSize: responsiveFontSize(1.1), fontFamily: FontsFamily.nunitoRegular, marginTop: 2, }, badgeContainer: { marginLeft: 8, }, badge: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, }, badgeText: { fontSize: responsiveFontSize(1), fontFamily: FontsFamily.nunitoBold, textTransform: 'uppercase', }, loaderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, }, loaderText: { fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoSemiBold, }, emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, }, emptyText: { fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoSemiBold, }, bottomBar: { paddingHorizontal: 16, paddingVertical: 12, borderTopWidth: 1, }, confirmButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 16, borderRadius: 14, }, confirmText: { fontSize: responsiveFontSize(1.8), fontFamily: FontsFamily.nunitoBold, }, }); const modalStyles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32, }, card: { width: '100%', borderRadius: 20, paddingVertical: 36, paddingHorizontal: 24, alignItems: 'center', }, iconCircle: { width: 80, height: 80, borderRadius: 40, justifyContent: 'center', alignItems: 'center', }, title: { fontSize: responsiveFontSize(2.2), fontFamily: FontsFamily.nunitoExtraBold, marginTop: 20, textAlign: 'center', }, message: { fontSize: responsiveFontSize(1.4), fontFamily: FontsFamily.nunitoSemiBold, marginTop: 10, textAlign: 'center', lineHeight: 20, }, timerContainer: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 16, paddingHorizontal: 14, paddingVertical: 6, borderRadius: 20, backgroundColor: 'rgba(128,128,128,0.1)', }, timerText: { fontSize: responsiveFontSize(1.2), fontFamily: FontsFamily.nunitoSemiBold, }, closeButton: { marginTop: 24, paddingVertical: 14, paddingHorizontal: 40, borderRadius: 12, }, closeButtonText: { color: '#fff', fontSize: responsiveFontSize(1.6), fontFamily: FontsFamily.nunitoBold, }, });