theme update latest project

This commit is contained in:
Abin Toppo
2026-02-16 10:41:38 +05:30
parent bd7dbd864c
commit a98e0b6cdd
75 changed files with 10173 additions and 7915 deletions
+754
View File
@@ -0,0 +1,754 @@
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;
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;
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="#fff" style={{ marginRight: 8 }} />
<Text style={styles.confirmText}>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: {
color: '#fff',
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,
},
});