Files
circle-collect/src/screens/payment/PaymentModeScreen.tsx
T
2026-03-26 15:07:08 +05:30

757 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ModeCardProps> = ({ 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 (
<Animated.View style={{ opacity: cardAnim, transform: [{ translateY: cardAnim.interpolate({ inputRange: [0, 1], outputRange: [20, 0] }) }] }}>
<TouchableOpacity
activeOpacity={isDisabled ? 1 : 0.7}
onPress={() => { 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 */}
<View style={[styles.radioOuter, { borderColor: isSelected ? theme.colors.primary : theme.colors.textMuted }]}>
{isSelected && <View style={[styles.radioInner, { backgroundColor: theme.colors.primary }]} />}
</View>
{/* Provider Brand Icon */}
<View style={[styles.modeIconContainer, { backgroundColor: brandColors.bg }]}>
<Text style={{ fontSize: responsiveFontSize(2.2), fontFamily: FontsFamily.nunitoExtraBold, color: brandColors.text }}>
{item.provider.charAt(0).toUpperCase()}
</Text>
</View>
{/* Info */}
<View style={styles.modeInfo}>
<Text style={[styles.modeName, { color: theme.colors.textPrimary }]}>{item.modeName}</Text>
<Text style={[styles.modeProvider, { color: theme.colors.textSecondary }]}>
{item.provider} • {item.currencyCode}
</Text>
<Text style={[styles.modeLimits, { color: theme.colors.textMuted }]}>
{item.currencyCode} {item.minAmount.toLocaleString()} – {item.maxAmount.toLocaleString()}
</Text>
</View>
{/* Badges */}
<View style={styles.badgeContainer}>
{item.isMaintenance && (
<View style={[styles.badge, { backgroundColor: theme.colors.warning + '22' }]}>
<Text style={[styles.badgeText, { color: theme.colors.warning }]}>Maintenance</Text>
</View>
)}
{!item.isActive && !item.isMaintenance && (
<View style={[styles.badge, { backgroundColor: theme.colors.error + '22' }]}>
<Text style={[styles.badgeText, { color: theme.colors.error }]}>Unavailable</Text>
</View>
)}
</View>
</TouchableOpacity>
</Animated.View>
);
};
// --- Main Screen ---
const PaymentModeScreen: React.FC = () => {
const theme = useTheme();
const navigation = useNavigation();
const route = useRoute<any>();
const { groupId, routeFrom, routeData = null } = route.params || {};
const user = useSelector((state: any) => state.user);
const [modes, setModes] = useState<PaymentMode[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMode, setSelectedMode] = useState<number | null>(null);
const [paying, setPaying] = useState(false);
const [payable, setPayable] = useState<any>();
// --- 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<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const spinAnim = useRef(new Animated.Value(0)).current;
const spinLoop = useRef<Animated.CompositeAnimation | null>(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 (
<AppView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<Header title="Select Payment Method" />
<Animated.View style={[styles.container, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
{/* Amount Summary */}
{routeFrom === 'GroupDetails' && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={styles.amountRow}>
<LucideIcon name="wallet-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Total Payable</Text>
</View>
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
{payable?.currencyCode || 'UGX'} {Number(payable?.totalPayable || 0).toLocaleString()}
</Text>
</View>}
{routeFrom === 'GroupScreen' && <View style={[styles.amountCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}>
<View style={styles.amountRow}>
<LucideIcon name="wallet-outline" size={20} color={theme.colors.primary} />
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>Entry Fee</Text>
</View>
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
{payable?.currencyCode || 'UGX'} {Number(routeData?.entryFee || 0).toLocaleString()}
</Text>
</View>}
{/* Payment Methods */}
{loading ? (
<View style={styles.loaderContainer}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={[styles.loaderText, { color: theme.colors.textMuted }]}>Loading payment methods...</Text>
</View>
) : modes.length === 0 ? (
<View style={styles.emptyContainer}>
<LucideIcon name="alert-circle-outline" size={48} color={theme.colors.textMuted} />
<Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>No payment methods available</Text>
</View>
) : (
<FlatList
data={modes}
keyExtractor={(item) => String(item.id)}
renderItem={({ item, index }) => (
<ModeCard
item={item}
index={index}
isSelected={selectedMode === item.id}
isDisabled={!item.isActive || item.isMaintenance}
onSelect={setSelectedMode}
/>
)}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
)}
</Animated.View>
{/* Bottom CTA */}
{!loading && modes.length > 0 && (
<View style={[styles.bottomBar, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.border }]}>
<TouchableOpacity
onPress={handleConfirmPayment}
disabled={!selectedMode || paying}
activeOpacity={0.8}
style={[
styles.confirmButton,
{
backgroundColor: selectedMode ? theme.colors.primary : theme.colors.textMuted,
opacity: paying ? 0.7 : 1,
},
]}
>
{paying ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<>
<LucideIcon name="shield-checkmark-outline" size={20} color={theme.colors.reverseText} style={{ marginRight: 8 }} />
<Text style={[styles.confirmText, { color: theme.colors.black }]}>Confirm & Pay</Text>
</>
)}
</TouchableOpacity>
</View>
)}
{/* Processing Modal — non-dismissable while polling */}
<Modal
visible={processingVisible}
transparent
animationType="fade"
onRequestClose={() => {
// Only allow closing if not actively polling
if (processingStatus !== 'polling') handleCloseProcessingModal();
}}
>
<View style={modalStyles.overlay}>
<View style={[modalStyles.card, { backgroundColor: theme.colors.card }]}>
{/* Icon / Spinner */}
{processingStatus === 'polling' ? (
<Animated.View style={{
transform: [{
rotate: spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
}),
}],
}}>
<LucideIcon name="sync-outline" size={52} color={theme.colors.primary} />
</Animated.View>
) : processingStatus === 'success' ? (
<View style={[modalStyles.iconCircle, { backgroundColor: '#2BAF2B22' }]}>
<LucideIcon name="checkmark-circle-outline" size={52} color="#2BAF2B" />
</View>
) : (
<View style={[modalStyles.iconCircle, { backgroundColor: theme.colors.error + '22' }]}>
<LucideIcon name="close-circle-outline" size={52} color={theme.colors.error} />
</View>
)}
{/* Title */}
<Text style={[modalStyles.title, { color: theme.colors.textPrimary }]}>
{processingStatus === 'polling' ? 'Processing Payment' :
processingStatus === 'success' ? 'Payment Successful' :
processingStatus === 'timeout' ? 'Verification Timed Out' : 'Payment Failed'}
</Text>
{/* Message */}
<Text style={[modalStyles.message, { color: theme.colors.textSecondary }]}>
{processingMessage}
</Text>
{/* Countdown timer while polling */}
{processingStatus === 'polling' && (
<View style={modalStyles.timerContainer}>
<LucideIcon name="time-outline" size={16} color={theme.colors.textMuted} />
<Text style={[modalStyles.timerText, { color: theme.colors.textMuted }]}>
{Math.floor(timeRemaining / 60)}:{String(timeRemaining % 60).padStart(2, '0')} remaining
</Text>
</View>
)}
{/* Close button — only visible when NOT polling */}
{processingStatus !== 'polling' && (
<TouchableOpacity
onPress={handleCloseProcessingModal}
style={[
modalStyles.closeButton,
{
backgroundColor: processingStatus === 'success'
? '#2BAF2B'
: theme.colors.primary,
},
]}
activeOpacity={0.8}
>
<Text style={modalStyles.closeButtonText}>
{processingStatus === 'success' ? 'Done' : 'Close'}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
</AppView>
);
};
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,
},
});