601 lines
18 KiB
TypeScript
601 lines
18 KiB
TypeScript
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;
|