first commit
This commit is contained in:
600
unionflow-mobile-apps/src/screens/payment/WavePaymentScreen.tsx
Normal file
600
unionflow-mobile-apps/src/screens/payment/WavePaymentScreen.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Alert,
|
||||
Vibration,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
Divider,
|
||||
Chip,
|
||||
} from 'react-native-paper';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { theme } from '../../theme/theme';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { useWavePayment } from '../../contexts/WavePaymentContext';
|
||||
import { WavePaymentService } from '../../services/WavePaymentService';
|
||||
import { AnalyticsService } from '../../services/AnalyticsService';
|
||||
import { HapticService } from '../../services/HapticService';
|
||||
|
||||
type WavePaymentScreenRouteProp = RouteProp<RootStackParamList, 'WavePayment'>;
|
||||
type WavePaymentScreenNavigationProp = StackNavigationProp<RootStackParamList, 'WavePayment'>;
|
||||
|
||||
interface Props {
|
||||
route: WavePaymentScreenRouteProp;
|
||||
navigation: WavePaymentScreenNavigationProp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran de paiement Wave Money ultra moderne
|
||||
*
|
||||
* Interface intuitive et sécurisée pour les paiements Wave Money en Côte d'Ivoire :
|
||||
* - Design moderne avec animations fluides
|
||||
* - Validation en temps réel des numéros Wave
|
||||
* - Calcul automatique des frais
|
||||
* - Feedback visuel et haptique
|
||||
* - Gestion des erreurs élégante
|
||||
* - Support hors ligne avec synchronisation
|
||||
*
|
||||
* @author Lions Dev Team
|
||||
* @version 2.0.0
|
||||
* @since 2025-01-16
|
||||
*/
|
||||
const WavePaymentScreen: React.FC<Props> = ({ route, navigation }) => {
|
||||
const { type, amount, description, metadata } = route.params;
|
||||
const { initiatePayment, isLoading } = useWavePayment();
|
||||
|
||||
const [fees, setFees] = useState<{ base: string; fees: string; total: string } | null>(null);
|
||||
const [isCalculatingFees, setIsCalculatingFees] = useState(false);
|
||||
const [paymentStatus, setPaymentStatus] = useState<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
|
||||
// Animations
|
||||
const cardScale = useSharedValue(1);
|
||||
const waveIconRotation = useSharedValue(0);
|
||||
const successScale = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Animation d'entrée
|
||||
cardScale.value = withSpring(1, { damping: 15 });
|
||||
|
||||
// Calculer les frais automatiquement
|
||||
if (amount) {
|
||||
calculateFees(amount);
|
||||
}
|
||||
|
||||
// Analytics
|
||||
AnalyticsService.trackEvent('wave_payment_screen_opened', {
|
||||
type,
|
||||
amount,
|
||||
});
|
||||
}, [amount]);
|
||||
|
||||
/**
|
||||
* Schéma de validation Yup pour le formulaire
|
||||
*/
|
||||
const validationSchema = Yup.object().shape({
|
||||
phoneNumber: Yup.string()
|
||||
.required('Le numéro de téléphone est obligatoire')
|
||||
.matches(/^\+225[0-9]{8}$/, 'Format invalide. Utilisez +225XXXXXXXX')
|
||||
.test('wave-number', 'Ce numéro ne semble pas être un numéro Wave valide', (value) => {
|
||||
// Validation basique des numéros Wave CI
|
||||
if (!value) return false;
|
||||
const number = value.replace('+225', '');
|
||||
// Les numéros Wave commencent généralement par 01, 05, 07
|
||||
return /^(01|05|07)[0-9]{6}$/.test(number);
|
||||
}),
|
||||
amount: Yup.string()
|
||||
.required('Le montant est obligatoire')
|
||||
.test('min-amount', 'Montant minimum : 100 FCFA', (value) => {
|
||||
return value ? parseFloat(value) >= 100 : false;
|
||||
})
|
||||
.test('max-amount', 'Montant maximum : 1,000,000 FCFA', (value) => {
|
||||
return value ? parseFloat(value) <= 1000000 : false;
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Calcule les frais Wave Money
|
||||
*/
|
||||
const calculateFees = async (amount: string) => {
|
||||
setIsCalculatingFees(true);
|
||||
try {
|
||||
const result = await WavePaymentService.calculateFees(amount);
|
||||
setFees(result);
|
||||
|
||||
// Animation de l'icône Wave
|
||||
waveIconRotation.value = withSequence(
|
||||
withTiming(360, { duration: 500 }),
|
||||
withTiming(0, { duration: 0 })
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Erreur calcul frais:', error);
|
||||
} finally {
|
||||
setIsCalculatingFees(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Traite le paiement Wave Money
|
||||
*/
|
||||
const handlePayment = async (values: { phoneNumber: string; amount: string }) => {
|
||||
try {
|
||||
setPaymentStatus('processing');
|
||||
HapticService.impact('medium');
|
||||
|
||||
// Animation de traitement
|
||||
cardScale.value = withSpring(0.95);
|
||||
|
||||
const paymentRequest = {
|
||||
type,
|
||||
amount: values.amount,
|
||||
phoneNumber: values.phoneNumber,
|
||||
description,
|
||||
metadata,
|
||||
};
|
||||
|
||||
const result = await initiatePayment(paymentRequest);
|
||||
|
||||
if (result.success) {
|
||||
setPaymentStatus('success');
|
||||
successScale.value = withSpring(1);
|
||||
HapticService.success();
|
||||
|
||||
// Vibration de succès
|
||||
if (Platform.OS === 'android') {
|
||||
Vibration.vibrate([100, 200, 100]);
|
||||
}
|
||||
|
||||
AnalyticsService.trackEvent('wave_payment_success', {
|
||||
type,
|
||||
amount: values.amount,
|
||||
transactionId: result.transactionId,
|
||||
});
|
||||
|
||||
Alert.alert(
|
||||
'Paiement initié',
|
||||
`Votre paiement de ${values.amount} FCFA a été initié avec succès.\n\nTransaction ID: ${result.transactionId}`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => navigation.goBack(),
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
throw new Error(result.error || 'Erreur lors du paiement');
|
||||
}
|
||||
} catch (error) {
|
||||
setPaymentStatus('error');
|
||||
HapticService.error();
|
||||
|
||||
AnalyticsService.trackEvent('wave_payment_error', {
|
||||
type,
|
||||
amount: values.amount,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
Alert.alert(
|
||||
'Erreur de paiement',
|
||||
error.message || 'Une erreur est survenue lors du paiement. Veuillez réessayer.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} finally {
|
||||
cardScale.value = withSpring(1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate un numéro de téléphone Wave
|
||||
*/
|
||||
const formatPhoneNumber = (text: string) => {
|
||||
// Supprimer tous les caractères non numériques sauf +
|
||||
let cleaned = text.replace(/[^\d+]/g, '');
|
||||
|
||||
// Ajouter +225 si pas présent
|
||||
if (!cleaned.startsWith('+225')) {
|
||||
if (cleaned.startsWith('225')) {
|
||||
cleaned = '+' + cleaned;
|
||||
} else if (cleaned.startsWith('0')) {
|
||||
cleaned = '+225' + cleaned.substring(1);
|
||||
} else if (cleaned.length > 0 && !cleaned.startsWith('+')) {
|
||||
cleaned = '+225' + cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
// Limiter à +225 + 8 chiffres
|
||||
if (cleaned.length > 12) {
|
||||
cleaned = cleaned.substring(0, 12);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: cardScale.value }],
|
||||
}));
|
||||
|
||||
const waveIconAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${waveIconRotation.value}deg` }],
|
||||
}));
|
||||
|
||||
const successAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: successScale.value }],
|
||||
opacity: successScale.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
|
||||
<Animated.View style={[cardAnimatedStyle]}>
|
||||
{/* En-tête Wave Money */}
|
||||
<Card style={styles.waveCard}>
|
||||
<View style={styles.waveHeader}>
|
||||
<Animated.View style={waveIconAnimatedStyle}>
|
||||
<Icon name="wave" size={32} color={theme.custom.colors.textOnPrimary} />
|
||||
</Animated.View>
|
||||
<View style={styles.waveInfo}>
|
||||
<Text style={styles.waveTitle}>Wave Money</Text>
|
||||
<Text style={styles.waveSubtitle}>Paiement mobile sécurisé</Text>
|
||||
</View>
|
||||
<Chip
|
||||
mode="outlined"
|
||||
textStyle={styles.chipText}
|
||||
style={styles.chip}
|
||||
>
|
||||
Côte d'Ivoire
|
||||
</Chip>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Détails du paiement */}
|
||||
<Card style={styles.detailsCard}>
|
||||
<Text style={styles.sectionTitle}>Détails du paiement</Text>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Type :</Text>
|
||||
<Text style={styles.detailValue}>{getPaymentTypeLabel(type)}</Text>
|
||||
</View>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Description :</Text>
|
||||
<Text style={styles.detailValue}>{description}</Text>
|
||||
</View>
|
||||
<Divider style={styles.divider} />
|
||||
|
||||
{/* Calcul des frais */}
|
||||
{fees && (
|
||||
<View style={styles.feesContainer}>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Montant :</Text>
|
||||
<Text style={styles.detailValue}>{fees.base}</Text>
|
||||
</View>
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>Frais Wave :</Text>
|
||||
<Text style={styles.feesValue}>{fees.fees}</Text>
|
||||
</View>
|
||||
<Divider style={styles.divider} />
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.totalLabel}>Total à payer :</Text>
|
||||
<Text style={styles.totalValue}>{fees.total}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isCalculatingFees && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
<Text style={styles.loadingText}>Calcul des frais...</Text>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Formulaire de paiement */}
|
||||
<Formik
|
||||
initialValues={{
|
||||
phoneNumber: '',
|
||||
amount: amount || '',
|
||||
}}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={handlePayment}
|
||||
>
|
||||
{({ handleChange, handleBlur, handleSubmit, values, errors, touched, setFieldValue }) => (
|
||||
<Card style={styles.formCard}>
|
||||
<Text style={styles.sectionTitle}>Informations de paiement</Text>
|
||||
|
||||
<TextInput
|
||||
label="Numéro Wave Money"
|
||||
value={values.phoneNumber}
|
||||
onChangeText={(text) => {
|
||||
const formatted = formatPhoneNumber(text);
|
||||
setFieldValue('phoneNumber', formatted);
|
||||
}}
|
||||
onBlur={handleBlur('phoneNumber')}
|
||||
error={touched.phoneNumber && !!errors.phoneNumber}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
left={<TextInput.Icon icon="phone" />}
|
||||
placeholder="+225XXXXXXXX"
|
||||
keyboardType="phone-pad"
|
||||
maxLength={12}
|
||||
/>
|
||||
{touched.phoneNumber && errors.phoneNumber && (
|
||||
<Text style={styles.errorText}>{errors.phoneNumber}</Text>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Montant (FCFA)"
|
||||
value={values.amount}
|
||||
onChangeText={(text) => {
|
||||
handleChange('amount')(text);
|
||||
if (text && parseFloat(text) >= 100) {
|
||||
calculateFees(text);
|
||||
}
|
||||
}}
|
||||
onBlur={handleBlur('amount')}
|
||||
error={touched.amount && !!errors.amount}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
left={<TextInput.Icon icon="currency-usd" />}
|
||||
placeholder="0"
|
||||
keyboardType="numeric"
|
||||
editable={!amount} // Désactiver si montant prédéfini
|
||||
/>
|
||||
{touched.amount && errors.amount && (
|
||||
<Text style={styles.errorText}>{errors.amount}</Text>
|
||||
)}
|
||||
|
||||
{/* Bouton de paiement */}
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSubmit}
|
||||
loading={isLoading || paymentStatus === 'processing'}
|
||||
disabled={isLoading || paymentStatus === 'processing'}
|
||||
style={styles.payButton}
|
||||
contentStyle={styles.payButtonContent}
|
||||
labelStyle={styles.payButtonLabel}
|
||||
icon="credit-card"
|
||||
>
|
||||
{paymentStatus === 'processing' ? 'Traitement...' : 'Payer avec Wave'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
{/* Indicateur de succès */}
|
||||
{paymentStatus === 'success' && (
|
||||
<Animated.View style={[styles.successContainer, successAnimatedStyle]}>
|
||||
<Icon name="check-circle" size={64} color={theme.custom.colors.success} />
|
||||
<Text style={styles.successText}>Paiement initié avec succès !</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Informations de sécurité */}
|
||||
<Card style={styles.securityCard}>
|
||||
<View style={styles.securityHeader}>
|
||||
<Icon name="shield-check" size={24} color={theme.custom.colors.success} />
|
||||
<Text style={styles.securityTitle}>Paiement sécurisé</Text>
|
||||
</View>
|
||||
<Text style={styles.securityText}>
|
||||
Vos informations sont protégées par un chiffrement de niveau bancaire.
|
||||
Wave Money est agréé par la BCEAO pour les services de paiement mobile.
|
||||
</Text>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retourne le libellé du type de paiement
|
||||
*/
|
||||
const getPaymentTypeLabel = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'cotisation': return 'Cotisation';
|
||||
case 'adhesion': return 'Adhésion';
|
||||
case 'aide': return 'Aide mutuelle';
|
||||
case 'evenement': return 'Événement';
|
||||
default: return 'Paiement';
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.custom.spacing.md,
|
||||
},
|
||||
|
||||
waveCard: {
|
||||
backgroundColor: theme.custom.colors.wave,
|
||||
marginBottom: theme.custom.spacing.md,
|
||||
borderRadius: theme.custom.dimensions.borderRadius.lg,
|
||||
},
|
||||
|
||||
waveHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: theme.custom.spacing.lg,
|
||||
},
|
||||
|
||||
waveInfo: {
|
||||
flex: 1,
|
||||
marginLeft: theme.custom.spacing.md,
|
||||
},
|
||||
|
||||
waveTitle: {
|
||||
fontSize: theme.custom.typography.sizes.xl,
|
||||
fontWeight: theme.custom.typography.weights.bold,
|
||||
color: theme.custom.colors.textOnPrimary,
|
||||
},
|
||||
|
||||
waveSubtitle: {
|
||||
fontSize: theme.custom.typography.sizes.sm,
|
||||
color: theme.custom.colors.textOnPrimary,
|
||||
opacity: 0.8,
|
||||
},
|
||||
|
||||
chip: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
|
||||
chipText: {
|
||||
color: theme.custom.colors.textOnPrimary,
|
||||
fontSize: theme.custom.typography.sizes.xs,
|
||||
},
|
||||
|
||||
detailsCard: {
|
||||
marginBottom: theme.custom.spacing.md,
|
||||
padding: theme.custom.spacing.lg,
|
||||
},
|
||||
|
||||
formCard: {
|
||||
marginBottom: theme.custom.spacing.md,
|
||||
padding: theme.custom.spacing.lg,
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: theme.custom.typography.sizes.lg,
|
||||
fontWeight: theme.custom.typography.weights.semibold,
|
||||
color: theme.custom.colors.text,
|
||||
marginBottom: theme.custom.spacing.md,
|
||||
},
|
||||
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.custom.spacing.sm,
|
||||
},
|
||||
|
||||
detailLabel: {
|
||||
fontSize: theme.custom.typography.sizes.base,
|
||||
color: theme.custom.colors.textSecondary,
|
||||
},
|
||||
|
||||
detailValue: {
|
||||
fontSize: theme.custom.typography.sizes.base,
|
||||
fontWeight: theme.custom.typography.weights.medium,
|
||||
color: theme.custom.colors.text,
|
||||
},
|
||||
|
||||
feesContainer: {
|
||||
marginTop: theme.custom.spacing.sm,
|
||||
},
|
||||
|
||||
feesValue: {
|
||||
fontSize: theme.custom.typography.sizes.base,
|
||||
fontWeight: theme.custom.typography.weights.medium,
|
||||
color: theme.custom.colors.warning,
|
||||
},
|
||||
|
||||
totalLabel: {
|
||||
fontSize: theme.custom.typography.sizes.lg,
|
||||
fontWeight: theme.custom.typography.weights.semibold,
|
||||
color: theme.custom.colors.text,
|
||||
},
|
||||
|
||||
totalValue: {
|
||||
fontSize: theme.custom.typography.sizes.lg,
|
||||
fontWeight: theme.custom.typography.weights.bold,
|
||||
color: theme.custom.colors.wave,
|
||||
},
|
||||
|
||||
divider: {
|
||||
marginVertical: theme.custom.spacing.sm,
|
||||
},
|
||||
|
||||
loadingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: theme.custom.spacing.md,
|
||||
},
|
||||
|
||||
loadingText: {
|
||||
marginLeft: theme.custom.spacing.sm,
|
||||
color: theme.custom.colors.textSecondary,
|
||||
},
|
||||
|
||||
input: {
|
||||
marginBottom: theme.custom.spacing.md,
|
||||
},
|
||||
|
||||
errorText: {
|
||||
color: theme.custom.colors.error,
|
||||
fontSize: theme.custom.typography.sizes.sm,
|
||||
marginTop: -theme.custom.spacing.sm,
|
||||
marginBottom: theme.custom.spacing.sm,
|
||||
},
|
||||
|
||||
payButton: {
|
||||
backgroundColor: theme.custom.colors.wave,
|
||||
marginTop: theme.custom.spacing.md,
|
||||
borderRadius: theme.custom.dimensions.borderRadius.md,
|
||||
},
|
||||
|
||||
payButtonContent: {
|
||||
height: theme.custom.dimensions.buttonHeights.lg,
|
||||
},
|
||||
|
||||
payButtonLabel: {
|
||||
fontSize: theme.custom.typography.sizes.lg,
|
||||
fontWeight: theme.custom.typography.weights.semibold,
|
||||
},
|
||||
|
||||
successContainer: {
|
||||
alignItems: 'center',
|
||||
padding: theme.custom.spacing.xl,
|
||||
},
|
||||
|
||||
successText: {
|
||||
fontSize: theme.custom.typography.sizes.lg,
|
||||
fontWeight: theme.custom.typography.weights.semibold,
|
||||
color: theme.custom.colors.success,
|
||||
marginTop: theme.custom.spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
securityCard: {
|
||||
backgroundColor: theme.custom.colors.success + '10',
|
||||
padding: theme.custom.spacing.lg,
|
||||
marginTop: theme.custom.spacing.md,
|
||||
},
|
||||
|
||||
securityHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.custom.spacing.sm,
|
||||
},
|
||||
|
||||
securityTitle: {
|
||||
fontSize: theme.custom.typography.sizes.base,
|
||||
fontWeight: theme.custom.typography.weights.semibold,
|
||||
color: theme.custom.colors.success,
|
||||
marginLeft: theme.custom.spacing.sm,
|
||||
},
|
||||
|
||||
securityText: {
|
||||
fontSize: theme.custom.typography.sizes.sm,
|
||||
color: theme.custom.colors.textSecondary,
|
||||
lineHeight: theme.custom.typography.lineHeights.relaxed * theme.custom.typography.sizes.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default WavePaymentScreen;
|
||||
Reference in New Issue
Block a user