Files
projet_gestion_commande/frontend-prep/src/pages/User/Checkout.tsx
T
2026-05-03 19:38:29 +02:00

807 lines
30 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCart } from '../../context/useCart';
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated, getReferralBalance, getPublicSettings, getMyProfile, getCryptoPaymentStatus, getProductById, getMediaUrl } from '../../api/api';
import type { CheckoutData, CryptoPaymentStatus } from '../../api/api';
import type { Product } from '../../api/api';
import Navbar from '../../components/Navbar';
import './Checkout.css';
// ============================================
// Interface pour les données du modal
// ============================================
interface ConfirmationData {
command_id: number;
client_order_number?: number;
assigned_to?: {
username: string;
distance_km?: number;
eta_minutes?: number;
};
queue_info?: {
position: number;
estimated_wait?: string;
};
delivery_address: string;
arrivalTime: string;
total: number;
referral_used?: number;
clientInfo: {
first_name: string;
last_name: string;
phone: string;
delivery_address: string;
};
}
function Checkout() {
const navigate = useNavigate();
const { cartItems, clearCart: clearCartContext, cartTotal } = useCart();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// État pour le modal de confirmation
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmationData, setConfirmationData] = useState<ConfirmationData | null>(null);
// État pour le modal zone non desservie
const [showZoneModal, setShowZoneModal] = useState(false);
const [zoneErrorMsg, setZoneErrorMsg] = useState('');
// Informations personnelles
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [address, setAddress] = useState('');
const [phone, setPhone] = useState('');
const total = cartTotal;
// Images enrichies
const [itemImages, setItemImages] = useState<Record<number, string>>({});
useEffect(() => {
if (cartItems.length === 0) return;
cartItems.forEach(async (item) => {
try {
const res = await getProductById(item.product_id);
if (res.success && res.data) {
const p: Product = res.data;
const img = p.media?.find((m) => m && m.type === 'image');
if (img?.url) {
setItemImages((prev) => ({ ...prev, [item.id]: getMediaUrl(img.url) }));
}
}
} catch { /* ignore */ }
});
}, [cartItems]);
// Parrainage
const [referralBalance, setReferralBalance] = useState(0);
const [referralEnabled, setReferralEnabled] = useState(false);
const [useReferral, setUseReferral] = useState(false);
// Crypto
const [cryptoEnabled, setCryptoEnabled] = useState(false);
const [cryptoOnly, setCryptoOnly] = useState(false);
const [cryptoCurrencies, setCryptoCurrencies] = useState<string[]>([]);
const [paymentMethod, setPaymentMethod] = useState<'especes' | 'crypto'>('especes');
const [payCurrency, setPayCurrency] = useState('');
const [cryptoPaymentData, setCryptoPaymentData] = useState<CryptoPaymentStatus | null>(null);
const [showCryptoModal, setShowCryptoModal] = useState(false);
const [cryptoPolling, setCryptoPolling] = useState(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isUserAuthenticated()) {
console.log('❌ [Checkout] Utilisateur non authentifié, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
};
checkAuth();
}, [navigate]);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isUserAuthenticated()) {
console.log('❌ [Checkout] Session expirée, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
// Charger les infos par défaut depuis le profil
useEffect(() => {
const savedAddress = localStorage.getItem('profile_default_address');
const savedPhone = localStorage.getItem('profile_default_phone');
if (savedAddress) setAddress(savedAddress);
if (savedPhone) setPhone(savedPhone);
// Pré-remplir nom/prénom depuis le backend
getMyProfile().then((res) => {
if (res.success && res.client) {
if (res.client.nom) setLastName(res.client.nom);
if (res.client.prenom) setFirstName(res.client.prenom);
}
});
}, []);
// Charger settings publics (parrainage + crypto)
useEffect(() => {
getPublicSettings().then((settings) => {
if (settings.referral_enabled) {
setReferralEnabled(true);
getReferralBalance().then((res) => {
if (res.success) setReferralBalance(res.balance);
});
}
if (settings.crypto_payment_enabled && settings.nowpayments_currencies.length > 0) {
setCryptoEnabled(true);
setCryptoCurrencies(settings.nowpayments_currencies);
setPayCurrency(settings.nowpayments_currencies[0]);
if (settings.crypto_only) {
setCryptoOnly(true);
setPaymentMethod('crypto');
}
}
});
}, []);
// Nettoyage du polling au démontage
useEffect(() => {
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, []);
/**
* ✅ Récupérer le username du JWT
*/
const getUsername = (): string | null => {
const username = extractUsernameFromToken();
if (!username) {
console.warn('⚠️ Impossible d\'extraire le username du JWT');
return null;
}
console.log('✅ Username du JWT:', username);
return username;
};
/**
* Calculer et formater l'heure d'arrivée estimée
*/
const calculateArrivalTime = (eta_minutes?: number, estimated_wait?: string): string => {
const arrivalTime = new Date();
let totalMinutes = 0;
if (eta_minutes) {
totalMinutes = eta_minutes;
} else if (estimated_wait) {
const hourMatch = estimated_wait.match(/(\d+)\s*hour/i);
const minuteMatch = estimated_wait.match(/(\d+)\s*minute/i);
if (hourMatch) {
totalMinutes += parseInt(hourMatch[1]) * 60;
}
if (minuteMatch) {
totalMinutes += parseInt(minuteMatch[1]);
}
}
arrivalTime.setMinutes(arrivalTime.getMinutes() + totalMinutes);
return arrivalTime.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
};
const startCryptoPolling = (commandId: number) => {
setCryptoPolling(true);
pollIntervalRef.current = setInterval(async () => {
const status = await getCryptoPaymentStatus(commandId);
if (!status) return;
setCryptoPaymentData(status);
if (status.payment_status === 'finished' || status.payment_status === 'confirmed') {
stopCryptoPolling();
const username = extractUsernameFromToken();
if (username) {
await clearCart(username);
await clearCartContext();
}
} else if (status.payment_status === 'failed' || status.payment_status === 'expired') {
stopCryptoPolling();
}
}, 10000);
};
const stopCryptoPolling = () => {
setCryptoPolling(false);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
const handleCryptoModalClose = () => {
stopCryptoPolling();
setShowCryptoModal(false);
navigate('/user/suivi-livraison');
};
/**
* ✅ Gérer la soumission de la commande
*/
const handleSubmitOrder = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// ✅ Vérifier l'auth avant de soumettre
if (!isUserAuthenticated()) {
console.log('❌ [handleSubmitOrder] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
// Validation
if (!firstName || !lastName || !address || !phone) {
setError('Veuillez remplir tous les champs obligatoires');
return;
}
const username = getUsername();
if (!username) {
setError('❌ Session expirée - Veuillez vous reconnecter');
navigate('/login/client', { replace: true });
return;
}
console.log('👤 Username du JWT:', username);
setLoading(true);
try {
const checkoutData: CheckoutData = {
username,
delivery_address: address,
first_name: firstName,
last_name: lastName,
phone,
payment_method: paymentMethod === 'crypto' ? 'crypto' : 'especes',
pay_currency: paymentMethod === 'crypto' ? payCurrency : undefined,
use_referral_balance: useReferral && referralBalance > 0,
};
console.log('📤 Envoi checkout avec JWT username:', checkoutData);
const response = await createCheckout(checkoutData);
console.log('📥 Réponse checkout:', response);
// Paiement crypto : afficher le modal avec l'adresse wallet
if (response.success && response.payment_method === 'crypto') {
setCryptoPaymentData({
command_id: response.command_id!,
client_order_number: (response as Record<string, unknown>).client_order_number as number | undefined,
payment_status: response.payment_status!,
pay_address: response.pay_address!,
pay_amount: response.pay_amount!,
pay_currency: response.pay_currency!,
price_amount: response.price_amount!,
price_currency: response.price_currency!,
});
setShowCryptoModal(true);
startCryptoPolling(response.command_id);
return;
}
if (!response.success && response.zone_error) {
setZoneErrorMsg(response.message);
setShowZoneModal(true);
return;
}
if (response.success && response.command_id) {
const { command_id, assigned_to, queue_info, delivery_address } = response;
const frontendTotal = total;
console.log(`💰 Total commande: ${frontendTotal.toFixed(2)}€`);
// Calculer l'heure d'arrivée
const arrivalTime = calculateArrivalTime(
assigned_to?.eta_minutes,
queue_info?.estimated_wait
);
// ✅ Vider le panier AVANT d'afficher la confirmation
console.log('🗑️ [CHECKOUT] Vidage du panier après commande réussie...');
try {
const clearResponse = await clearCart(username);
console.log('📥 [CHECKOUT] Réponse clearCart:', clearResponse);
if (clearResponse.success) {
await clearCartContext();
console.log('✅ [CHECKOUT] Panier vidé avec succès');
} else {
console.warn('⚠️ [CHECKOUT] Erreur API clearCart (non bloquant):', clearResponse.message);
await clearCartContext();
}
} catch (clearErr) {
console.warn('⚠️ [CHECKOUT] Erreur au vidage du panier (non bloquant):', clearErr);
try {
await clearCartContext();
} catch (localClearErr) {
console.error('❌ [CHECKOUT] Impossible de vider le panier local:', localClearErr);
}
}
// ✅ Préparer les données pour le modal
setConfirmationData({
command_id,
client_order_number: (response as Record<string, unknown>).client_order_number as number | undefined,
assigned_to,
queue_info,
delivery_address: delivery_address || address,
arrivalTime,
total: frontendTotal,
referral_used: (response as Record<string, unknown>).referral_used as number | undefined,
clientInfo: {
first_name: firstName,
last_name: lastName,
phone,
delivery_address: address
}
});
// ✅ Afficher le modal
setShowConfirmation(true);
} else {
setError(response.message || '❌ Erreur lors de la validation de la commande');
}
} catch (err: unknown) {
console.error('❌ Erreur checkout:', err);
setError(err instanceof Error ? err.message : '❌ Erreur serveur. Veuillez réessayer.');
} finally {
setLoading(false);
}
};
/**
* ✅ Fermer le modal et rediriger
*/
const handleCloseConfirmation = () => {
setShowConfirmation(false);
setConfirmationData(null);
navigate('/user/suivi-livraison');
};
return (
<>
<Navbar />
<div className="checkout-container">
<h1>Finaliser la commande</h1>
<div className="checkout-content">
{/* Résumé de la commande */}
<div className="order-summary-box">
<h2>Résumé de la commande</h2>
<div className="summary-items">
{cartItems.map((item, index) => (
<div key={`${item.id}-${index}`} className="summary-item">
{itemImages[item.id] ? (
<img src={itemImages[item.id]} alt={item.name_product || 'Produit'} className="summary-item-image" />
) : (
<div className="summary-item-image summary-item-image--placeholder">
<i className="fas fa-leaf" />
</div>
)}
<div className="summary-item-info">
<p className="summary-item-name">{item.name_product}</p>
<p className="summary-item-details">
{item.quantity}x - {item.price.toFixed(2)}
</p>
</div>
</div>
))}
</div>
<div className="summary-total">
<span>Total:</span>
<span className="total-price">{total.toFixed(2)} </span>
</div>
</div>
{/* Formulaire */}
<form className="checkout-form" onSubmit={handleSubmitOrder}>
{error && (
<div className="error-alert">
⚠️ {error}
</div>
)}
<div className="form-section">
<h3>Informations personnelles</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="firstName">Prénom *</label>
<input
type="text"
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="lastName">Nom *</label>
<input
type="text"
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={loading}
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="address">Adresse de livraison *</label>
<input
type="text"
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Numéro, rue, ville, code postal"
disabled={loading}
required
/>
{localStorage.getItem('profile_default_address') && (
<p className="form-prefill-hint"><i className="fas fa-map-marker-alt" /> Pré-rempli depuis votre profil modifiez si vous êtes ailleurs</p>
)}
</div>
<div className="form-group">
<label htmlFor="phone">Téléphone *</label>
<input
type="tel"
id="phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+33 6 12 34 56 78"
disabled={loading}
required
/>
{localStorage.getItem('profile_default_phone') && (
<p className="form-prefill-hint"><i className="fas fa-phone" /> Pré-rempli depuis votre profil</p>
)}
</div>
</div>
{/* Méthode de paiement */}
{cryptoEnabled && (
<div className="form-section">
<h3>Méthode de paiement</h3>
{cryptoOnly ? (
<div className="crypto-only-badge">
<i className="fas fa-coins" /> Paiement uniquement en cryptomonnaie
</div>
) : (
<div className="payment-method-selector">
<button
type="button"
className={`payment-method-btn ${paymentMethod === 'especes' ? 'active' : ''}`}
onClick={() => setPaymentMethod('especes')}
disabled={loading}
>
<i className="fas fa-money-bill-wave" /> Espèces
</button>
<button
type="button"
className={`payment-method-btn ${paymentMethod === 'crypto' ? 'active' : ''}`}
onClick={() => setPaymentMethod('crypto')}
disabled={loading}
>
<i className="fas fa-coins" /> Crypto
</button>
</div>
)}
{(paymentMethod === 'crypto') && (
<div className="crypto-currency-selector">
<label>Cryptomonnaie</label>
<div className="crypto-currency-grid">
{cryptoCurrencies.map((currency) => (
<button
key={currency}
type="button"
className={`crypto-currency-btn ${payCurrency === currency ? 'active' : ''}`}
onClick={() => setPayCurrency(currency)}
disabled={loading}
>
{currency.toUpperCase()}
</button>
))}
</div>
<p className="crypto-info-hint">
<i className="fas fa-info-circle" /> Vous recevrez l'adresse de paiement après validation
</p>
</div>
)}
</div>
)}
{/* Toggle parrainage */}
{referralEnabled && referralBalance > 0 && (
<div className="referral-toggle-box">
<div className="referral-toggle-info">
<span className="referral-toggle-icon"><i className="fas fa-gift"></i></span>
<div>
<p className="referral-toggle-label">Solde parrainage</p>
<p className="referral-toggle-balance">{referralBalance.toFixed(2)} € disponible</p>
</div>
</div>
<label className="referral-switch">
<input
type="checkbox"
checked={useReferral}
onChange={(e) => setUseReferral(e.target.checked)}
disabled={loading}
/>
<span className="referral-switch-slider" />
</label>
</div>
)}
<div className="checkout-telegram-note">
<i className="fab fa-telegram" />
<div>
<strong>Compte Telegram requis</strong>
<p>
Votre commande ne pourra être validée que si votre compte Telegram est lié.
Rendez-vous dans votre <a href="/user/profil">profil</a> pour le lier avant de confirmer.
</p>
</div>
</div>
<div className="form-actions">
<button
type="button"
className="back-button"
onClick={() => navigate('/user/panier')}
disabled={loading}
>
← Retour au panier
</button>
<button
type="submit"
className="submit-order-button"
disabled={loading}
>
{loading ? 'Validation en cours...' : 'Passer la commande'}
</button>
</div>
</form>
</div>
</div>
{/* ============================================ */}
{/* MODAL PAIEMENT CRYPTO */}
{/* ============================================ */}
{showCryptoModal && cryptoPaymentData && (
<div className="confirmation-modal-overlay">
<div className="confirmation-modal">
<div className="confirmation-modal-header confirmation-modal-header--crypto">
<i className="fas fa-coins confirmation-icon" />
<h2>Paiement Crypto</h2>
</div>
<div className="confirmation-modal-body">
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-receipt icon" /> Commande #{cryptoPaymentData.client_order_number ?? cryptoPaymentData.command_id}
</div>
<div className="confirmation-detail">
<strong>Statut :</strong>{' '}
<span className={`crypto-status crypto-status--${cryptoPaymentData.payment_status}`}>
{cryptoPaymentData.payment_status === 'waiting' && ' En attente de paiement'}
{cryptoPaymentData.payment_status === 'confirming' && '🔄 Confirmation en cours...'}
{cryptoPaymentData.payment_status === 'confirmed' && ' Confirmé'}
{cryptoPaymentData.payment_status === 'finished' && ' Paiement reçu !'}
{cryptoPaymentData.payment_status === 'failed' && ' Paiement échoué'}
{cryptoPaymentData.payment_status === 'expired' && ' Expiré'}
{!['waiting','confirming','confirmed','finished','failed','expired'].includes(cryptoPaymentData.payment_status) && cryptoPaymentData.payment_status}
</span>
</div>
</div>
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-wallet icon" /> Adresse de paiement
</div>
<div className="crypto-address-box">
<code className="crypto-address">{cryptoPaymentData.pay_address}</code>
<button
type="button"
className="crypto-copy-btn"
onClick={() => navigator.clipboard.writeText(cryptoPaymentData.pay_address)}
>
<i className="fas fa-copy" /> Copier
</button>
</div>
</div>
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-coins icon" /> Montant à envoyer
</div>
<div className="confirmation-detail">
<strong>{cryptoPaymentData.pay_amount} {cryptoPaymentData.pay_currency.toUpperCase()}</strong>
<span className="crypto-equiv"> ≈ {cryptoPaymentData.price_amount.toFixed(2)} {cryptoPaymentData.price_currency.toUpperCase()}</span>
</div>
</div>
{cryptoPolling && (
<div className="crypto-polling-info">
<i className="fas fa-spinner fa-spin" /> Vérification automatique toutes les 10 secondes...
</div>
)}
{(cryptoPaymentData.payment_status === 'finished' || cryptoPaymentData.payment_status === 'confirmed') && (
<div className="crypto-success-msg">
<i className="fas fa-check-circle" /> Paiement confirmé ! Votre commande est en cours de traitement.
</div>
)}
</div>
<div className="confirmation-modal-actions">
<button className="confirmation-button confirmation-button--neutral" onClick={handleCryptoModalClose}>
<i className="fas fa-location-arrow" /> Suivre ma commande
</button>
</div>
</div>
</div>
)}
{/* ============================================ */}
{/* MODAL ZONE NON DESSERVIE */}
{/* ============================================ */}
{showZoneModal && (
<div className="confirmation-modal-overlay" onClick={() => setShowZoneModal(false)}>
<div className="confirmation-modal" onClick={(e) => e.stopPropagation()}>
<div className="confirmation-modal-header confirmation-modal-header--error">
<i className="fas fa-map-marker-alt confirmation-icon"></i>
<h2>Zone non desservie</h2>
</div>
<div className="confirmation-modal-body">
<div className="confirmation-section">
<div className="confirmation-detail confirmation-detail--centered">
<p style={{ marginBottom: '0.75rem' }}>{zoneErrorMsg}</p>
<p className="confirmation-detail--muted">
Vérifiez l'adresse saisie ou contactez-nous pour connaître les zones de livraison disponibles.
</p>
</div>
</div>
</div>
<div className="confirmation-modal-actions">
<button className="confirmation-button confirmation-button--neutral" onClick={() => setShowZoneModal(false)}>
<i className="fas fa-arrow-left"></i> Modifier l'adresse
</button>
</div>
</div>
</div>
)}
{/* ============================================ */}
{/* MODAL DE CONFIRMATION STYLISÉ */}
{/* ============================================ */}
{showConfirmation && confirmationData && (
<div className="confirmation-modal-overlay" onClick={handleCloseConfirmation}>
<div className="confirmation-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="confirmation-modal-header">
<i className="fas fa-check-circle confirmation-icon"></i>
<h2>Commande Confirmée</h2>
</div>
{/* Body */}
<div className="confirmation-modal-body">
{/* Numéro de commande */}
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-receipt icon"></i>
Détails de la commande
</div>
<div className="confirmation-detail">
<strong>Numéro:</strong> #{confirmationData.client_order_number ?? confirmationData.command_id}
</div>
<div className="confirmation-detail">
<strong>Date:</strong> {new Date().toLocaleString('fr-FR')}
</div>
</div>
{/* Informations client */}
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-user icon"></i>
Informations client
</div>
<div className="confirmation-detail">
<strong>Nom:</strong> {confirmationData.clientInfo.first_name} {confirmationData.clientInfo.last_name}
</div>
<div className="confirmation-detail">
<strong>Téléphone:</strong> {confirmationData.clientInfo.phone}
</div>
</div>
{/* Adresse de livraison */}
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-map-marker-alt icon"></i>
Adresse de livraison
</div>
<div className="confirmation-detail">
{confirmationData.delivery_address}
</div>
</div>
{/* Parrainage utilisé */}
{confirmationData.referral_used != null && confirmationData.referral_used > 0 && (
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-gift icon"></i>
Parrainage appliqué
</div>
<div className="confirmation-detail">
<strong>Crédit utilisé:</strong> -{confirmationData.referral_used.toFixed(2)}
</div>
</div>
)}
{/* Suivi */}
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-map-marker-alt icon"></i>
Suivi de livraison
</div>
<div className="confirmation-detail">
Suivez votre livraison en temps réel depuis la page Suivi
</div>
</div>
{/* Total */}
<div className="confirmation-total">
<div className="confirmation-total-label">
<i className="fas fa-euro-sign"></i> TOTAL
</div>
<div className="confirmation-total-amount">{confirmationData.total.toFixed(2)} </div>
</div>
</div>
{/* Actions */}
<div className="confirmation-modal-actions">
<button className="confirmation-button" onClick={handleCloseConfirmation}>
<i className="fas fa-location-arrow"></i> Suivre ma livraison
</button>
</div>
</div>
</div>
)}
</>
);
}
export default Checkout;